refactor(web): migrate all web providers to image_gen-style plugin architecture#25448
Merged
Conversation
Adds plugins/web/brave_free/ as the first plugin built against the new
WebSearchProvider ABC. Mirrors the plugins/image_gen/openai/ layout exactly:
plugins/web/brave_free/
plugin.yaml kind: backend, provides_web_providers: [brave-free]
__init__.py register(ctx) -> ctx.register_web_search_provider(...)
provider.py BraveFreeWebSearchProvider(WebSearchProvider)
Behavior preserved: same name ("brave-free" with hyphen), same env var
(BRAVE_SEARCH_API_KEY), same HTTP request shape, same response normalization.
The legacy tools/web_providers/brave_free.py is left in place — the
dispatcher in tools/web_tools.py still references it. Task 7 cuts over the
dispatcher to the new registry; Task 10 deletes the legacy file.
E2E verified:
HERMES_PLUGINS_DEBUG=1 python -c "
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
from agent.web_search_registry import list_providers
print([p.name for p in list_providers()])
"
# -> ['brave-free']
Adds plugins/web/ddgs/ following the same plugins/image_gen/ pattern as brave_free. DuckDuckGo search via the community ddgs package; no API key, package is an optional dep gated by is_available(). E2E verified — registry now has ['brave-free', 'ddgs'].
Adds plugins/web/searxng/. SearXNG aggregates results from upstream engines via its JSON API (/search?format=json) — search-only, no extract capability (supports_extract() returns False). E2E verified — registry now has ['brave-free', 'ddgs', 'searxng'].
The three migrated providers (brave-free, ddgs, searxng) are now dispatched
through agent.web_search_registry.get_provider() instead of importing
their concrete classes directly. The four inline providers (parallel, exa,
tavily, firecrawl) keep their existing branches — they live in
tools/web_tools.py itself and aren't part of this spike's plugin extraction.
The legacy tools/web_providers/{brave_free,ddgs,searxng}.py modules are
still in place (untouched by this commit) — Task 10 deletes them once the
real migration PR is ready. Keeping them alive during the spike means
revertibility is trivial.
E2E verified:
1. Plugin discovery registers ['brave-free','ddgs','searxng']
2. Config web.search_backend: brave-free resolves to the plugin instance
3. Dispatch result matches the original {success, data.web[]} contract
4. compile OK; no new LSP errors beyond pre-existing ones in web_tools.py
Adds _plugin_web_search_providers() and wires it into _visible_providers() for the "Web Search & Extract" category. Mirrors the existing image_gen pattern at the same site exactly. Spike scope: while the three migrated providers (brave-free, ddgs, searxng) still have hardcoded TOOL_CATEGORIES rows, _WEB_PLUGIN_SKIPLIST excludes them so the picker doesn't show duplicates. The migration PR drops the hardcoded rows and the skip-list both — then this helper is the only source of web-provider picker rows. E2E verified: helper returns [] today (skip-list covers all 3 migrated providers); injection point is sound and ready for the post-migration state.
Deletes tools/web_providers/{brave_free,ddgs,searxng}.py — the three
providers that moved to plugins/web/ in prior commits. tools/web_tools.py
no longer imports them (registry dispatch as of d873596), so removing
them is purely a cleanup pass.
Also migrates the existing tests to the new import paths:
tests/tools/test_web_providers_brave_free.py
tests/tools/test_web_providers_ddgs.py
tests/tools/test_web_providers_searxng.py
Mechanical rewrites:
- `from tools.web_providers.X import YSearchProvider`
-> `from plugins.web.X.provider import YWebSearchProvider`
- `.is_configured()` -> `.is_available()` (legacy method -> new method)
- `.provider_name()` -> `.name` (legacy method -> new property)
- `from tools.web_providers.base import WebSearchProvider`
-> `from agent.web_search_provider import WebSearchProvider`
(the subclass-check asserts membership in the new plugin-facing ABC)
- `sys.modules.delitem("tools.web_providers.ddgs")` updated to point at
`plugins.web.ddgs.provider` (cache-busting for lazy ddgs imports)
The TestXBackendWiring / TestXSearchOnlyErrors classes (covering
_is_backend_available, _get_backend, check_web_api_key, and the
"search-only" error paths in web_extract/web_crawl) are untouched —
those still test web_tools.py's backend-selection logic, which continues
to recognize the names "brave-free" / "ddgs" / "searxng" even after the
modules behind them moved to plugins.
tools/web_providers/base.py is intentionally NOT deleted by this commit
— it's the parent ABC of the legacy modules and shares its name with
agent/web_search_provider.py::WebSearchProvider. Removing it surfaces the
naming collision (see PR description Finding 0); the real migration PR
deletes it in the same commit that drops the _WEB_PLUGIN_SKIPLIST
guards in hermes_cli/tools_config.py.
Test results:
bash scripts/run_tests.sh tests/tools/test_web_providers_*.py
-> 65 passed in 3.41s (all rewritten unit tests + unchanged integration tests)
bash scripts/run_tests.sh tests/tools/test_web_*.py
-> 141 passed in 4.70s (full web test set, post-deletion)
…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 (#25182 finding #1) and is
fold-in to the same PR rather than a follow-up.
Two ABC additions to cover the surface area of the remaining four providers (exa, parallel, tavily, firecrawl) which were untouched by the initial spike: 1. supports_crawl() + crawl() — Tavily natively crawls a seed URL via its /crawl endpoint. Exposing supports_crawl=True lets the crawl tool's dispatcher route to Tavily when configured, falling back to the auxiliary-model summarization path otherwise. Firecrawl could add this in a follow-up (the SDK supports it; we just don't surface it as a tool today). 2. Async-or-sync extract() — Parallel's SDK is natively async (AsyncParallel.beta.extract); Exa and Tavily are sync; Firecrawl is sync but called inside asyncio.to_thread() with a 60s timeout. The ABC docstring now permits either shape: implementations declare their own sync/async signature and the dispatcher uses inspect.iscoroutinefunction to detect and await. Also adds get_active_crawl_provider() to web_search_registry mirroring the search/extract resolvers, with web.crawl_backend as the explicit override config key. No behavior change on its own — these are scaffolds for the four remaining provider migrations.
…tract)
Migrates Exa from the inline `_exa_search()` / `_exa_extract()` helpers in
tools/web_tools.py to a bundled plugin at plugins/web/exa/.
This is the first plugin in this PR to advertise supports_extract=True,
exercising the multi-capability ABC path that the initial three migrations
(brave_free, ddgs, searxng — all search-only) did not cover.
Both Exa methods are sync — the SDK is sync-only. The web_extract_tool
dispatcher in tools/web_tools.py will continue to call them inline until
Task "dispatch-extract-all" cuts it over to the registry.
Behaviour preserved bit-for-bit aside from the ABC method-name change:
- is_configured() -> is_available()
- provider_name() -> name (property)
- "exa" stays as the registered name
- Module-level `_exa_client` cache + lazy `from exa_py import Exa`
preserved at the new location.
- Errors (ValueError for missing API key, ImportError for missing SDK,
generic Exception) caught and surfaced as {"success": False, "error": ...}
instead of raising.
Adds "exa" to _WEB_PLUGIN_SKIPLIST in hermes_cli/tools_config.py so the
hardcoded TOOL_CATEGORIES["web"] row and the plugin-injected row don't
duplicate during the spike. The skip-list goes away in the cleanup phase
along with the hardcoded row.
The legacy inline `_exa_search` / `_exa_extract` / `_get_exa_client` /
`_exa_client` in tools/web_tools.py are NOT deleted yet — the dispatcher
still references them. They go away in the next dispatcher-cutover commit.
E2E verified:
- Plugin discovers + registers
- .supports_search/.supports_extract/.supports_crawl = (True, True, False)
- .get_setup_schema() returns the picker row shape
- resolve(): explicit exa + EXA_API_KEY -> exa; without key -> exa (registered
but unavailable, dispatcher surfaces "EXA_API_KEY not set" error)
Migrates Parallel.ai from inline `_parallel_search()` / `_parallel_extract()`
in tools/web_tools.py to a bundled plugin at plugins/web/parallel/.
First plugin in the codebase to expose an async :meth:`extract`:
- search() is sync — Parallel.beta.search
- extract() is **async def** — AsyncParallel.beta.extract
The ABC's docstring on supports_extract() already permits sync-or-async;
this commit is the first to exercise the async path. The web_extract_tool
dispatcher (next commit) detects coroutines via
inspect.iscoroutinefunction and awaits accordingly.
Behavior preserved:
- PARALLEL_API_KEY required (raises ValueError if missing → surfaced
as {"success": False, "error": "..."} instead)
- PARALLEL_SEARCH_MODE env var honored (agentic|fast|one-shot, default
agentic), validated via _resolve_search_mode()
- Limit capped at 20 server-side via min(limit, 20)
- Per-URL failure mode preserved: response.errors[] each become a
result dict with an "error" field rather than raising
- Module-level _parallel_client / _async_parallel_client caches kept
(mirrors legacy singleton pattern)
Adds "parallel" to _WEB_PLUGIN_SKIPLIST in hermes_cli/tools_config.py so
the picker doesn't double-list.
The legacy inline _parallel_search, _parallel_extract, _get_parallel_client,
_get_async_parallel_client in tools/web_tools.py are NOT deleted yet — the
dispatcher still calls them. They go away when the dispatcher cuts over.
E2E verified:
- inspect.iscoroutinefunction(p.search) -> False
- inspect.iscoroutinefunction(p.extract) -> True
- extract() returns a coroutine (not a list)
- 5 providers register correctly (brave-free, ddgs, exa, parallel, searxng)
…tract + crawl)
Migrates Tavily from inline _tavily_request() / _normalize_tavily_*
helpers in tools/web_tools.py to a bundled plugin at plugins/web/tavily/.
First plugin in the codebase to advertise supports_crawl=True. Tavily is
unique among built-in backends in offering a native /crawl endpoint that
walks linked pages from a seed URL with optional natural-language
instructions and depth ("basic" or "advanced").
Capabilities:
- supports_search() -> True (Tavily /search)
- supports_extract() -> True (Tavily /extract)
- supports_crawl() -> True (Tavily /crawl)
All sync (httpx.post under the hood).
The crawl method accepts forward-compat kwargs (instructions, depth,
limit) and is gated against unsafe URLs/policy by the dispatcher in
web_crawl_tool — exactly as before.
Behavior preserved:
- TAVILY_API_KEY required (ValueError → typed error response)
- TAVILY_BASE_URL env override honored
- /crawl requires both body auth AND Bearer header — preserved
- failed_results[] and failed_urls[] response keys mapped to per-URL
items with error fields rather than raising
- max_results capped at 20 server-side
Adds "tavily" to _WEB_PLUGIN_SKIPLIST.
The legacy inline _tavily_request / _normalize_tavily_search_results /
_normalize_tavily_documents / _TAVILY_BASE_URL in tools/web_tools.py are
NOT deleted yet — search/extract dispatch and the entire web_crawl_tool
function still reference them. They go away when those dispatchers are
cut over to the registry.
E2E verified:
- Tavily registers with all 3 capabilities
- Provider list now: brave-free, ddgs, exa, parallel, searxng, tavily
…ct + dual auth)
Migrates Firecrawl from inline code in tools/web_tools.py to a bundled
plugin at plugins/web/firecrawl/. By line count this is the largest of
the seven provider migrations: the firecrawl path captured most of the
file's vendor-specific complexity.
What moved into the plugin (all previously in tools/web_tools.py):
Lazy Firecrawl SDK proxy
- _load_firecrawl_cls() — caches the imported SDK class
- _FirecrawlProxy + Firecrawl singleton — defers ~200ms of SDK
imports until first construction or isinstance check.
Client construction (dual auth)
- _get_direct_firecrawl_config() — direct FIRECRAWL_API_KEY/URL path
- _get_firecrawl_gateway_url() — managed Nous tool-gateway URL
- _is_tool_gateway_ready() — gateway URL + Nous token check
- _has_direct_firecrawl_config() — direct config present?
- _get_firecrawl_client() — combined client construction
honoring web.use_gateway
- check_firecrawl_api_key() — top-level "is firecrawl usable"
- _firecrawl_backend_help_suffix() — managed-gateway help string
- _raise_web_backend_configuration_error() — typed misconfig error
Response shape normalization (vendor-specific)
- _to_plain_object(), _normalize_result_list() — SDK→dict helpers
- _extract_web_search_results() — handles SDK/direct/gateway shapes
- _extract_scrape_payload() — nested-data unwrap for scrape
Per-URL extract loop
- 60s asyncio.wait_for timeout per URL
- Pre-scrape website-policy gate
- Post-scrape redirect-aware SSRF re-check
- Format-aware content selection (markdown / html / auto)
- Per-URL errors returned as {"error": str} entries, no raises
Extract is declared `async def` — each URL is scraped in
asyncio.to_thread(...). This is the second async-extract plugin after
parallel.
The plugin re-exports `Firecrawl` (the lazy proxy) and
`check_firecrawl_api_key()` so existing tests doing
`patch("tools.web_tools.Firecrawl")` or
`monkeypatch.setattr(web_tools, "check_firecrawl_api_key", ...)` keep
working — tools/web_tools.py re-exports both names in the next
dispatcher-cutover commit.
Note: web_crawl_tool still has its own Firecrawl crawl path inline
(separate from extract); the Firecrawl SDK supports /crawl but we don't
expose supports_crawl=True on this plugin yet. Tavily handles crawl
today. Adding Firecrawl crawl is a clean follow-up.
Adds "firecrawl" to _WEB_PLUGIN_SKIPLIST.
E2E verified:
- All 7 providers register: brave-free, ddgs, exa, firecrawl,
parallel, searxng, tavily
- inspect.iscoroutinefunction(firecrawl.extract) -> True
- Firecrawl proxy is a callable lazy proxy at module level
- check_firecrawl_api_key reflects FIRECRAWL_API_KEY presence
Cuts over web_search_tool, web_extract_tool, and web_crawl_tool in
tools/web_tools.py to dispatch through agent.web_search_registry
instead of the legacy hardcoded if-elif backend chains.
Per-tool changes:
web_search_tool (sync)
Replace 5 backend branches (parallel, exa, registry-3-providers,
tavily, firecrawl-fallthrough) with a single registry path:
1. _get_search_backend() resolves the configured name
2. _wsp_get_provider(name) for explicit-config-wins semantics
3. get_active_search_provider() fallback for typo / unknown name
4. provider.search(query, limit) — sync for all 7 providers
web_extract_tool (async)
Replace 4 backend branches (parallel-async, exa-sync, tavily-sync,
search-only-error, firecrawl-perurl-loop) with:
1. Same provider resolution as search.
2. When configured backend IS registered but doesn't support
extract (search-only providers like brave-free), surface a
typed "search-only" error matching the legacy text — tests
assert that wording.
3. inspect.iscoroutinefunction(provider.extract) detects sync vs
async: parallel + firecrawl are async; exa + tavily are sync.
Sync extracts run in asyncio.to_thread() so we don't block.
web_crawl_tool (async)
Replace tavily-specific branch + search-only-error block with:
1. _wsp_get_provider(backend) — explicit config first
2. Search-only typed error when the configured name doesn't
support crawl (matches legacy phrasing)
3. get_active_crawl_provider() fallback otherwise
4. provider.crawl(url, **kwargs) — async-or-sync dispatch as above
5. Response post-processing (LLM summarization, trimming) stays
unchanged — it's not provider-specific.
When no plugin advertises supports_crawl, falls through to the
existing Firecrawl-via-web-summarize path below (unchanged).
Test updates (2 tests in tests/tools/test_web_tools_config.py):
- test_web_search_clamps_limit_before_backend_call:
patch("tools.web_tools._parallel_search") -> patch the registry
provider returned by agent.web_search_registry.get_provider
- test_search_error_response_does_not_expose_diagnostics:
patch("tools.web_tools._get_firecrawl_client") -> same pattern
Tests unchanged (still pass):
- All TestXBackendWiring classes (test _get_backend / _is_backend_available
config-resolution, independent of dispatch)
- All TestXSearchOnlyErrors classes (test the search-only error path
via web_extract_tool / web_crawl_tool — error text preserved)
- 141 passing web tests total, 0 regressions.
Dead-code cleanup deferred to a follow-up commit so this diff stays
focused on the cutover. After this commit:
- tools.web_tools._exa_search / _exa_extract / _parallel_search /
_parallel_extract / _tavily_request / _normalize_tavily_* /
_get_firecrawl_client / _extract_web_search_results /
_extract_scrape_payload / _to_plain_object / _normalize_result_list
are no longer called by the dispatchers, but still exist.
- The config-resolution layer (_get_backend, _is_backend_available,
_is_tool_gateway_ready, _has_direct_firecrawl_config) IS still in
use and must stay.
- The Firecrawl proxy and check_firecrawl_api_key are still imported
by integration tests and patched by unit tests — must stay (or be
re-exported from the plugin).
Two regressions discovered by running the full tests/tools/ suite after
the dispatcher cutover, both fixed in this commit:
1. web_crawl_tool incorrectly errored "search-only" for firecrawl
---------------------------------------------------------------------
The cutover treated any provider with supports_crawl()==False as a
search-only backend and returned the typed search-only error. But
firecrawl can crawl via the legacy multi-page-extract path inside
web_crawl_tool — it just doesn't expose supports_crawl on the plugin
(adding native firecrawl crawl is a clean follow-up).
Fix: only emit the search-only error when the provider supports
NEITHER crawl NOR extract (brave-free / ddgs / searxng). When the
provider supports extract but not crawl (firecrawl), fall through to
the legacy firecrawl-via-extract path below.
2. firecrawl plugin's check_website_access wasn't patchable
---------------------------------------------------------------------
The plugin imported `from tools.website_policy import check_website_access`
INSIDE the extract() function body, so monkeypatching the name on
plugins.web.firecrawl.provider had no effect — the inner import re-bound
the name on every call.
Fix: hoist the import to module level. Cheap (website_policy itself
has no heavy deps) and makes the standard
monkeypatch.setattr(firecrawl_provider, "check_website_access", ...)
pattern work.
Test updates (tests/tools/test_website_policy.py — 4 tests):
- test_web_extract_short_circuits_blocked_url
- test_web_extract_blocks_redirected_final_url
Both: patch the gate at plugins.web.firecrawl.provider (where it
runs after migration) and force the firecrawl plugin to be the
active extract provider via FIRECRAWL_API_KEY.
- test_web_crawl_short_circuits_blocked_url
- test_web_crawl_blocks_redirected_final_url
Both: unchanged — the dispatcher-level gate at tools.web_tools.py
line 1651 still uses the imported `check_website_access` name and
the firecrawl-fallthrough path is exercised as before.
Verified: 22/22 tests/tools/test_website_policy.py pass.
Removes ~580 lines of dead code from tools/web_tools.py that were superseded by the plugin migration but kept around in the cutover commit to keep the diff focused. Replaces them with thin re-export shims so existing tests and external callers that reach for the legacy ``tools.web_tools.<name>`` paths continue to work transparently. Deleted from tools/web_tools.py -------------------------------- - Lazy Firecrawl SDK proxy (_load_firecrawl_cls, _FirecrawlProxy, _FIRECRAWL_CLS_CACHE, the Firecrawl singleton) - Firecrawl client section (_get_direct_firecrawl_config, _get_firecrawl_gateway_url, _is_tool_gateway_ready, _has_direct_firecrawl_config, _raise_web_backend_configuration_error, _firecrawl_backend_help_suffix, _get_firecrawl_client) - Parallel client section (_get_parallel_client, _get_async_parallel_client, _parallel_client, _async_parallel_client) - Tavily client section (_TAVILY_BASE_URL, _tavily_request, _normalize_tavily_search_results, _normalize_tavily_documents) - Generic SDK normalizers (_to_plain_object, _normalize_result_list, _extract_web_search_results, _extract_scrape_payload) - Exa client section (_get_exa_client, _exa_client, _exa_search, _exa_extract) - Parallel helpers (_parallel_search, _parallel_extract) - Duplicate inline check_firecrawl_api_key Net: tools/web_tools.py drops from 2227 → 1613 lines (-614 lines). Re-exports added at top of tools/web_tools.py --------------------------------------------- - From plugins.web.firecrawl.provider: Firecrawl, _FirecrawlProxy, _FIRECRAWL_CLS_CACHE, _load_firecrawl_cls, _get_direct_firecrawl_config, _get_firecrawl_gateway_url, _is_tool_gateway_ready, _has_direct_firecrawl_config, _firecrawl_backend_help_suffix, _raise_web_backend_configuration_error, _get_firecrawl_client, _to_plain_object, _normalize_result_list, _extract_web_search_results, _extract_scrape_payload, check_firecrawl_api_key - From plugins.web.tavily.provider: _tavily_request, _normalize_tavily_search_results, _normalize_tavily_documents - From plugins.web.parallel.provider: _get_parallel_client, _get_async_parallel_client - From plugins.web.exa.provider: _get_exa_client Plus retained module-level imports for backward-compat with tests: - httpx (tests patch tools.web_tools.httpx for tavily request mocking) - build_vendor_gateway_url, _read_nous_access_token, resolve_managed_tool_gateway, managed_nous_tools_enabled, prefers_gateway (tests patch tools.web_tools.<name>) Plugin indirection pattern (key technique) ------------------------------------------ For functions inside the firecrawl/parallel/exa plugins to honor unit-test patches that target ``tools.web_tools.<name>``, the plugin implementations now do ``import tools.web_tools as _wt`` at call time and read helper names through that module (``_wt._read_nous_access_token``, ``_wt.Firecrawl``, ``_wt.prefers_gateway``, etc.). This makes the existing test patches transparently reach the plugin code without any test changes. The cached client globals (_firecrawl_client, _firecrawl_client_config, _parallel_client, _async_parallel_client, _exa_client) also now live on tools.web_tools so existing test setup_method handlers that reset ``tools.web_tools._<vendor>_client = None`` between cases keep working. The plugins read/write the cache via getattr/setattr on the web_tools module. Verified -------- - 173/173 targeted web tests pass: test_web_providers.py, test_web_providers_brave_free.py, test_web_providers_ddgs.py, test_web_providers_searxng.py, test_web_tools_config.py, test_web_tools_tavily.py, test_website_policy.py, test_config_null_guard.py - Compile-clean (py_compile.compile passes) - All inline implementations now exist in exactly one place (plugins.web.<vendor>.provider) Follow-up clean-up ------------------ - Drop _WEB_PLUGIN_SKIPLIST + hardcoded TOOL_CATEGORIES["web"] rows (next commit) - Delete tools/web_providers/ directory entirely - Add tests/plugins/web/ coverage - Full tests/tools/ + tests/gateway/ regression sweep before promoting PR
…re sole source
Removes the seven hardcoded TOOL_CATEGORIES["web"] provider rows that
duplicated the plugin-registered providers, and deletes the
_WEB_PLUGIN_SKIPLIST that existed to prevent duplicate picker rows
during the migration. The Web Search & Extract category now derives its
provider rows entirely from agent.web_search_registry via
_plugin_web_search_providers(), matching how Spotify, Google Meet, and
the image_gen plugins are surfaced.
Removed (deduplicated against plugin schemas):
- Firecrawl Cloud → plugins.web.firecrawl
- Exa → plugins.web.exa
- Parallel → plugins.web.parallel
- Tavily → plugins.web.tavily
- SearXNG → plugins.web.searxng
- Brave Search (Free Tier) → plugins.web.brave_free
- DuckDuckGo (ddgs) → plugins.web.ddgs (post_setup hook preserved)
Retained in TOOL_CATEGORIES["web"]:
- Nous Subscription — requires requires_nous_auth +
managed_nous_feature + override_env_vars
to drive the managed-gateway UX. Not a
provider — a different *setup flow* for the
firecrawl backend.
- Firecrawl Self-Hosted — points firecrawl at a private Docker URL
via FIRECRAWL_API_URL only. Same reason:
UX setup-flow row, not a provider.
These two rows describe alternative auth/billing paths for the
firecrawl backend; they intentionally share web_backend="firecrawl"
with the plugin row but light up different env-var prompts.
Plugin schema extensions
------------------------
- ddgs plugin's get_setup_schema() now emits `post_setup: "ddgs"` so
selection still triggers the pip-install hook in _run_post_setup().
- _plugin_web_search_providers() passes `post_setup` through verbatim
when present in the schema (other future plugins like camofox / a
hypothetical playwright-web plugin can opt in the same way).
- Picker rows now carry both `web_backend` (legacy field consumed by
setup + selection helpers) and `web_search_plugin_name`
(informational marker), so behavior is identical between hardcoded
and plugin-registered rows.
Net diff
--------
- hermes_cli/tools_config.py: -141/+50 lines (~91 lines net)
- plugins/web/ddgs/provider.py: +7/-4 (post_setup field + badge polish)
Verified
--------
- Compile-clean for both files
- Picker shows: 2 hardcoded rows (Nous Subscription, Firecrawl
Self-Hosted) + 7 plugin rows (alphabetically: Brave Search,
DuckDuckGo, Exa, Firecrawl, Parallel, SearXNG, Tavily). DuckDuckGo
row carries post_setup="ddgs" for first-time install.
- 173 web-specific tests still pass.
… ABC tests Removes the legacy in-tree provider scaffolding that PR #25182 fully replaced with the plugin architecture: tools/web_providers/__init__.py (6 lines) tools/web_providers/base.py (89 lines — old ABCs) tools/web_providers/ARCHITECTURE.md (73 lines — old design doc) These were the staging-ground ABCs and provider modules that the plugin migration absorbed. All seven web providers now implement the single :class:`agent.web_search_provider.WebSearchProvider` ABC and live under ``plugins/web/<vendor>/``. Nothing else in the tree imports ``tools.web_providers`` — verified via grep before deletion. Test migration (tests/tools/test_web_providers.py) -------------------------------------------------- Rewrote ``TestWebProviderABCs`` to test the new unified ABC at :mod:`agent.web_search_provider`: - test_cannot_instantiate_abc_directly — abstract ``name`` + ``is_available`` - test_concrete_search_only_provider_works — exercise default ``supports_extract=False`` / ``supports_crawl=False`` flags - test_concrete_multi_capability_provider_works — exercise all three capabilities, async extract supported (declared sync here for simplicity; real plugins like parallel + firecrawl use async) - test_search_only_provider_skips_extract_and_crawl — verify ``supports_*()`` flags default to False so search-only providers don't have to implement extract() or crawl() The 9 other tests in the file (per-capability backend selection, DEFAULT_CONFIG merge, dispatcher routing) test public helpers in ``tools.web_tools`` that still exist and pass unchanged. agent/web_search_provider.py docstring updated to reflect that the legacy ABCs no longer exist; the response-shape contract is preserved bit-for-bit so external consumers see no behavioral change. Net diff -------- - tools/web_providers/ removed (-168 lines) - tests/tools/test_web_providers.py rewritten ABC section (+78/-30 net, same coverage, new API) - agent/web_search_provider.py docstring (-3/+5 lines) Verified -------- - 173/173 targeted web tests pass - 12/12 ABC contract tests pass with the new interface - No remaining grep hits for ``tools.web_providers`` outside of intentional historical references in plugin docstrings.
Adds 44 focused tests under tests/plugins/web/ covering the surface that the PR #25182 web-provider migration introduced. Complements the existing tests/tools/ coverage which is dispatcher-centric; this file is plugin-centric and tests each plugin + the registry directly. Test classes (44 tests, ~1.1s on 4 workers) ------------------------------------------- TestBundledPluginsRegister (16 tests) - All seven plugins present in the registry after _ensure_plugins_discovered() - Per-plugin parametrized capability-flag assertions (brave-free / ddgs / searxng: search-only; exa / parallel / firecrawl: search + extract; tavily: search + extract + crawl) - Every plugin exposes name + display_name properties - Every plugin returns a picker-compatible get_setup_schema() dict TestIsAvailable (7 tests) - Each premium plugin reports is_available()==False when its env var is absent and True once set (brave-free / searxng / tavily / exa / parallel) - firecrawl recognizes either FIRECRAWL_API_KEY or FIRECRAWL_API_URL as a "configured" signal - ddgs is the always-on fallback and must not raise from is_available() TestRegistryResolution (4 tests) - Option B semantics validated end-to-end: 1. Explicit configured provider wins even when is_available()==False (dispatcher surfaces typed credential errors, no silent switch) 2. Unknown/typo name falls back to first available legacy-preference provider 3. Asking for extract via a search-only backend falls back to an extract-capable available provider (capability-incompatible branch in _resolve()) 4. No config + no credentials → None (or ddgs if installed) TestAsyncExtractDispatch (4 tests) - parallel + firecrawl extract() are coroutine functions (async path in dispatcher uses await) - exa + tavily extract() are sync (dispatcher wraps in asyncio.to_thread) TestErrorResponseShapes (7 tests) - Plugins return typed error dicts (success=False + "error" key) when credentials are missing, never raise - async extract() returns list of per-URL error dicts - tavily crawl() returns {"results": [{"error": ...}]} on missing credentials Design notes ------------ - All tests use real imports of plugin modules — no mocking of provider classes themselves — so they catch drift in the ABC, registry, and glue layer simultaneously. Per the hermes-agent-dev skill's E2E testing guidance. - The autouse _isolate_env fixture clears every web-provider env var before each test so is_available() reflects the test's setup. - Resolution tests use the lower-level _resolve() directly rather than rebuilding the HERMES_HOME config dance — same observable behavior, no sys.modules.pop side-effects that would break the ABC isinstance check inside ctx.register_web_search_provider().
…line path
The web-provider migration originally left firecrawl crawl as the only
provider-specific code remaining inline in tools/web_tools.py (~250
lines of Firecrawl-specific crawl orchestration that didn't fit the
plugin's existing surface). This commit closes that gap.
What this adds
--------------
1. plugins/web/firecrawl/provider.py: implement async ``crawl(url, **kwargs)``
- Accepts the same kwargs as the dispatcher passes to any crawl
provider (``instructions``, ``depth``, ``limit``); Firecrawl's
/crawl endpoint ignores ``instructions`` and ``depth`` so we log
and drop with a clear info message.
- Wraps the sync SDK ``crawl()`` call in asyncio.to_thread so the
gateway event loop isn't blocked on a multi-page crawl.
- Preserves the response-shape normalization across pydantic /
typed-object / dict variants that the legacy inline code did.
- Preserves per-page website-policy re-check (catches blocked
redirects after the SDK returns).
- Returns the same {"results": [...]} shape so the dispatcher's
shared LLM-summarization post-processing path works unchanged.
- Sets supports_crawl() to True so the dispatcher routes through
the plugin instead of the legacy fallthrough.
2. tools/web_tools.py: delete the entire legacy firecrawl crawl block
that used to run after "No registered provider supports crawl" —
~270 lines including:
- check_firecrawl_api_key gate + typed error
- inline SSRF + website-policy seed-URL gate (dispatcher already
does this)
- Firecrawl client setup with crawl_params
- 100+ lines of pydantic/dict/typed-object normalization
- Per-page LLM-processing loop (kept in the dispatcher's shared
post-processing path; that's where it always belonged)
- trimming + base64 image cleanup (still done in the dispatcher's
shared path)
Replaced with a single typed-error branch when no crawl-capable
provider is available: "web_crawl has no available backend. Set
FIRECRAWL_API_KEY (or FIRECRAWL_API_URL for self-hosted), or set
TAVILY_API_KEY for Tavily."
Test updates
------------
- tests/tools/test_website_policy.py:
- test_web_crawl_short_circuits_blocked_url: dispatcher seed-URL
gate still runs on web_tools.check_website_access (no change to
that patch), but the firecrawl client lockdown moved to the
plugin module — patch firecrawl_provider._get_firecrawl_client
instead of web_tools._get_firecrawl_client. The dispatcher
short-circuits before the plugin runs, so the test still passes.
- test_web_crawl_blocks_redirected_final_url: patch the per-page
policy gate at plugins.web.firecrawl.provider.check_website_access
(where it now runs) AND on web_tools (where the seed-URL gate
still runs). Patch firecrawl_provider._get_firecrawl_client for
the FakeCrawlClient injection. Both checks flow through the same
fake_check function.
- tests/plugins/web/test_web_search_provider_plugins.py:
- Update parametrized capability-flag spec: firecrawl supports_crawl
is now True.
- Add test_firecrawl_crawl_returns_error_dict_when_unconfigured —
verifies inspect.iscoroutinefunction(p.crawl) is True and that
the async crawl returns a per-page error dict (not a raise) when
FIRECRAWL_API_KEY is missing.
Verified
--------
- 218/218 web tests pass (was 173, +44 plugin tests + 1 new firecrawl
crawl test from this commit = 218 with the test deduplication).
- Compile-clean (py_compile passes on both files).
- Provider capabilities matrix confirmed end-to-end:
name search extract crawl async-extract? async-crawl?
firecrawl True True True True True
tavily True True True False False
Both crawl-capable providers exercise the dispatcher's
inspect.iscoroutinefunction async-or-sync detection.
Net diff
--------
- tools/web_tools.py: -254 lines (legacy inline crawl gone)
- plugins/web/firecrawl/provider.py: +185 lines (crawl method)
- test_website_policy.py: +14/-9 lines (patch locations)
- test_web_search_provider_plugins.py: +22/-1 lines (capability flag
+ new firecrawl crawl test)
- Total: -32 net LoC; tools/web_tools.py is now 1509 lines (was 1763
before this commit, 2227 before the migration started).
… cleanup
Self-review of the plugin migration surfaced one warning and a handful of
doc/dead-code cleanups. None affect production behaviour through the main
dispatcher (which always calls `tools.web_tools._get_backend()` first and
preserves the full 7-provider walk), but direct callers of
`agent.web_search_registry.get_active_*_provider()` previously diverged
from the legacy order and could return `None` for users with credentials
but no explicit `web.backend` config key.
Changes
-------
1. `_LEGACY_PREFERENCE` was shipped as a 4-tuple
`("brave-free", "firecrawl", "searxng", "ddgs")` while the PR
description and the legacy `_get_backend()` candidate order both
call for the 7-tuple
`(firecrawl, parallel, tavily, exa, searxng, brave-free, ddgs)`.
Replaced with the 7-tuple. Verified empirically: with TAVILY+EXA keys
and no config, `get_active_search_provider()` now returns tavily
(was None); with EXA+PARALLEL it returns parallel (was None); with
BRAVE+FIRECRAWL it returns firecrawl (was brave-free).
2. `agent/web_search_registry.py` — module docstring, `_resolve` step-3
docstring, and inline comment all listed the old 4-tuple and claimed
"brave-free first because it was the shipped default". The legacy
default is `"firecrawl"`. Rewritten to match the new ordering and
reference `tools.web_tools._get_backend()` as the source of truth.
3. `agent/web_search_registry.py` — `get_active_crawl_provider`
docstring said "only Tavily implements it among built-in providers".
Firecrawl also advertises `supports_crawl=True` after the previous
commit. Updated to "Tavily and Firecrawl".
4. `plugins/web/tavily/provider.py` — module docstring said "Tavily is
the only built-in backend that natively crawls". Updated.
5. `agent/web_search_provider.py` — ABC docstring mentioned only
`search` / `extract` capabilities. Added `crawl` for accuracy.
6. `plugins/web/{firecrawl,parallel,exa}/provider.py` — dead plugin-level
cache globals (`_firecrawl_client`, `_parallel_client`,
`_async_parallel_client`, `_exa_client`) were declared but never read
(all reads/writes go through `_wt.*` per the `extracting-inline-
helpers-to-plugins` recipe). Removed the dead declarations; the
reset-for-tests helpers in firecrawl + parallel now clear the
canonical `_wt._<name>` slots, matching the pattern exa already used.
Tests
-----
218/218 web-targeted tests still pass (no test changes needed). 4910/4910
in `tests/tools/` still green.
Surfaced by local E2E behavior-parity testing of PR vs origin/main: the
plugin-migrated dispatchers were quietly changing the error envelope
shape returned to function-calling models on unconfigured systems.
Two findings, both from per-result error wrapping bleeding into the
pre-flight configuration error path:
1. **search**: ``firecrawl.search()`` caught the
``ValueError("Web tools are not configured...")`` from
``_get_firecrawl_client()`` and returned it as
``{"success": False, "error": ...}``, losing the legacy
``{"error": "Error searching web: ..."}`` envelope that
``tool_error()`` emits on main. Models that special-case the
``error`` key still detect the failure, but the prefix is part of
the legacy contract some users rely on.
2. **crawl**: ``firecrawl.crawl()`` caught the same pre-flight
``ValueError`` and wrapped it as a per-page error inside
``results[0]``. Main short-circuits on ``check_firecrawl_api_key()``
BEFORE dispatching, so its unconfigured response is
``{"success": False, "error": "web_crawl requires Firecrawl..."}``
at the top level. The PR's per-page burying hid the failure inside
``results[]`` where models that check ``result.get("error")`` would
miss it.
Fix:
- ``plugins/web/firecrawl/provider.py``: pull
``_get_firecrawl_client()`` outside the broad ``try`` in
``search()``. Pre-flight ``ValueError`` / ``ImportError`` propagate
to the dispatcher's top-level exception handler. In-flight SDK
errors still get wrapped as ``{"success": False, ...}``.
- ``tools/web_tools.py``: mirror main's upstream availability gate in
``web_crawl_tool``. When the resolved crawl provider is
``is_available()==False``, short-circuit BEFORE dispatching with the
same top-level error shape main emits.
- ``tests/tools/test_web_providers.py``: 2 regression tests
(``TestUnconfiguredErrorEnvelopeParity``) lock in the behavior so
future plugin work can't undo this.
Verified via local subprocess-based parity test (14/14 scenarios match
origin/main shape exactly) and full 210/210 web test suite green.
Contributor
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-import |
8 |
invalid-argument-type |
8 |
unresolved-attribute |
6 |
unsupported-operator |
3 |
call-non-callable |
1 |
First entries
plugins/web/firecrawl/provider.py:241: [unresolved-attribute] unresolved-attribute: Attribute `gateway_origin` is not defined on `None` in union `ManagedToolGatewayConfig | None`
tests/run_agent/test_provider_attribution_headers.py:90: [unresolved-attribute] unresolved-attribute: Attribute `startswith` is not defined on `dict[str, str]` in union `Unknown | str | Divergent | dict[str, str]`
plugins/web/ddgs/provider.py:47: [unresolved-import] unresolved-import: Cannot resolve imported module `ddgs`
tests/run_agent/test_provider_attribution_headers.py:156: [unsupported-operator] unsupported-operator: Operator `not in` is not supported between objects of type `Literal["X-OpenRouter-Cache-TTL"]` and `Unknown | str | dict[str, str] | ... omitted 4 union elements`
run_agent.py:7287: [invalid-argument-type] invalid-argument-type: Argument to function `_codex_cloudflare_headers` is incorrect: Expected `str`, found `Unknown | str | dict[str, str] | ... omitted 4 union elements`
run_agent.py:2429: [invalid-argument-type] invalid-argument-type: Argument to function `query_ollama_num_ctx` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | (dict[str, str] & ~AlwaysFalsy) | (Any & ~AlwaysFalsy) | ... omitted 5 union elements`
tests/run_agent/test_provider_attribution_headers.py:155: [unsupported-operator] unsupported-operator: Operator `not in` is not supported between objects of type `Literal["X-OpenRouter-Cache"]` and `Unknown | str | dict[str, str] | ... omitted 4 union elements`
plugins/web/firecrawl/provider.py:246: [unresolved-attribute] unresolved-attribute: Attribute `nous_user_token` is not defined on `None` in union `ManagedToolGatewayConfig | None`
run_agent.py:13672: [invalid-argument-type] invalid-argument-type: Argument to function `len` is incorrect: Expected `Sized`, found `(str & ~AlwaysFalsy) | (dict[Unknown, Unknown] & ~AlwaysFalsy) | (Any & ~AlwaysFalsy) | ... omitted 4 union elements`
run_agent.py:2688: [invalid-argument-type] invalid-argument-type: Argument to function `_is_oauth_token` is incorrect: Expected `str`, found `(Unknown & ~AlwaysFalsy) | (str & ~AlwaysFalsy) | (dict[str, str] & ~AlwaysFalsy) | ... omitted 5 union elements`
tests/agent/test_codex_cloudflare_headers.py:163: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str & ~AlwaysFalsy`, `int & ~AlwaysFalsy` in union `(Unknown & ~AlwaysFalsy) | (str & ~AlwaysFalsy) | (dict[str, str] & ~AlwaysFalsy) | ... omitted 4 union elements`
run_agent.py:2685: [invalid-argument-type] invalid-argument-type: Argument to function `build_anthropic_client` is incorrect: Expected `str`, found `(Unknown & ~AlwaysFalsy) | (str & ~AlwaysFalsy) | (dict[str, str] & ~AlwaysFalsy) | ... omitted 5 union elements`
run_agent.py:13669: [invalid-argument-type] invalid-argument-type: Argument to function `_is_oauth_token` is incorrect: Expected `str`, found `str | dict[Unknown, Unknown] | Any | ... omitted 4 union elements`
plugins/web/parallel/provider.py:110: [unresolved-import] unresolved-import: Cannot resolve imported module `parallel`
tests/agent/test_codex_cloudflare_headers.py:163: [unresolved-attribute] unresolved-attribute: Attribute `startswith` is not defined on `dict[str, str]` in union `Unknown | str | Divergent | dict[str, str]`
plugins/web/firecrawl/provider.py:88: [unresolved-import] unresolved-import: Cannot resolve imported module `firecrawl`
plugins/web/firecrawl/provider.py:670: [call-non-callable] call-non-callable: Object of type `object` is not callable
plugins/web/tavily/provider.py:51: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`
plugins/web/brave_free/provider.py:66: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`
run_agent.py:7458: [invalid-argument-type] invalid-argument-type: Argument to function `build_anthropic_client` is incorrect: Expected `str`, found `str | dict[Unknown, Unknown] | Any | ... omitted 4 union elements`
tools/web_tools.py:815: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `bool`, `str` in union `bool | str | dict[Unknown, Unknown] | Any`
plugins/web/exa/provider.py:70: [unresolved-import] unresolved-import: Cannot resolve imported module `exa_py`
plugins/web/searxng/provider.py:57: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`
tests/plugins/web/test_web_search_provider_plugins.py:27: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/agent/test_codex_cloudflare_headers.py:181: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["originator"]` and `(Unknown & ~AlwaysFalsy) | (str & ~AlwaysFalsy) | (dict[str, str] & ~AlwaysFalsy) | ... omitted 4 union elements`
... and 1 more
✅ Fixed issues (22):
| Rule | Count |
|---|---|
invalid-argument-type |
8 |
unresolved-attribute |
5 |
unresolved-import |
5 |
unsupported-operator |
3 |
call-non-callable |
1 |
First entries
tests/run_agent/test_provider_attribution_headers.py:156: [unsupported-operator] unsupported-operator: Operator `not in` is not supported between objects of type `Literal["X-OpenRouter-Cache-TTL"]` and `Unknown | str | dict[str, str] | ... omitted 3 union elements`
tools/web_tools.py:342: [unresolved-attribute] unresolved-attribute: Attribute `gateway_origin` is not defined on `None` in union `ManagedToolGatewayConfig | None`
tools/web_providers/brave_free.py:73: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`
tools/web_tools.py:1024: [unresolved-import] unresolved-import: Cannot resolve imported module `exa_py`
run_agent.py:2736: [invalid-argument-type] invalid-argument-type: Argument to function `get_model_context_length` is incorrect: Expected `str`, found `str | dict[str, str] | Any | ... omitted 3 union elements`
run_agent.py:7287: [invalid-argument-type] invalid-argument-type: Argument to function `_codex_cloudflare_headers` is incorrect: Expected `str`, found `Unknown | str | dict[str, str] | ... omitted 3 union elements`
tools/web_tools.py:347: [unresolved-attribute] unresolved-attribute: Attribute `nous_user_token` is not defined on `None` in union `ManagedToolGatewayConfig | None`
run_agent.py:2685: [invalid-argument-type] invalid-argument-type: Argument to function `build_anthropic_client` is incorrect: Expected `str`, found `(Unknown & ~AlwaysFalsy) | (str & ~AlwaysFalsy) | (dict[str, str] & ~AlwaysFalsy) | ... omitted 4 union elements`
tests/run_agent/test_provider_attribution_headers.py:90: [unresolved-attribute] unresolved-attribute: Attribute `startswith` is not defined on `dict[str, str]` in union `Unknown | str | dict[str, str]`
tests/agent/test_codex_cloudflare_headers.py:163: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str & ~AlwaysFalsy`, `int & ~AlwaysFalsy` in union `(Unknown & ~AlwaysFalsy) | (str & ~AlwaysFalsy) | (dict[str, str] & ~AlwaysFalsy) | ... omitted 3 union elements`
run_agent.py:2688: [invalid-argument-type] invalid-argument-type: Argument to function `_is_oauth_token` is incorrect: Expected `str`, found `(Unknown & ~AlwaysFalsy) | (str & ~AlwaysFalsy) | (dict[str, str] & ~AlwaysFalsy) | ... omitted 4 union elements`
run_agent.py:13672: [invalid-argument-type] invalid-argument-type: Argument to function `len` is incorrect: Expected `Sized`, found `(str & ~AlwaysFalsy) | (dict[Unknown, Unknown] & ~AlwaysFalsy) | (Any & ~AlwaysFalsy) | ... omitted 3 union elements`
tools/web_providers/ddgs.py:54: [unresolved-import] unresolved-import: Cannot resolve imported module `ddgs`
tests/agent/test_codex_cloudflare_headers.py:181: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["originator"]` and `(Unknown & ~AlwaysFalsy) | (str & ~AlwaysFalsy) | (dict[str, str] & ~AlwaysFalsy) | ... omitted 3 union elements`
tools/web_providers/searxng.py:72: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`
tools/web_tools.py:400: [unresolved-import] unresolved-import: Cannot resolve imported module `parallel`
tools/web_tools.py:1931: [call-non-callable] call-non-callable: Object of type `object` is not callable
tests/agent/test_codex_cloudflare_headers.py:163: [unresolved-attribute] unresolved-attribute: Attribute `startswith` is not defined on `dict[str, str]` in union `Unknown | str | dict[str, str]`
run_agent.py:2429: [invalid-argument-type] invalid-argument-type: Argument to function `query_ollama_num_ctx` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | (dict[str, str] & ~AlwaysFalsy) | (Any & ~AlwaysFalsy) | ... omitted 4 union elements`
run_agent.py:13669: [invalid-argument-type] invalid-argument-type: Argument to function `_is_oauth_token` is incorrect: Expected `str`, found `str | dict[Unknown, Unknown] | Any | ... omitted 3 union elements`
tests/run_agent/test_provider_attribution_headers.py:155: [unsupported-operator] unsupported-operator: Operator `not in` is not supported between objects of type `Literal["X-OpenRouter-Cache"]` and `Unknown | str | dict[str, str] | ... omitted 3 union elements`
run_agent.py:7458: [invalid-argument-type] invalid-argument-type: Argument to function `build_anthropic_client` is incorrect: Expected `str`, found `str | dict[Unknown, Unknown] | Any | ... omitted 3 union elements`
Unchanged: 4356 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
3 tasks
teknium1
added a commit
that referenced
this pull request
May 19, 2026
Catch the website docs up to two weeks of merged work (May 4 – May 18, 2026, roughly 1,080 PRs). The audit found ~50 user-visible features that had landed in code with no docs footprint, plus a handful of stale pages. This PR closes every gap the scan turned up. New pages - user-guide/features/deliverable-mode.md — extension list, agent triggers, kanban_complete artifacts pattern, [[as_document]] override (PR #27813). - developer-guide/web-search-provider-plugin.md — authoring guide modeled on image-gen-provider-plugin, covering brave_free / ddgs / etc. (PR #25448). Providers / auth - Rename "Alibaba Cloud" → "Qwen Cloud (Alibaba DashScope)" everywhere the display label shows up; provider id stays `alibaba` (PR #24835). - Document OAuth refresh-token quarantine for xAI / MiniMax / Codex (PRs #28116 / #28118 / #28119). - Document Nous JWT minting from refresh token + invalid-refresh quarantine + cross-profile shared token store (PRs #27663 / #19712). - Add `## Microsoft Entra ID authentication (keyless)` section to azure-foundry guide — DefaultAzureCredential, RBAC, OpenAI + Anthropic routing details (PR #28101 / #9df9816da). - Custom providers `api_mode` is now prompted-and-persisted, not just URL autodetected (PR #25068). - Delegation honours `api_mode` + auto-detects anthropic_messages base URLs (PR #26824). - `x_search` auto-enables when xAI credentials are present (PR #27376). - Add `xAI Grok OAuth (SuperGrok)` row to providers headline table (PR #26534). - NVIDIA NIM billing-origin header is set automatically (PR #26585). Windows / installer - `install.ps1`: document `-Commit <sha>` and `-Tag <v>` pin params plus the BOM-strip / git-retry hardening (PR #28169). - Document Hermes Desktop thin installer + first-launch bootstrap (PR #27822). - Document `dep_ensure` Windows bootstrap (PR #27845). - Document install-method auto-detection (pip / git / homebrew / nixos) and the matching update command (PR #27843). Gateway / messaging - `/platform list|pause|resume` full description + circuit-breaker semantics (PR #26600). - Slack / Matrix / Mattermost get parallel `allowed_channels` / `allowed_rooms` allowlist sections matching Telegram/Discord/DingTalk (PR #21251). - Discord `allow_any_attachment` + `max_attachment_bytes` (config and env vars) (PR #27245). - Discord clarify-choice button rendering (PR #25485). - Telegram `guest_mode` @mention bypass for allowlisted groups (PR #22759). - Telegram `notifications` mode (`important` vs `all`) (PR #22793). - `[[as_document]]` skill / response directive for forcing document-style media delivery (PR #21210). CLI / TUI - `/new [name]` argument (PR #19637). - `/subgoal` user-supplied criteria appended to `/goal` (PR #25449). - `/exit --delete` flag confirmation prompts for destructive slash commands (PR #22687). - Status-bar additions: ▶ N background indicator (PR #27175), context compression count (PR #21218), YOLO mode banner+statusbar warning (PR #26238). - `display.timestamps` + `docker_extra_args` config keys (PR #23599). - TUI collapsible startup banner sections (PR #20625). - `HERMES_SESSION_ID` exported to tool subprocesses (PR #23847). i18n - Refresh display.language locale list from 8 → 16 (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu) — matches `agent/i18n.py:SUPPORTED_LANGUAGES`. Tools / features - `vision_analyze` native-pixel passthrough for vision-capable callers, with auxiliary text-describer fallback (PR #22955). - `session_search` rewrite to the single-shape tool (discovery / scroll / browse modes) (PRs #27590 / #27840). - Clarify MCP transport scope: client supports stdio + SSE; embedded `hermes mcp serve` is stdio-only (PR #21227). - Web search backends table: add Brave Search (free tier) and DDGS rows (PR #21337). - ACP session-scoped edit auto-approval modes (PR #27862). - Curator rename map in the user-visible per-run summary (PR #22910). - Prompt caching feature page reference in features/overview.md — Claude cross-session 1-hour prefix cache on native Anthropic / OpenRouter / Nous Portal (PR #23828). - Cron per-job profile parameter (PR #28124). - `--no-skills` flag for `hermes profile create` (PR #20986). Build - Verified with `npm run build` in `website/`; both `en` and `zh-Hans` locales compile. Remaining broken-link/anchor warnings are pre-existing (`rl-training.md` from learning-path / overview; the zh-Hans translation lag the docs skill already calls out).
bot-ted
added a commit
to bot-ted/hermes-agent
that referenced
this pull request
May 20, 2026
* fix(kanban): ignore stale HERMES_KANBAN_BOARD for removed boards
* fix(kanban): keep board-management commands independent from board override
* fix(kanban): preserve notifier_profile for dashboard home subscriptions
* fix(kanban): promote dependents when a parent is archived
* fix(cli): make kanban specify max_tokens configurable
* fix(kanban): sync slash subcommands with live parser
* fix(kanban): promote blocked tasks when parent dependencies complete
recompute_ready only scanned 'todo' tasks for promotion, ignoring
'blocked' tasks entirely. When a task was blocked (e.g. by the circuit
breaker) and its parent dependencies later completed, the task stayed
stuck in 'blocked' forever unless manually unblocked.
Now recompute_ready also scans 'blocked' tasks. When all parents are
done/archived, the blocked task is promoted to 'ready' with failure
counters reset — equivalent to an automatic unblock.
Includes a regression test for the blocked-parent-done promotion path.
* fix(kanban): use 'is not None' check for max_runtime_seconds in create_task
max_runtime_seconds=0 was being silently coerced to None due to a falsy
check (if max_runtime_seconds). Zero is a valid value that causes the
dispatcher to immediately time out a task. The adjacent max_retries
parameter already used the correct 'is not None' pattern.
Fixes the inconsistency by aligning max_runtime_seconds with max_retries.
* fix(kanban): reset failure counters on unblock_task
When a task is manually unblocked (blocked → ready/todo), the
consecutive_failures counter and last_failure_error were left intact.
The next failure would immediately re-trip the circuit breaker because
the counter was still at or above the failure limit.
Reset both fields on unblock so the task gets a fresh retry budget.
Includes a regression test that verifies counters are zeroed.
* fix(kanban): fingerprint crash errors to prevent fleet-wide retry exhaustion
When a systemic failure (provider outage, auth expiry, OOM) crashes
multiple workers simultaneously, detect_crashed_workers increments
each task failure counter independently. The circuit breaker only
trips after N × failure_limit retries across the fleet.
Fingerprint crash errors by normalizing host-specific details (PIDs,
timestamps). When 3+ tasks crash with the same fingerprint in a
single detection cycle, immediately trip the circuit breaker
(failure_limit=1) instead of waiting for repeated failures.
Isolated crashes (unique fingerprints) retain their normal retry
budget. Protocol violations continue to trip immediately.
Includes regression tests for systemic and isolated crash paths.
* fix(kanban): align board_exists with board discovery rules
* fix(kanban): demote ready children when a parent is reopened
* fix(kanban): serialize DB initialization
* fix(kanban): task_age() tolerates ISO-8601 timestamps
Prevents ValueError crash in dashboard get_board() when a task has
an ISO timestamp (e.g. "2026-05-10T15:00:00Z") instead of a unix epoch
int. Adds _to_epoch() helper that normalises both formats.
* Fix Kanban dashboard initial board selection
* fix(kanban): persist worker session metadata on completion
Salvages #25579 by @wesleysimplicio. Stamps task_runs.metadata.worker_session_id
from HERMES_SESSION_ID on kanban_complete. Cherry-picked the substantive
commit (not the AUTHOR_MAP fixup tip) onto current main.
* fix(kanban): make claim ttl configurable
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(kanban): pass accept-hooks to worker chat subprocess
* feat(kanban): add board-level default workdir (#25430)
* docs(kanban-worker): document notification routing configuration
* fix(kanban): preserve worker tools with restricted toolsets
* fix(kanban): make legacy task migration idempotent
(cherry picked from commit 293f1c3a7241b0117669e049d9aa746c9645ac90)
* fix: harden Kanban worker Hermes command resolution
* feat(kanban): allow trimmed task comments
SS-1647 live SHIP validation: real code + tests for kanban comment --max-len.
* fix: show scheduled kanban tasks in dashboard
* fix: assign single-task kanban decompositions
* fix(kanban-dashboard): make Orchestration mode checkbox label static
The checkbox label echoed its state ("Auto (default)" / "Manual") instead
of describing the action, so a checked box reading "Auto" parsed as a
status indicator rather than a control. The accompanying sub-description
was also static and started with "When on, ...", which read awkwardly
when the box was unchecked.
Replace the dynamic label with a static action label
("Auto-decompose triage tasks") and flip the sub-description between the
two modes so it stays accurate either way. The top-of-page Orchestration
pill is unchanged — that one is intentionally a status badge / toggle.
Fixes #28178
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(env): add HERMES_KANBAN_DISPATCH_IN_GATEWAY override (#21956)
Salvages the env-vars docs portion of #21956 by @Bartok9.
The ascii-guard-ignore tags from the original PR already landed on main.
* fix(kanban): close sqlite connection on init failure to prevent fd leak
Salvages #28301 by @Ade5954. If WAL setup, PRAGMA application, or schema
init raises after sqlite3.connect() succeeds, the new connection was
leaking. Wrap the body in try/except so the connection is closed before
the exception propagates.
* fix(kanban): don't crash dispatched workers when kanban-worker skill is absent
Salvages #27372 by @oemtalks. The dispatcher unconditionally injected
`--skills kanban-worker` into every worker spawn, but worker profiles
sometimes don't have that bundled skill in their skills dir, which is
fatal at CLI startup (`ValueError: Unknown skill(s): kanban-worker`).
Adds `_kanban_worker_skill_available(hermes_home)` and only injects the
flag when the skill resolves. The MANDATORY lifecycle still ships via
KANBAN_GUIDANCE in the system prompt, so omitting the flag is safe.
* fix(packaging): ship dashboard plugin assets in wheel
Salvages #23737 by @LeonSGP43. Adds plugins/* manifest.json and dist/
glob entries to setuptools package-data so wheel installs ship the
bundled dashboard plugin assets (kanban, achievements, etc.). Without
these, /api/dashboard/plugins can't discover plugin assets outside a
source checkout.
* docs(kanban): document worker protocol auto-blocks
Salvages #21585 by @helix4u. Documents the protocol_violation event
(worker exits successfully while task is still running), adds
--max-retries to the create flag list and --failure-limit to dispatch.
* fix(oneshot): pass fallback_providers from profile config to AIAgent
Salvages #23368 by @uzunkuyruk. Oneshot workers (e.g. kanban workers
spawned via 'hermes -p <profile> chat -q ...') were not honouring the
profile's fallback_providers / fallback_model chain because oneshot.py
never read the config and never passed fallback_model= to AIAgent.
Reads cfg.get('fallback_providers') (new list format) or
cfg.get('fallback_model') (legacy single-dict) with the same
normalization cli.py applies, then forwards as fallback_model=_fb.
* fix(kanban): reject direct running transitions in dashboard bulk updates
Salvages #24050 by @kronexoi. The single-task PATCH already rejects
direct status='running' since it bypasses the dispatcher/claim invariant,
but the bulk-update endpoint still accepted it. Aligns bulk with single
by emitting an error result row for any 'running' entry.
* feat(kanban): add initial-status for human-ops cards
Salvages #27526 by @shunsuke-hikiyama. Adds an --initial-status flag
(running|blocked, default running) to 'kanban create', threaded through
kanban_db.create_task() and the kanban_create tool schema. 'blocked'
parks the task directly in the blocked column for R3 human-ops review,
skipping the brief running-to-blocked transition.
Dropped the unrelated 'add' alias, WIFEXITED Windows compat, and
slash-handler error formatting changes that were bundled in the
original PR — those should ship as their own focused changes if still
wanted.
* fix(kanban): release scratch workspace and tmux session on task completion
Salvages #27369 by @LeonJS. complete_task() now calls _cleanup_workspace()
and _cleanup_worker_tmux() after marking a task complete.
Scratch workspaces (used by swarm agents) accumulate on disk — hundreds
of MB per task, never released. Stale tmux sessions from completed
agents also persist indefinitely.
Both gates are safe:
- workspace_kind == 'scratch' gate preserves user worktree/dir workspaces
- tmux #{pane_dead} == 1 gate only kills sessions where the worker has
already exited
- best-effort: cleanup failures never block task completion
* fix(kanban): honor severity thresholds in diagnostics
Salvages #26431 by @LeonSGP43. Dashboard plugin_api list_diagnostics
was using exact-match (severity == filter), so '--severity warning'
hid 'error' and 'critical' diagnostics. Adds severity_at_or_above()
helper to kanban_diagnostics and uses it in the dashboard endpoint
(CLI already used SEVERITY_ORDER comparison correctly).
* test: isolate Kanban env pins in hermetic fixture
Salvages the substantive part of #22295 by @steezkelly. Adds the
missing HERMES_KANBAN_HOME, HERMES_KANBAN_RUN_ID, HERMES_KANBAN_CLAIM_LOCK,
HERMES_KANBAN_DISPATCH_IN_GATEWAY entries to _HERMES_BEHAVIORAL_VARS so
ambient developer-shell pins on those vars don't bleed into pytest runs.
The frozenset extraction + standalone regression test from the original
PR were dropped to keep the change minimal — main already maintains the
list inline.
* feat(kanban): add max_in_progress config to cap concurrent running tasks
Salvages #22981 by @SimbaKingjoe. Adds 'kanban.max_in_progress' config
that caps simultaneously running tasks. When the board already has N
running, dispatcher skips spawning so slow workers (local LLMs,
resource-constrained hosts) don't pile up and time out.
Threads through dispatch_once(max_in_progress=) and gateway dispatcher
config parsing with validation (warns on invalid/below-1 values).
* fix(packaging): ship bundled skills in wheel
Salvages #23738 by @LeonSGP43. Wheel installs were missing skills/ and
optional-skills/ because pyproject's [tool.setuptools.packages.find]
only includes Python packages — the skills directories don't have
__init__.py so they were silently dropped from the wheel.
Adds setup.py with data_files spec emitting skills/* and optional-skills/*
under hermes_agent-<v>.data/data/, and a get_bundled_skills_dir() helper
in hermes_constants that discovers the wheel-installed location via
sysconfig before falling back to a source-checkout path. tools/skills_sync
uses the helper so 'hermes update' works for pip-installed users.
* fix: 4 small surgical bugs
Salvages #23302 by @Bartok9. Four independent one-area fixes:
1. kanban boards delete alias now hard-deletes (not archives) — the
alias didn't carry --delete, so getattr(args, 'delete', False)
returned False. Detect boards_action=='delete' explicitly.
2. Gateway auto-title failures no longer leak as user-visible
warnings — debug-log only since they're not actionable.
3. Background process completion notification snaps truncation to
the next newline boundary, prepends a marker when content is
dropped.
4. _cprint() schedules the run_in_terminal coroutine via
asyncio.ensure_future so output isn't silently dropped from
background threads (fixes #23185 Bug A). Skips the
double-print fallback that would fire for mock paths.
* perf(prompt): cache kanban worker guidance at session init
Salvages #24402 by @RyanRana. The KANBAN_GUIDANCE block (~835 tokens)
is session-static — the dispatcher decides at spawn time whether the
process is a kanban worker via the kanban_show tool's check_fn (gated
on HERMES_KANBAN_TASK env var). Re-checking 'kanban_show' in
valid_tool_names and re-loading the reference on every system-prompt
rebuild (init + each context compression) is wasted work.
Caches the resolved string on agent._kanban_worker_guidance once in
agent_init and consumes it in system_prompt.build_system_prompt(),
with a getattr fallback for code paths that bypass agent_init.
* feat(kanban): add --sort option to 'hermes kanban list'
Salvages #25745 by @LizerAIDev. Adds --sort {created,created-desc,
priority,priority-desc,status,assignee,title,updated} to 'hermes kanban
list'. Validated against VALID_SORT_ORDERS map; invalid values raise
ValueError. Default behaviour (priority DESC, created ASC) is unchanged
when --sort is omitted.
* docs: add kanban codex lane skill
* feat(kanban): worker visibility endpoints (workers/active, runs/{id}, inspect)
Adds three read-only endpoints to the kanban dashboard plugin so the
SwitchUI workspace (and any other dashboard consumer) can track
workers across tasks without N+1 round-trips through /tasks/{task_id}.
- GET /workers/active
Single SQL JOIN of task_runs + tasks where ended_at IS NULL,
worker_pid IS NOT NULL, status='running'. Returns
{workers: [...], count, checked_at}.
- GET /runs/{run_id}
Direct lookup of any task_run row by id. Reuses existing
kanban_db.get_run() helper and _run_dict() serialiser. 404 when
not found. Mirrors GET /tasks/{task_id} 404 shape.
- GET /runs/{run_id}/inspect
Live PID stats via psutil.Process.as_dict() — cpu_percent,
memory_rss_bytes, memory_vms_bytes, num_threads, num_fds, status,
create_time, cmdline. Short-circuits with alive:false when run
has ended, has no worker_pid, the pid is gone, or psutil is
unavailable. AccessDenied surfaces as alive:true with error
rather than a 500.
11 new tests in tests/plugins/test_kanban_worker_runs.py cover the
empty-board case, running-task case, ended-run filtering,
missing-pid filtering, 404 paths, already-ended inspect, no-pid
inspect, dead-pid inspect, and live-pid inspect (psutil mocked).
All pass.
Companion termination endpoint (POST /runs/{run_id}/terminate) is
intentionally out of scope here — opening a separate issue first
since the RBAC and dispatcher-mediated soft-cancel design needs
maintainer input before code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(release): map contributor email for attribution check
* test(kanban-dashboard): pin enriched 409 detail and inline error wiring (#26744)
- Existing ``test_patch_drag_drop_move_todo_to_ready`` now asserts the
enriched 409 detail names the blocking parent (id, quoted title, and
current status), so the dashboard always has something actionable to
render.
- New bundle-assertion test ``test_dashboard_surfaces_ready_blocked_error_inline``
pins the frontend wiring: the ``parseApiErrorMessage`` helper exists,
the drag/drop banner runs through it, and the drawer maintains a
visible ``patchErr`` state that's cleared between PATCHes and tasks.
* docs(codex_app_server): document multi-root Kanban writable_roots (#27941)
Update the Codex app-server runtime guide's Kanban section to reflect
the new behaviour:
* The sandbox override now adds the board DB directory plus every
Kanban path the dispatcher pinned (HERMES_KANBAN_WORKSPACES_ROOT,
HERMES_KANBAN_WORKSPACE, legacy HERMES_KANBAN_ROOT) -- deduplicated,
DB-dir first.
* The motivation note now includes the cross-mount artifact-write
scenario (e.g. ``/media/.../kanban-workspaces/...`` on a separate
drive) and links to issue #27941 so readers can find the original
bug report.
* fix(gateway): quiet corrupt kanban dispatcher boards
Salvages substantive part of #26490 by @aqilaziz. Detects corrupt board
DBs ("file is not a database" / "database disk image is malformed")
and disables them by fingerprint until they're repaired, instead of
flooding the gateway log with repeated logger.exception tracebacks every
tick.
Cherry-picked the substantive commit (ea5b4ec2a); the tip commit was
an unrelated _is_dir OSError fix for service-path lookup. Dropped a
small test reformat that was bundled in the same commit.
* docs: align kanban readiness docs and smoke tests
Salvages #28199 by @bensargotest-sys. Aligns Kanban docs with current
tool registration: dispatcher-spawned task workers get task tools,
profiles that explicitly enable the kanban toolset get orchestrator
routing tools (kanban_list, kanban_unblock). Corrects failure-limit
text to current default of 2. Hardens the e2e subprocess script to
resolve repo root and use the spawnable default assignee. Updates the
diagnostics severity fixture to assert error below the critical
threshold.
* feat(kanban): surface per-task model_override in show + tool output
Salvages #26897 by @loicnico96. The per-task model_override DB column
already exists on main, but it wasn't exposed in user-facing surfaces.
This adds:
- 'kanban show' prints 'model: <name>' when model_override is set
- kanban_show / kanban_list tool responses include the model_override field
Original branch was stale (PR was authored against an older field name
'model'); applied the substantive surface exposure manually using the
current 'model_override' field name.
* feat(cli): add kanban swarm topology helper
Salvages #26791 by @Niraven. Adds 'hermes kanban swarm' to create a
durable Kanban Swarm v1 graph: a completed root/blackboard card,
parallel worker cards, a verifier gated on all workers, and a
synthesizer gated on the verifier. Stores shared swarm blackboard
updates as structured JSON comments on the root card.
Self-contained: new hermes_cli/kanban_swarm.py module + CLI wiring +
unit tests.
* feat(kanban): add optional board parameter to all MCP tools
Salvages #27598 by @nnnet. Adds optional 'board' parameter to all 9
kanban_* MCP tools via shared _connect helper. Backwards compatible —
omitting board keeps current pinned-board behavior. Useful for
orchestrator profiles that route across multiple boards.
Two-file scope: tools/kanban_tools.py + tests.
* feat(kanban): stamp originating ACP session_id on tasks
Salvages #23208 by @awizemann. Tracks which chat session created a
kanban task so clients can render a per-session board without falling
back to tenant + time-window heuristics.
- Schema: tasks gains nullable session_id TEXT column with index
(additive migration in _migrate_add_optional_columns).
- ACP: server.py exposes the originating session id via HERMES_SESSION_ID
with save/restore around the agent loop.
- Tool: kanban_create reads HERMES_SESSION_ID (with explicit override).
- CLI: 'hermes kanban list --session <id>' filter; JSON output exposes
session_id.
* feat(kanban): wire dispatcher to dispatch review agents from review column
Salvages #23772 by @thewillhuang. Adds 'review' as a valid kanban task
status and extends dispatch_once to monitor the review column as a
second dispatch source (in addition to the existing ready column).
- Adds 'review' to VALID_STATUSES
- Adds claim_review_task() — atomically transitions review → running
- Adds has_spawnable_review() — health telemetry mirror
- Extends dispatch_once with a review column dispatch loop
- Review agents get 'sdlc-review' skill auto-loaded
Resolved 2 conflicts (VALID_STATUSES merge with main's 'scheduled' state,
test file additions). Adapted claim_review_task to main's
ttl_seconds: Optional[int] = None convention (matches claim_task).
* feat(kanban): stale detection for running tasks in dispatcher
Salvages #23790 by @thewillhuang. Adds detect_stale_running() to
the dispatcher cycle. Running tasks that have been started for longer
than dispatch_stale_timeout_seconds (default 14400 = 4h) without a
heartbeat in the last hour are auto-reclaimed to ready.
- New config kanban.dispatch_stale_timeout_seconds (default 14400, 0 disables)
- New 'stale' field on DispatchResult
- detect_stale_running() in kanban_db.py with heartbeat freshness check
- Records outcome='stale' on run close + 'stale' event; ticks failure counter
- Wires config through gateway embedded dispatcher
- Updates _cmd_dispatch verbose/JSON output and daemon logging
Resolved test-file end-of-file conflict by appending both halves.
* feat(kanban): filter tasks by workflow fields and runs by status/outcome
Salvages #26745 by @nehaaprasaad. Exposes filtering for the existing
workflow_template_id and current_step_key columns:
- list_tasks() accepts workflow_template_id and current_step_key kwargs
- 'hermes kanban list' adds matching CLI flags
- dashboard plugin_api also exposes the filters
Resolved a small conflict in list_tasks signature alongside main's
session_id and order_by additions; combined all three into the single
filter list.
* feat(kanban): add respawn guard to block repeat worker storms
Salvages #27484 by @fardoche6. Adds a respawn guard that skips worker
spawn for tasks where:
- a recent run already succeeded (recent_success — within guard window)
- the previous run hit a quota/auth error (blocker_auth, also auto-blocks)
- a recent task comment includes a GitHub PR URL (active_pr)
The guard prevents repeat worker storms on the same bug/task. Includes
the contributor's review-findings fixup (regex hardening, observability,
auth coverage).
Resolved a small DispatchResult conflict alongside main's 'stale' field;
kept both. Authorship preserved via rebase merge.
* feat(kanban): show dashboard cron jobs across profiles
Salvages #27568 by @SerenityTn. Dashboard cron page now lists cron
jobs from all profiles, with profile-aware filter UI and storage
routing. Includes test coverage for cross-profile listing, mutation,
deletion, and validation.
Also fixes orphan conflict markers in config.py left by an earlier
salvage merge (kanban.dispatch_stale_timeout_seconds was double-nested
in HEAD/PR markers from #28452 salvage of #23790).
* fix(kanban): remove orphan conflict markers from config.py (#28458)
PR #28452 (salvage of #23790, stale detection) merged with leftover
git conflict markers in hermes_cli/config.py around the
`dispatch_stale_timeout_seconds` config block, breaking config import
and any code path that loads it. Cleans up the markers and keeps both
config blocks (worker log rotation/orchestrator + stale detection).
Resolves a self-introduced regression.
* fix(kanban): remove orphan conflict markers from kanban.py (#28459)
PR #28454 (salvage of #26745, workflow filter) merged with leftover
git conflict markers in hermes_cli/kanban.py at three sites:
- _task_to_dict() (session_id alongside workflow_template_id/current_step_key)
- p_list parser (--sort alongside --workflow-template-id/--step-key)
- _cmd_list (order_by alongside the new filter kwargs)
Cleans up the markers and keeps both halves at each site.
Resolves a self-introduced regression.
* feat(kanban): configure worktree paths and branches
Salvages #26496 by @aqilaziz. Adds branch_name column + CLI flag so
tasks with workspace_kind='worktree' can pin a target branch on
create. Schema migration added to _migrate_add_optional_columns.
- Task.branch_name field + DB column + migration
- create_task accepts branch_name kwarg
- hermes kanban create --branch <name> flag
- kanban show output includes 'Branch: <name>' when set
Cherry-picked the substantive commit (a7558cf27); the PR's tip was
an unrelated service-path-dirs commit. Resolved 2 INSERT-column-list
and show-output conflicts alongside main's session_id and
max_runtime_seconds additions; kept all three.
* feat(skills): add skill bundles — alias /<name> loads multiple skills (#28373)
Skill bundles are tiny YAML files in ~/.hermes/skill-bundles/ that
group several skills under one slash command. Invoking /<bundle-name>
from any surface (CLI, TUI, dashboard, any gateway platform) loads
every referenced skill into a single combined user message.
Use cases:
- /backend-dev → loads github-code-review + test-driven-development
+ github-pr-workflow as one bundle.
- /research → loads several research skills together.
- Team task profiles shared via dotfiles.
Behavior:
- Bundles take precedence over individual skills when slugs collide.
- Missing skills are skipped with a note, not fatal.
- No system-prompt mutation — bundles generate a fresh user message
at invocation time, the same way /<skill> does. Prompt cache stays
intact.
- Works in CLI dispatch, gateway dispatch, autocomplete (CLI + TUI),
/help display.
Schema (~/.hermes/skill-bundles/<slug>.yaml):
name: backend-dev
description: Backend feature work.
skills:
- github-code-review
- test-driven-development
instruction: |
Optional extra guidance prepended to the loaded skills.
New module: agent/skill_bundles.py — load, scan, resolve, build
invocation message, save, delete. yaml.safe_load only; broken
bundles log a warning and are skipped, never raise.
New CLI subcommand: hermes bundles {list,show,create,delete,reload}.
Implementation in hermes_cli/bundles.py; wired in hermes_cli/main.py.
'bundles' added to _BUILTIN_SUBCOMMANDS so plugin discovery skips it.
New in-session slash command: /bundles lists installed bundles in
both CLI and gateway. /<bundle-name> dispatch added to CLI (cli.py)
and gateway (gateway/run.py) before the existing /<skill-name> path.
Autocomplete: SlashCommandCompleter gained an optional
skill_bundles_provider parameter that defaults to None — the prompt
shows '▣ <description> (N skills)' for bundles vs '⚡' for skills.
Tests:
- tests/agent/test_skill_bundles.py — 33 tests covering slugify,
scan/cache freshness, resolve (including underscore→hyphen
Telegram alias), build_bundle_invocation_message (loading, missing
skills, user/bundle instruction injection, dedup), save/delete,
reload diff, list sort.
- tests/hermes_cli/test_bundles.py — 8 tests for the CLI
subcommand (create/list/show/delete/reload, --force, missing
bundle errors).
- tests/gateway/test_bundles_command.py — 4 tests for the gateway
handler and bundle resolution priority.
Live E2E: verified subprocess invocations of hermes bundles
{list,create,show,reload,delete} round-trip correctly against an
isolated HERMES_HOME.
Docs:
- website/docs/user-guide/features/skills.md — new 'Skill Bundles'
section with quick example, YAML schema, management commands,
behavior notes.
- website/docs/reference/cli-commands.md — 'hermes bundles' added to
the top-level command table and given its own subcommand section.
* feat(kanban): add scheduled status for delayed follow-ups
Salvages #24533 by @roycepersonalassistant. Adds a first-class
'scheduled' Kanban status for time-delay follow-ups that aren't
waiting on human input.
- hermes kanban schedule <task_id> [reason] CLI command
- Dashboard/API transitions to/from Scheduled
- unblock_task() now releases both 'blocked' AND 'scheduled' tasks
(re-checking parent dependencies before moving to ready/todo)
- i18n + docs updates
Resolved conflicts: kept HEAD's failure-counter reset on unblock
alongside the PR's scheduled state, kept HEAD's 'running' direct-set
rejection, combined both bulk-status branches. Dropped the dist/
bundle changes (months-stale; would need rebuild from source).
* feat(kanban): drag-to-delete trash zone + bulk delete for task cards
Salvages #28125 by @Jpalmer95. Adds:
- Drag-to-delete trash zone in the kanban dashboard
- Bulk delete endpoint with cascading delete_task cleanup
- Frontend updates (drag visual + drop handler)
- Confirmation prompt before delete
Resolved end-of-file test conflict by appending both halves.
* docs: add Korean Kanban documentation
Salvages #21823 by @pochi-gio. Adds Korean (ko) Docusaurus locale and
translates Kanban documentation (kanban.md, kanban-tutorial.md) and the
two related skills (devops-kanban-orchestrator, devops-kanban-worker).
Purely additive — adds ko to the locales list in docusaurus.config.ts
and creates the website/i18n/ko/ tree.
* fix(tests): catch up six stale tests after compression/aux/kanban changes (#28465)
- aux_config: drop session_search from _AUX_TASKS and remove stale test
(PR #27590 removed auxiliary.session_search from DEFAULT_CONFIG)
- compression_boundary_hook: set compressor._last_compress_aborted=False
on MagicMock so the post-compress abort branch (PR #28117) doesn't
short-circuit before the session-id rotation under test
- kanban_dashboard_plugin: use consecutive_failures=3 so severity stays
'error' (failure_threshold default dropped from 3 to 2 in d9fef0c8a,
so failures=5 now crosses the critical floor of 2*2=4)
- cli_manual_compress: accept force kwarg on DummyAgent._compress_context
(cli._manual_compress now passes force=True)
* fix(telegram): render full clarify choice text in message body, use short button labels
When Telegram clarify prompts offer long choices, mobile clients
truncate the inline button labels, making options unreadable.
Previously only the question was shown in the message body with
truncated choice text in button labels.
Fix: append the full numbered option list to the message body
so users can read complete choice text on any client. Buttons
now use short numeric labels (1, 2, ...) to avoid Telegram
truncation. The 'Other (type answer)' button is unchanged.
Long choice labels are now rendered in full (not truncated to
57 chars + '...') since they appear in the body instead of
button labels.
Closes: #27497
* chore(release): map @asdlem for PR #27852 salvage
* fix(telegram): default streaming transport to edit
* fix(telegram): respect reply_to_mode for DM topic reply fallback
The DM topic reply fallback code in send() hardcoded should_thread=True
when telegram_dm_topic_reply_fallback metadata was present, bypassing
_should_thread_reply() and ignoring reply_to_mode config. This caused
quote bubbles on every response even with reply_to_mode: 'off'.
Fix:
- Add reply_to_mode param to _reply_to_message_id_for_send() and
_thread_kwargs_for_send() classmethods
- In send(), check self._reply_to_mode != 'off' for DM topic fallback
- Suppress reply anchor and reply_to_message_id when mode is 'off'
while preserving message_thread_id for correct topic routing
- Thread reply_to_mode through all 29 call sites
Regression coverage: 10 new tests in test_telegram_reply_mode.py
covering classmethod behavior, send() integration, and backward
compatibility.
Fixes reply_to_mode: 'off' ignored by Telegram DM topic reply fallback code #23994
* fix(gateway): route Telegram audio file attachments away from STT pipeline (#24870)
Telegram distinguishes three kinds of audio payloads:
- message.voice → Opus/OGG voice messages → STT pipeline ✓
- message.audio → audio file attachments → bypasses STT ← was broken
- message.document (audio mime) → generic file route
**Root cause** — the inbound message routing block in gateway/run.py
matched both MessageType.VOICE *and* MessageType.AUDIO into audio_paths,
which were then fed unconditionally to _enrich_message_with_transcription.
Audio file attachments (.mp3, .m4a, etc.) were therefore auto-transcribed
instead of being treated as files, making the transcribe skill unusable
from Telegram because the path it needed was never surfaced.
**Fix**
- Introduce a new audio_file_paths list populated exclusively by
MessageType.AUDIO events.
- Narrow the audio_paths selector to MessageType.VOICE (and bare
audio/ mime-type events that are not explicitly AUDIO or DOCUMENT).
- After the STT block, inject a document-style context note for each
audio_file_path, giving the agent the file path and asking what to do
with it (consistent with how plain documents are handled).
**Tests** — 5 new tests in test_telegram_audio_vs_voice.py:
- voice message still transcribed (regression guard)
- audio attachment skips STT (core fix)
- audio attachment context note format
- STT disabled still produces file note (not STT-disabled notice)
- MessageType.AUDIO != MessageType.VOICE sanity check
Fixes #24870
* chore(release): map bartok9 noreply for PR #24879 salvage
* fix(send_message): route standalone Telegram sends through TELEGRAM_PROXY
When the send_message tool runs outside the gateway process (agent loop,
TUI, cron, etc.), _gateway_runner_ref() returns None and the standalone
path in _send_telegram constructs Bot(token=token) directly, bypassing
any configured proxy. In regions where api.telegram.org is blocked, the
send times out after ~5s with 'Telegram send failed: Timed out' and
nothing ever shows up in gateway.log because the request never reaches
the gateway.
Resolve TELEGRAM_PROXY (via gateway.platforms.base.resolve_proxy_url,
which also honours HTTPS_PROXY/HTTP_PROXY/ALL_PROXY and NO_PROXY) just
before constructing the Bot. When a proxy is found, attach an
HTTPXRequest(proxy=...) for both 'request' and 'get_updates_request',
matching what gateway/platforms/telegram.py already does for in-gateway
sends and what the Discord standalone sender already does. Any
exception attaching the proxy falls back cleanly to a direct connection,
preserving prior behaviour for users without a proxy configured.
Adds tests/tools/test_send_message_telegram_proxy.py covering both the
proxy-configured and no-proxy cases.
* chore(release): map @pepelax for PR #25419 salvage
* fix(kanban-dashboard): restore implementations dropped during salvages (#28481)
Four kanban dashboard test failures, all from PR salvages that picked up
the test additions but dropped the corresponding implementations.
- BOARD_COLUMNS: add 'review' (status added by PR f55d94a1e but the
board API never grew the column → test_board_empty failed because
VALID_STATUSES - {archived} mismatched the rendered columns).
- update_task: enrich the 'ready' 409 detail with the blocking parent
list (id, title, status) and add _parents_blocking_ready helper.
Implementation lost in the #26744 salvage (commit e215558ba) which
pinned the test but not the server-side code.
- dist/index.js: add parseApiErrorMessage helper, wire it through the
drag/drop banner, add patchErr state to the TaskDrawer and surface
it inline by the action row. Lost in the same #26744 salvage.
- test_diagnostics_endpoint_severity_filter: update to at-or-above
semantics (PR a94ddd807 changed the filter from exact-match so the
warning filter now correctly includes error+critical too).
* fix(gateway): roll over Telegram tool progress bubbles
* fix(gateway): scope audio_file_paths outside media_urls guard
The audio-file-paths handling block at line 7334 references the variable
unconditionally, but #24879 initialized it inside the 'if event.media_urls'
block — so events without media_urls hit UnboundLocalError.
Found via test_run_agent_queued_message_does_not_treat_commentary_as_final
after PR #28478 landed.
* fix(gateway): keep tool-progress edits alive after Telegram flood control
When a progress-message edit hits Telegram flood control (RetryAfter),
can_edit was unconditionally set to False, permanently disabling coalescing
for the rest of the run. Subsequent tool updates were posted as separate
new messages instead of updating the existing progress bubble.
Fix: only set can_edit=False for non-recoverable edit errors. On flood
control, back off by resetting _last_edit_ts so the throttle interval is
respected before the next edit attempt.
Fixes #25188
* chore(release): map @erhnysr for PR #25198 salvage
* fix(telegram): preserve can_edit after transient network errors in progress edits (#27828)
When edit_message_text fails with a transient error (httpx.ConnectError,
NetworkError, server disconnected, timeouts), the progress-message sender
must not permanently set can_edit = False — that would convert a single
Telegram network hiccup into separate per-tool bubbles for the rest of the run.
Changes:
- gateway/platforms/telegram.py: edit_message now returns retryable=True for
transient network errors (ConnectError, NetworkError, timeouts, server
disconnects, temporarily unavailable). Permanent failures (flood control,
message-not-found, permissions) remain retryable=False.
- gateway/run.py: send_progress_messages checks result.retryable before
setting can_edit = False. Transient failures skip the fallback-send and
continue — the next edit cycle catches up with the accumulated lines.
Permanent failures (flood, message-not-found, etc.) still disable editing.
Tests: 22 new tests in test_telegram_progress_edit_transient.py covering
transient vs permanent error classification, SendResult.retryable semantics,
and the can_edit decision logic.
Fixes #27828
* fix(telegram): recover from post-update polling conflict without entering limbo
* fix(test+release): update conflict retry count for MAX=5; map @CryptoByz
* fix(gateway): route background-process notifications into Telegram DM topics
Background-process completion notifications (notify_on_complete) and
watch-pattern notifications were always delivered to the Telegram main
chat instead of the originating private-chat topic.
Hermes-created Telegram DM topic lanes only render a send when it carries
both message_thread_id and a reply anchor. The synthetic MessageEvent
injected on process completion had no message_id, so _reply_anchor_for_event
returned None and _thread_kwargs_for_send dropped message_thread_id
entirely — routing the notification to the main chat.
Capture the triggering message id at spawn time and thread it through to
the synthetic event so it can be reply-anchored back into the topic:
- session_context: add HERMES_SESSION_MESSAGE_ID context var
- telegram adapter: populate SessionSource.message_id on inbound messages
- terminal tool: persist watcher_message_id on the process session
- process registry: carry/persist message_id on watcher dicts + checkpoint
- gateway: set MessageEvent.message_id on injected notifications
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(release): map @fabiosiqueira for PR #27212 salvage
* fix(telegram): route resumed DM topic sends directly
* fix(telegram): enforce TELEGRAM_ALLOWED_USERS allowlist on inbound messages
TELEGRAM_ALLOWED_USERS was only checked for callback/inline-button
actions but not for inbound messages. Unauthorized users triggered an
'Unauthorized user' log warning but their messages were still processed
by the agent — a P0 security bypass (issue #23778).
Fix: add allowlist check in _should_process_message() which is called
for all message types (text, command, media, location). If the sender
is not in TELEGRAM_ALLOWED_USERS, the message is dropped immediately
with a warning log. Empty TELEGRAM_ALLOWED_USERS continues to allow
all users (existing behavior).
Fixes #23778
* fix(telegram): fail-closed auth fallback when TELEGRAM_ALLOWED_USERS is empty
The _is_callback_user_authorized fallback returned True when
TELEGRAM_ALLOWED_USERS was not set, allowing any Telegram user
to interact with the bot. Change to fail-closed: deny by default
unless GATEWAY_ALLOW_ALL_USERS=true is explicitly set.
Fixes #24457
* test(telegram): stub _is_callback_user_authorized in trigger-gating fixture
After PR #24468 made the empty-allowlist callback auth fail-closed
(and #23795 wired _is_callback_user_authorized into _should_process_message),
trigger-gating tests started failing because their fake messages from
user 111 hit the new deny-by-default path before trigger evaluation.
Force-authorize all senders in _make_adapter() so the trigger logic
under test runs. The fail-closed behavior itself is covered by
test_telegram_callback_auth_fail_closed.py.
* fix(telegram): reset sticky fallback IP on connect failure, retry primary DNS
When a sticky fallback IP (from DoH discovery) becomes unreachable,
the transport previously got stuck in an attempt_order that only
tried the dead IP. This prevented the gateway from recovering
until the service was restarted.
Changes:
- Always include primary DNS path (None) after the sticky IP in the
attempt_order so that a primary-path retry happens on sticky failure.
- Reset self._sticky_ip to None when the currently sticky IP hits
a connect timeout / connect error, allowing the next request to
retry from scratch.
Fixes silent Telegram disconnection when discovered fallback IPs
are transiently or permanently unreachable.
* test+release: align stale sticky-IP test for #24511; map @falconexe
* fix(telegram): propagate extra base_url config
* feat(send_message): auto-detect @username mentions and create Telegram entities
When sending messages containing @username patterns, auto-generate
MessageEntity(type='mention') entries so that the receiving bot's
require_mention filter can trigger. This enables proper bot-to-bot
interop where mention-based routing is used.
* test+release: align send_message mocks for MessageEntity import; map @fonhal
* fix(telegram): resume typing indicator after inline approval click (#27853)
The text /approve and /deny paths in gateway/run.py call
resume_typing_for_chat() after resolve_gateway_approval() succeeds, but
the Telegram inline-button (ea:*) callback in _handle_callback_query did
not. Typing is paused when the approval is sent (gateway/run.py:15658),
so without a matching resume the typing indicator stayed gone for the
remainder of a long-running turn after a button click.
Symmetry-match the text path: after a successful resolve, call
self.resume_typing_for_chat(str(query_chat_id)). Guarded by count > 0
to match /approve's "if not count" early-return — if nothing was
actually resolved, the agent thread was never unblocked, so typing
should remain paused.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(gateway): mark final voice reply as notify-worthy so Telegram delivers it audibly
In Telegram "important" notifications mode (default), TelegramPlatformAdapter
sets ``disable_notification=True`` on every send unless metadata carries
``notify=True``. GatewayRunner._send_voice_reply already passes thread
metadata through to ``adapter.send_voice``, but never marks the final
auto-TTS voice reply as notify-worthy — so users with the default mode get
the final voice note delivered silently with no push notification.
Mirror the final-text path in gateway/platforms/base.py (the existing
text-response final send already adds ``metadata["notify"] = True``).
Issue #27970 Bug 2. Bug 1 (MP3 vs. native OGG voice-note) is being
addressed by existing PRs #20182 / #20878 — this PR is intentionally
scoped to the silent-delivery bug only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: avoid Telegram group reply thread session splits
* chore(release): map @eliteworkstation94-ai for PR #28157 salvage
* fix(gateway): avoid duplicate Telegram text after auto-TTS voice replies
* chore(release): map @Zyrixtrex for PR #26754 salvage
* fix(telegram): escape send_slash_confirm preview with format_message
send_slash_confirm() sent the raw command preview with ParseMode.MARKDOWN,
skipping the format_message() conversion applied to every other dynamic
send in the adapter. Commands with underscores, dots, brackets, or other
MarkdownV2-sensitive characters raised BadRequest: Can't parse entities;
the exception was swallowed by the outer try/except, so the confirmation
prompt silently never appeared.
Fix: wrap preview through format_message() and switch to MARKDOWN_V2,
symmetric with send_update_prompt and the callback sends fixed in
a69404052.
* chore(release): map @nftpoetrist for PR #25856 salvage
* fix(telegram): retry wrapped connect timeouts
* chore(release): map @samahn0601 for PR #27887 salvage
* fix(tts): keep native audio outside Telegram voice delivery
* chore(release): map @aqilaziz for PR #26406 salvage
* fix(gateway): pin Telegram DM-topic routing to user's current topic
Topic-mode DM replies were fragmenting one conversation across many sessions: a Reply on a message in another topic delivered Telegram's message_thread_id for *that* topic, and #3206's strip routed plain replies to the lobby. Both pulled the user away from their current session. Fix: when topic mode is on, rewrite source.thread_id to the user's most-recent binding if the inbound id is missing/General or not a known topic. Non-topic-mode users unchanged.
* chore(release): map @karthikeyann for PR #26609 salvage
* fix(send_message): add thread-not-found retry for Telegram forum topic sends
The standalone _send_telegram path in send_message_tool lacked the
thread-not-found fallback that the gateway adapter has. When a forum
topic thread_id was stale or deleted, the send would fail entirely
instead of retrying to the General topic.
Changes:
- Add _is_telegram_thread_not_found() helper matching gateway adapter
- Add thread-not-found retry in text send path
- Add thread-not-found retry in media send path (with f.seek(0))
- Separate text_kwargs from thread_kwargs to prevent
disable_web_page_preview leaking into send_photo/send_video calls
Closes #27012
* test(send_message): add thread-not-found retry tests for Telegram forum topics
Adds two tests to TestSendTelegramThreadIdMapping:
- test_thread_not_found_retries_without_message_thread_id
- test_thread_not_found_for_media_retries_without_message_thread_id
Refs #27012
* test(send_message): add thread-not-found retry tests for Telegram topics
Three tests covering the #27012 fix:
- test_is_thread_not_found_matches_expected_errors
- test_text_send_retries_without_thread_id_on_thread_not_found
- test_disable_web_page_preview_not_leaked_to_media_sends
116/116 existing tests still pass (no regressions).
* chore(release): map @kunci115 for PR #27098 salvage
* fix(gateway): register Telegram commands for groups
Register Telegram bot commands across default, private, and group scopes so
the slash-command menu is available outside DMs.
Changes from review feedback:
- Add asyncio.Lock to prevent race condition in _ensure_forum_commands
- Extract MAX_COMMANDS_PER_SCOPE constant (30) to avoid magic number
- Upgrade error logging from debug->warning in forum registration
- Add tests covering lazy forum registration and concurrent safety
- Remove /start handler from this PR (separate feature)
Fixes review: needs_work (race, magic number, log levels, missing tests)
* test+release: fix test fixture for forum_commands; map @chromalinx
* fix(telegram): gate profile bots by allowed topics
* chore(release): map @booker1207 for PR #25132 salvage
* fix(cron): route Telegram cron deliveries to a dedicated topic via TELEGRAM_CRON_THREAD_ID
When Telegram topic mode is enabled, cron messages delivered to the bot's
root DM (TELEGRAM_HOME_CHANNEL without a thread id) land in the system
lobby — replies there are rebuffed with the lobby reminder and
reply_to_message_id is dropped, so users cannot interact with the cron
output (#24409).
Add an optional TELEGRAM_CRON_THREAD_ID env var that overrides
TELEGRAM_HOME_CHANNEL_THREAD_ID for cron deliveries only. Operators can
create a "Cron" forum topic in the DM, point this var at its thread id,
and replies to cron messages will land in that topic's existing session
instead of the lobby. The home-channel thread id (used elsewhere, e.g.
restart notifications) is unchanged, and explicit
deliver="telegram:chat:thread" targets continue to win over the env var.
Per the reporter's clarification on 2026-05-13, option (a) (cron-side
route to a dedicated topic + config knob) was chosen.
Fixes #24409
* fix(telegram): route image documents (.png/.jpg/.webp/.gif) through vision pipeline
When users send images as documents (Telegram file picker), they were
rejected with "Unsupported document type" because SUPPORTED_DOCUMENT_TYPES
only includes text/office formats. Add SUPPORTED_IMAGE_DOCUMENT_TYPES
to base.py and handle them in telegram.py before the document check.
- Add SUPPORTED_IMAGE_DOCUMENT_TYPES constant to base.py
- Add MIME reverse-lookup for image types in telegram.py
- Route image documents through cache_image_from_bytes + vision pipeline
- Handle media groups for image documents
Closes: #20128, #18620
* test+release: stub auth in test_telegram_documents fixture; map @kiranvk-2011
* fix(gateway): prevent Windows Telegram /restart leaving gateway stopped
* chore(release): map @rak135 for PR #25960 salvage
* fix(telegram): preserve topic metadata on overflow edits
* feat(telegram): add disable_topic_auto_rename gateway flag
When Hermes auto-titles a session in a Telegram DM topic it currently
renames the topic itself to the generated title. That works for
operator-managed lanes (extra.dm_topics) but is disruptive for
ad-hoc Threaded-Mode topics that users name by hand — every first
exchange overwrites their chosen title.
Add gateway.platforms.telegram.extra.disable_topic_auto_rename (default
False, preserving prior behaviour). When set, both
_schedule_telegram_topic_title_rename and the underlying
_rename_telegram_topic_for_session_title short-circuit before touching
the Telegram API. Internal session titles (sessions list, TUI) keep
working unchanged.
Also bridge the legacy top-level telegram.disable_topic_auto_rename key
through to gateway.platforms.telegram.extra so users on the older
config layout don't have to migrate to enable it.
- Tests cover the runtime flag, the scheduling entry-point, and string
truthiness coercion for YAML-loaded values.
- Docs updated in messaging/telegram.md with an example block.
* chore(release): map @B0Tch1 for PR #27634 salvage
* fix(gateway): restore Telegram DM topic thread_id after session split (#27166)
When context compression triggers a mid-turn session split, source.thread_id
can be None on synthetic/recovered events. _thread_metadata_for_source then
returns None, causing the Telegram adapter to send with no message_thread_id
and the response lands in the General thread instead of the active DM topic.
Fix:
- hermes_state.py: Add get_telegram_topic_binding_by_session() for reverse
lookup by session_id (enabled by the existing UNIQUE INDEX on session_id).
- gateway/run.py: After session-split detection, if source is a Telegram DM
and source.thread_id is None, recover it from the binding via the new
method so _thread_metadata_for_source produces the correct thread routing.
- tests/: Coverage for the new lookup method and the recovery flow.
* chore(release): map @jackjin1997 for PR #27239 salvage
* fix(gateway): allow chat-scoped telegram auth without sender user_id
* chore(release): map @soynchux for PR #27806 salvage
* fix(telegram): add DM topic typing fallback when message_thread_id rejected
When a DM topic lane's message_thread_id is rejected by Telegram
(e.g. stale or deleted topic), send_typing now falls back to sending
the typing indicator without thread_id so it at least appears in the
main DM view, rather than being silently swallowed.
Also adds test for the fallback behavior.
* fix(telegram): report cron topic fallback
* chore(release): map @el-analista for PR #25368 salvage
* fix(telegram): wire gt: callback dispatch for gmail-triage buttons
The gmail-triage skill's Telegram inline buttons emit callback_data of the
form `gt:<verb>:<arg>`, but `_handle_callback_query` had no `gt:` branch —
taps fell through silently and the spinner sat there until Telegram timed it
out.
Add `_handle_gmail_triage_callback`, dispatched from the existing callback
router, that:
- Authorizes the caller via the same `_is_callback_user_authorized` path as
the approval / slash-confirm / clarify handlers.
- Maps each verb to a script under `~/.hermes/scripts/gmail-triage/` and runs
it async with a 60s timeout.
- Splits verbs into one-shots (send / archive / draft / spam) — append the
confirmation and strip the keyboard so the action can't fire twice — and
sticky-state changes (mute / trust / vip ± -domain) — append the
confirmation but leave the keyboard tappable so the user can stack actions
on one email.
- On failure: toast only, keyboard preserved so the user can retry.
- Logs every callback outcome to gateway.log for debugging.
* chore(release): map @khungate for PR #25829 salvage
* feat(telegram): support quick-command-only menus
* chore(release): map @stevehq26-bot for PR #28015 salvage
* fix(telegram): handle channel post updates
* test: address telegram channel post review
* test+release: stub auth in channel_posts fixture; map @brndnsvr
* Quiet noisy Telegram gateway errors
* chore(release): map oracle@jarviss-mbp.home for PR #24014 salvage
* Route Telegram multi-bot mentions exclusively
* Document Telegram multi-profile gateway commands
* fix: ignore Telegram messages for other bots
* chore(release): map @OCWC22 for PR #24581 salvage
* feat(telegram): ignore_root_dm with system command lobby
* docs(telegram): document ignore_root_dm feature
* chore(release): map @ai-hana-ai for PR #23928 salvage
* feat(telegram): pin incoming user message for duration of agent turn
When a user sends a message on Telegram, the incoming message is now
automatically pinned at the start of processing and unpinned when the
agent finishes its turn. This gives the user a visual indicator that
their message is being worked on, and keeps the conversation anchored.
Changes:
- telegram.py: Added pinChatMessage in on_processing_start and
unpinChatMessage in on_processing_complete. Restructured both
hooks so pin/unpin runs independently of the reactions feature
(reactions are optional; pinning is always on).
- telegram.py: Pass message_id through SessionSource so it's
available in the session context.
- session_context.py: Added HERMES_SESSION_MESSAGE_ID context var.
- run.py: Pass source.message_id through set_session_vars.
Pinning is silent (disable_notification=True) and failures are
logged at debug level without interrupting message processing.
Only the user's incoming message is pinned -- never the agent's
replies. Auto-resume events (which have no message_id) are
correctly skipped.
* chore(release): map @indigokarasu for PR #26636 salvage
* feat(telegram): skip-STT audio path + 2GB cap via local Bot API server
Two coordinated changes that unblock downstream audio pipelines
(diarization, custom transcription, archival) on attachments larger
than the public Bot API's 20MB getFile ceiling.
- `stt.enabled: false` no longer drops voice/audio with a generic
"transcription disabled" note. The gateway probes the cached file's
duration (wave → mutagen → ffprobe ladder) and surfaces
`[The user sent a voice message: <abs path> (duration: M:SS)]` to
the agent so a skill or tool can pick up the raw file. The previous
placeholder is replaced rather than appended when present.
- `platforms.telegram.extra.base_url` set → adapter auto-lifts its
document size cap from 20MB to 2GB (the local telegram-bot-api
`--local` ceiling) and the "too large" reply reports the active
limit dynamically. No new config knob; presence of `base_url` is the
opt-in.
- `platforms.telegram.extra.local_mode: true` wires
`Application.builder().local_mode(True)` on the python-telegram-bot
builder. PTB then reads files from disk instead of HTTP, which is
required when telegram-bot-api runs in `--local` mode (the server
returns absolute filesystem paths, not `/file/bot...` URLs).
- gateway/run.py: rewrites the `stt.enabled: false` branch of
`_enrich_message_with_transcription`. New `_format_duration` +
`_probe_audio_duration` helpers.
- gateway/platforms/telegram.py: `_max_doc_bytes` instance attribute
derived from `extra.base_url`; `local_mode` builder wiring;
dynamic "too large" message.
- tests/gateway/test_stt_config.py: covers path-surfacing with and
without an existing user message, and placeholder replacement.
- tests/gateway/test_telegram_max_doc_bytes.py: 3 cases — default 20MB
without base_url, 2GB when set, empty-string base_url keeps default.
- website/docs/user-guide/messaging/telegram.md: new "Skipping STT"
subsection under Voice Messages and a full "Large Files (>20MB) via
Local Bot API Server" walkthrough (api_id/api_hash, docker-compose,
one-time `logOut` migration, `platforms.telegram.extra` config, the
`local_mode` disk-access requirement, the silent HTTP-fallback 404).
- website/docs/user-guide/features/voice-mode.md: documents the
`stt.enabled` knob in the config reference.
- `pytest tests/gateway/test_telegram_max_doc_bytes.py
tests/gateway/test_stt_config.py` → 9/9 passing.
- Verified end-to-end on a live deployment: gateway log shows
`Using custom Telegram base_url: http://...` and
`Using Telegram local_mode (read files from disk)` on startup;
voice messages above 20MB cache to disk and surface their path to
the agent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(release): map @alber70g for PR #25280 salvage
* fix(web): add scheduled column to i18n type definitions (#28549)
columnLabels and columnHelp in en.ts include a scheduled entry but the
Translations interface in types.ts did not declare it, causing a
TypeScript build failure in the Nix derivation. Made the field optional
since only en.ts provides it currently.
* docs: comprehensive 2-week sweep of feature/PR coverage gaps (#28497)
Catch the website docs up to two weeks of merged work (May 4 – May 18, 2026,
roughly 1,080 PRs). The audit found ~50 user-visible features that had landed
in code with no docs footprint, plus a handful of stale pages. This PR closes
every gap the scan turned up.
New pages
- user-guide/features/deliverable-mode.md — extension list, agent triggers,
kanban_complete artifacts pattern, [[as_document]] override (PR #27813).
- developer-guide/web-search-provider-plugin.md — authoring guide modeled on
image-gen-provider-plugin, covering brave_free / ddgs / etc. (PR #25448).
Providers / auth
- Rename "Alibaba Cloud" → "Qwen Cloud (Alibaba DashScope)" everywhere the
display label shows up; provider id stays `alibaba` (PR #24835).
- Document OAuth refresh-token quarantine for xAI / MiniMax / Codex (PRs
#28116 / #28118 / #28119).
- Document Nous JWT minting from refresh token + invalid-refresh quarantine
+ cross-profile shared token store (PRs #27663 / #19712).
- Add `## Microsoft Entra ID authentication (keyless)` section to
azure-foundry guide — DefaultAzureCredential, RBAC, OpenAI + Anthropic
routing details (PR #28101 / #9df9816da).
- Custom providers `api_mode` is now prompted-and-persisted, not just URL
autodetected (PR #25068).
- Delegation honours `api_mode` + auto-detects anthropic_messages base URLs
(PR #26824).
- `x_search` auto-enables when xAI credentials are present (PR #27376).
- Add `xAI Grok OAuth (SuperGrok)` row to providers headline table (PR
#26534).
- NVIDIA NIM billing-origin header is set automatically (PR #26585).
Windows / installer
- `install.ps1`: document `-Commit <sha>` and `-Tag <v>` pin params plus
the BOM-strip / git-retry hardening (PR #28169).
- Document Hermes Desktop thin installer + first-launch bootstrap (PR
#27822).
- Document `dep_ensure` Windows bootstrap (PR #27845).
- Document install-method auto-detection (pip / git / homebrew / nixos) and
the matching update command (PR #27843).
Gateway / messaging
- `/platform list|pause|resume` full description + circuit-breaker
semantics (PR #26600).
- Slack / Matrix / Mattermost get parallel `allowed_channels` /
`allowed_rooms` allowlist sections matching Telegram/Discord/DingTalk
(PR #21251).
- Discord `allow_any_attachment` + `max_attachment_bytes` (config and env
vars) (PR #27245).
- Discord clarify-choice button rendering (PR #25485).
- Telegram `guest_mode` @mention bypass for allowlisted groups (PR
#22759).
- Telegram `notifications` mode (`important` vs `all`) (PR #22793).
- `[[as_document]]` skill / response directive for forcing
document-style media delivery (PR #21210).
CLI / TUI
- `/new [name]` argument (PR #19637).
- `/subgoal` user-supplied criteria appended to `/goal` (PR #25449).
- `/exit --delete` flag confirmation prompts for destructive slash
commands (PR #22687).
- Status-bar additions: ▶ N background indicator (PR #27175), context
compression count (PR #21218), YOLO mode banner+statusbar warning (PR
#26238).
- `display.timestamps` + `docker_extra_args` config keys (PR #23599).
- TUI collapsible startup banner sections (PR #20625).
- `HERMES_SESSION_ID` exported to tool subprocesses (PR #23847).
i18n
- Refresh display.language locale list from 8 → 16 (en, zh, zh-hant, ja,
de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu) — matches
`agent/i18n.py:SUPPORTED_LANGUAGES`.
Tools / features
- `vision_analyze` native-pixel passthrough for vision-capable callers,
with auxiliary text-describer fallback (PR #22955).
- `session_search` rewrite to the single-shape tool (discovery / scroll /
browse modes) (PRs #27590 / #27840).
- Clarify MCP transport scope: client supports stdio + SSE; embedded
`hermes mcp serve` is stdio-only (PR #21227).
- Web search backends table: add Brave Search (free tier) and DDGS rows
(PR #21337).
- ACP session-scoped edit auto-approval modes (PR #27862).
- Curator rename map in the user-visible per-run summary (PR #22910).
- Prompt caching feature page reference in features/overview.md — Claude
cross-session 1-hour prefix cache on native Anthropic / OpenRouter /
Nous Portal (PR #23828).
- Cron per-job profile parameter (PR #28124).
- `--no-skills` flag for `hermes profile create` (PR #20986).
Build
- Verified with `npm run build` in `website/`; both `en` and `zh-Hans`
locales compile. Remaining broken-link/anchor warnings are pre-existing
(`rl-training.md` from learning-path / overview; the
zh-Hans translation lag the docs skill already calls out).
* chore(release): pre-stage AUTHOR_MAP for May 2026 LHF batch group 9 (#28571)
Pre-stages AUTHOR_MAP entries for 9 new/under-mapped contributors whose
PRs are being salvaged in the May 2026 LHF batch group 9.
Contributors:
- jdelmerico (#28278 — signal require_mention filter)
- justemu (#27996 — matrix thread_require_mention)
- YuanHanzhong (#28029 — dashboard browser scrollback)
- noctilust (#28080 — drop stale TUI resume env)
- MoonJuhan (#28288 — tolerate unreadable JSONL transcripts)
- outsourc-e (#28164 — cron emoji ZWJ sequences)
- Zyrixtrex (#28275 — Google OAuth urlopen timeout)
- ooovenenoso (#28256 — tool loop recovery hints)
- vanthinh6886 (#28018 — yaml/flock/atomic write guards; non-noreply email)
Per references/batch-pr-salvage-may14-additions.md.
* feat(signal): add require_mention filter for group chats
Add a configurable mention filter to the Signal adapter so the bot
only responds in groups when it is explicitly @mentioned.
Changes:
- gateway/platforms/signal.py: read require_mention from adapter
extra config or SIGNAL_REQUIRE_MENTION env var; skip group messages
that don't mention the bot account (checked in rendered text and
raw mention metadata)
- gateway/config.py: map signal.require_mention YAML key to the
SIGNAL_REQUIRE_MENTION env var (env var takes precedence)
Config example:
signal:
require_mention: true
Or via env var:
SIGNAL_REQUIRE_MENTION=true
* Revert "feat(telegram): pin incoming user message for duration of agent turn"
This reverts commit a724c3b9cf5f01e28365322ae5ae3a9579567806.
* Revert "feat(telegram): support quick-command-only menus"
This reverts commit b1acf80e17858e2e5ae7c0d412a3a573d7fcbca4.
* Revert "feat(send_message): auto-detect @username mentions and create Telegram entities"
This reverts commit cf814c96f613b38bd891ac941c32da653e81c7ad.
* Revert "fix(telegram): enforce TELEGRAM_ALLOWED_USERS allowlist on inbound messages"
This reverts commit db50af910be6b4171ea9cf54f4cc38be27ac1da6.
* fix(gateway): pre-mark sessions as resume_pending before drain to prevent data loss (#27856)
Pre-mark all running agent sessions as resume_pending BEFORE the drain
wait begins. If the service manager kills the process during the drain
(window), the durable marker is already written so the next gateway boot
can recover in-flight sessions. On graceful drain completion, clear the
early markers for sessions that finished successfully.
* fix(matrix): implement thread_require_mention to prevent multi-agent reply loops
In multi-agent shared Matrix rooms, multiple bots all participating in the
same thread could trigger infinite reply loops — each bot's reply re-engaged
the others because they were all in the bot-thread set. Discord has a
`thread_require_mention` opt-in for this; Matrix didn't.
Add `_parse_thread_require_mention(config)` (mirrors Discord's pattern).
In `_resolve_message_context`, when enabled and the message is in a
bot-participated thread (not a free-response room), require @mention
before processing.
Salvage of @justemu's 2-commit stack (#27996). Fixes #27995.
* fix(cli): show active profile in TUI prompt
* fix(tui): preserve dunder identifiers in markdown
* test(file_ops): add regression tests for git baseline warning in write_file
Adds TestGitBaselineCheck with 6 unit tests covering _check_git_baseline
and the warning field in write_file result:
- Git not available → None
- Not in a git repo → None
- Clean repo → None
- Dirty repo → returns warning string with branch name
- write_file result includes warning when dirty
- write_file result omits warning when clean
* fix(dashboard): use browser scrollback for chat wheel
* fix(cli): ignore stale HERMES_TUI_RESUME env
HERMES_TUI_RESUME is an internal env var the Python wrapper exports to hand
a session ID off to the Ink TUI. Because…
dimavrem22
pushed a commit
to inkbox-ai/hermes-agent
that referenced
this pull request
May 20, 2026
* fix(kanban): seed bundled skills (e.g. kanban-worker) on kanban init
Closes #23725
* fix(kanban): ignore stale HERMES_KANBAN_BOARD for removed boards
* fix(kanban): keep board-management commands independent from board override
* fix(kanban): preserve notifier_profile for dashboard home subscriptions
* fix(kanban): promote dependents when a parent is archived
* fix(cli): make kanban specify max_tokens configurable
* fix(kanban): sync slash subcommands with live parser
* fix(kanban): promote blocked tasks when parent dependencies complete
recompute_ready only scanned 'todo' tasks for promotion, ignoring
'blocked' tasks entirely. When a task was blocked (e.g. by the circuit
breaker) and its parent dependencies later completed, the task stayed
stuck in 'blocked' forever unless manually unblocked.
Now recompute_ready also scans 'blocked' tasks. When all parents are
done/archived, the blocked task is promoted to 'ready' with failure
counters reset — equivalent to an automatic unblock.
Includes a regression test for the blocked-parent-done promotion path.
* fix(kanban): use 'is not None' check for max_runtime_seconds in create_task
max_runtime_seconds=0 was being silently coerced to None due to a falsy
check (if max_runtime_seconds). Zero is a valid value that causes the
dispatcher to immediately time out a task. The adjacent max_retries
parameter already used the correct 'is not None' pattern.
Fixes the inconsistency by aligning max_runtime_seconds with max_retries.
* fix(kanban): reset failure counters on unblock_task
When a task is manually unblocked (blocked → ready/todo), the
consecutive_failures counter and last_failure_error were left intact.
The next failure would immediately re-trip the circuit breaker because
the counter was still at or above the failure limit.
Reset both fields on unblock so the task gets a fresh retry budget.
Includes a regression test that verifies counters are zeroed.
* fix(kanban): fingerprint crash errors to prevent fleet-wide retry exhaustion
When a systemic failure (provider outage, auth expiry, OOM) crashes
multiple workers simultaneously, detect_crashed_workers increments
each task failure counter independently. The circuit breaker only
trips after N × failure_limit retries across the fleet.
Fingerprint crash errors by normalizing host-specific details (PIDs,
timestamps). When 3+ tasks crash with the same fingerprint in a
single detection cycle, immediately trip the circuit breaker
(failure_limit=1) instead of waiting for repeated failures.
Isolated crashes (unique fingerprints) retain their normal retry
budget. Protocol violations continue to trip immediately.
Includes regression tests for systemic and isolated crash paths.
* fix(kanban): align board_exists with board discovery rules
* fix(kanban): demote ready children when a parent is reopened
* fix(kanban): serialize DB initialization
* fix(kanban): task_age() tolerates ISO-8601 timestamps
Prevents ValueError crash in dashboard get_board() when a task has
an ISO timestamp (e.g. "2026-05-10T15:00:00Z") instead of a unix epoch
int. Adds _to_epoch() helper that normalises both formats.
* Fix Kanban dashboard initial board selection
* fix(kanban): persist worker session metadata on completion
Salvages #25579 by @wesleysimplicio. Stamps task_runs.metadata.worker_session_id
from HERMES_SESSION_ID on kanban_complete. Cherry-picked the substantive
commit (not the AUTHOR_MAP fixup tip) onto current main.
* fix(kanban): make claim ttl configurable
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(kanban): pass accept-hooks to worker chat subprocess
* feat(kanban): add board-level default workdir (#25430)
* docs(kanban-worker): document notification routing configuration
* fix(kanban): preserve worker tools with restricted toolsets
* fix(kanban): make legacy task migration idempotent
(cherry picked from commit 293f1c3a7241b0117669e049d9aa746c9645ac90)
* fix: harden Kanban worker Hermes command resolution
* feat(kanban): allow trimmed task comments
SS-1647 live SHIP validation: real code + tests for kanban comment --max-len.
* fix: show scheduled kanban tasks in dashboard
* fix: assign single-task kanban decompositions
* fix(kanban-dashboard): make Orchestration mode checkbox label static
The checkbox label echoed its state ("Auto (default)" / "Manual") instead
of describing the action, so a checked box reading "Auto" parsed as a
status indicator rather than a control. The accompanying sub-description
was also static and started with "When on, ...", which read awkwardly
when the box was unchecked.
Replace the dynamic label with a static action label
("Auto-decompose triage tasks") and flip the sub-description between the
two modes so it stays accurate either way. The top-of-page Orchestration
pill is unchanged — that one is intentionally a status badge / toggle.
Fixes #28178
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(env): add HERMES_KANBAN_DISPATCH_IN_GATEWAY override (#21956)
Salvages the env-vars docs portion of #21956 by @Bartok9.
The ascii-guard-ignore tags from the original PR already landed on main.
* fix(kanban): close sqlite connection on init failure to prevent fd leak
Salvages #28301 by @Ade5954. If WAL setup, PRAGMA application, or schema
init raises after sqlite3.connect() succeeds, the new connection was
leaking. Wrap the body in try/except so the connection is closed before
the exception propagates.
* fix(kanban): don't crash dispatched workers when kanban-worker skill is absent
Salvages #27372 by @oemtalks. The dispatcher unconditionally injected
`--skills kanban-worker` into every worker spawn, but worker profiles
sometimes don't have that bundled skill in their skills dir, which is
fatal at CLI startup (`ValueError: Unknown skill(s): kanban-worker`).
Adds `_kanban_worker_skill_available(hermes_home)` and only injects the
flag when the skill resolves. The MANDATORY lifecycle still ships via
KANBAN_GUIDANCE in the system prompt, so omitting the flag is safe.
* fix(packaging): ship dashboard plugin assets in wheel
Salvages #23737 by @LeonSGP43. Adds plugins/* manifest.json and dist/
glob entries to setuptools package-data so wheel installs ship the
bundled dashboard plugin assets (kanban, achievements, etc.). Without
these, /api/dashboard/plugins can't discover plugin assets outside a
source checkout.
* docs(kanban): document worker protocol auto-blocks
Salvages #21585 by @helix4u. Documents the protocol_violation event
(worker exits successfully while task is still running), adds
--max-retries to the create flag list and --failure-limit to dispatch.
* fix(oneshot): pass fallback_providers from profile config to AIAgent
Salvages #23368 by @uzunkuyruk. Oneshot workers (e.g. kanban workers
spawned via 'hermes -p <profile> chat -q ...') were not honouring the
profile's fallback_providers / fallback_model chain because oneshot.py
never read the config and never passed fallback_model= to AIAgent.
Reads cfg.get('fallback_providers') (new list format) or
cfg.get('fallback_model') (legacy single-dict) with the same
normalization cli.py applies, then forwards as fallback_model=_fb.
* fix(kanban): reject direct running transitions in dashboard bulk updates
Salvages #24050 by @kronexoi. The single-task PATCH already rejects
direct status='running' since it bypasses the dispatcher/claim invariant,
but the bulk-update endpoint still accepted it. Aligns bulk with single
by emitting an error result row for any 'running' entry.
* feat(kanban): add initial-status for human-ops cards
Salvages #27526 by @shunsuke-hikiyama. Adds an --initial-status flag
(running|blocked, default running) to 'kanban create', threaded through
kanban_db.create_task() and the kanban_create tool schema. 'blocked'
parks the task directly in the blocked column for R3 human-ops review,
skipping the brief running-to-blocked transition.
Dropped the unrelated 'add' alias, WIFEXITED Windows compat, and
slash-handler error formatting changes that were bundled in the
original PR — those should ship as their own focused changes if still
wanted.
* fix(kanban): release scratch workspace and tmux session on task completion
Salvages #27369 by @LeonJS. complete_task() now calls _cleanup_workspace()
and _cleanup_worker_tmux() after marking a task complete.
Scratch workspaces (used by swarm agents) accumulate on disk — hundreds
of MB per task, never released. Stale tmux sessions from completed
agents also persist indefinitely.
Both gates are safe:
- workspace_kind == 'scratch' gate preserves user worktree/dir workspaces
- tmux #{pane_dead} == 1 gate only kills sessions where the worker has
already exited
- best-effort: cleanup failures never block task completion
* fix(kanban): honor severity thresholds in diagnostics
Salvages #26431 by @LeonSGP43. Dashboard plugin_api list_diagnostics
was using exact-match (severity == filter), so '--severity warning'
hid 'error' and 'critical' diagnostics. Adds severity_at_or_above()
helper to kanban_diagnostics and uses it in the dashboard endpoint
(CLI already used SEVERITY_ORDER comparison correctly).
* test: isolate Kanban env pins in hermetic fixture
Salvages the substantive part of #22295 by @steezkelly. Adds the
missing HERMES_KANBAN_HOME, HERMES_KANBAN_RUN_ID, HERMES_KANBAN_CLAIM_LOCK,
HERMES_KANBAN_DISPATCH_IN_GATEWAY entries to _HERMES_BEHAVIORAL_VARS so
ambient developer-shell pins on those vars don't bleed into pytest runs.
The frozenset extraction + standalone regression test from the original
PR were dropped to keep the change minimal — main already maintains the
list inline.
* feat(kanban): add max_in_progress config to cap concurrent running tasks
Salvages #22981 by @SimbaKingjoe. Adds 'kanban.max_in_progress' config
that caps simultaneously running tasks. When the board already has N
running, dispatcher skips spawning so slow workers (local LLMs,
resource-constrained hosts) don't pile up and time out.
Threads through dispatch_once(max_in_progress=) and gateway dispatcher
config parsing with validation (warns on invalid/below-1 values).
* fix(packaging): ship bundled skills in wheel
Salvages #23738 by @LeonSGP43. Wheel installs were missing skills/ and
optional-skills/ because pyproject's [tool.setuptools.packages.find]
only includes Python packages — the skills directories don't have
__init__.py so they were silently dropped from the wheel.
Adds setup.py with data_files spec emitting skills/* and optional-skills/*
under hermes_agent-<v>.data/data/, and a get_bundled_skills_dir() helper
in hermes_constants that discovers the wheel-installed location via
sysconfig before falling back to a source-checkout path. tools/skills_sync
uses the helper so 'hermes update' works for pip-installed users.
* fix: 4 small surgical bugs
Salvages #23302 by @Bartok9. Four independent one-area fixes:
1. kanban boards delete alias now hard-deletes (not archives) — the
alias didn't carry --delete, so getattr(args, 'delete', False)
returned False. Detect boards_action=='delete' explicitly.
2. Gateway auto-title failures no longer leak as user-visible
warnings — debug-log only since they're not actionable.
3. Background process completion notification snaps truncation to
the next newline boundary, prepends a marker when content is
dropped.
4. _cprint() schedules the run_in_terminal coroutine via
asyncio.ensure_future so output isn't silently dropped from
background threads (fixes #23185 Bug A). Skips the
double-print fallback that would fire for mock paths.
* perf(prompt): cache kanban worker guidance at session init
Salvages #24402 by @RyanRana. The KANBAN_GUIDANCE block (~835 tokens)
is session-static — the dispatcher decides at spawn time whether the
process is a kanban worker via the kanban_show tool's check_fn (gated
on HERMES_KANBAN_TASK env var). Re-checking 'kanban_show' in
valid_tool_names and re-loading the reference on every system-prompt
rebuild (init + each context compression) is wasted work.
Caches the resolved string on agent._kanban_worker_guidance once in
agent_init and consumes it in system_prompt.build_system_prompt(),
with a getattr fallback for code paths that bypass agent_init.
* feat(kanban): add --sort option to 'hermes kanban list'
Salvages #25745 by @LizerAIDev. Adds --sort {created,created-desc,
priority,priority-desc,status,assignee,title,updated} to 'hermes kanban
list'. Validated against VALID_SORT_ORDERS map; invalid values raise
ValueError. Default behaviour (priority DESC, created ASC) is unchanged
when --sort is omitted.
* docs: add kanban codex lane skill
* feat(kanban): worker visibility endpoints (workers/active, runs/{id}, inspect)
Adds three read-only endpoints to the kanban dashboard plugin so the
SwitchUI workspace (and any other dashboard consumer) can track
workers across tasks without N+1 round-trips through /tasks/{task_id}.
- GET /workers/active
Single SQL JOIN of task_runs + tasks where ended_at IS NULL,
worker_pid IS NOT NULL, status='running'. Returns
{workers: [...], count, checked_at}.
- GET /runs/{run_id}
Direct lookup of any task_run row by id. Reuses existing
kanban_db.get_run() helper and _run_dict() serialiser. 404 when
not found. Mirrors GET /tasks/{task_id} 404 shape.
- GET /runs/{run_id}/inspect
Live PID stats via psutil.Process.as_dict() — cpu_percent,
memory_rss_bytes, memory_vms_bytes, num_threads, num_fds, status,
create_time, cmdline. Short-circuits with alive:false when run
has ended, has no worker_pid, the pid is gone, or psutil is
unavailable. AccessDenied surfaces as alive:true with error
rather than a 500.
11 new tests in tests/plugins/test_kanban_worker_runs.py cover the
empty-board case, running-task case, ended-run filtering,
missing-pid filtering, 404 paths, already-ended inspect, no-pid
inspect, dead-pid inspect, and live-pid inspect (psutil mocked).
All pass.
Companion termination endpoint (POST /runs/{run_id}/terminate) is
intentionally out of scope here — opening a separate issue first
since the RBAC and dispatcher-mediated soft-cancel design needs
maintainer input before code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(release): map contributor email for attribution check
* test(kanban-dashboard): pin enriched 409 detail and inline error wiring (#26744)
- Existing ``test_patch_drag_drop_move_todo_to_ready`` now asserts the
enriched 409 detail names the blocking parent (id, quoted title, and
current status), so the dashboard always has something actionable to
render.
- New bundle-assertion test ``test_dashboard_surfaces_ready_blocked_error_inline``
pins the frontend wiring: the ``parseApiErrorMessage`` helper exists,
the drag/drop banner runs through it, and the drawer maintains a
visible ``patchErr`` state that's cleared between PATCHes and tasks.
* docs(codex_app_server): document multi-root Kanban writable_roots (#27941)
Update the Codex app-server runtime guide's Kanban section to reflect
the new behaviour:
* The sandbox override now adds the board DB directory plus every
Kanban path the dispatcher pinned (HERMES_KANBAN_WORKSPACES_ROOT,
HERMES_KANBAN_WORKSPACE, legacy HERMES_KANBAN_ROOT) -- deduplicated,
DB-dir first.
* The motivation note now includes the cross-mount artifact-write
scenario (e.g. ``/media/.../kanban-workspaces/...`` on a separate
drive) and links to issue #27941 so readers can find the original
bug report.
* fix(gateway): quiet corrupt kanban dispatcher boards
Salvages substantive part of #26490 by @aqilaziz. Detects corrupt board
DBs ("file is not a database" / "database disk image is malformed")
and disables them by fingerprint until they're repaired, instead of
flooding the gateway log with repeated logger.exception tracebacks every
tick.
Cherry-picked the substantive commit (ea5b4ec2a); the tip commit was
an unrelated _is_dir OSError fix for service-path lookup. Dropped a
small test reformat that was bundled in the same commit.
* docs: align kanban readiness docs and smoke tests
Salvages #28199 by @bensargotest-sys. Aligns Kanban docs with current
tool registration: dispatcher-spawned task workers get task tools,
profiles that explicitly enable the kanban toolset get orchestrator
routing tools (kanban_list, kanban_unblock). Corrects failure-limit
text to current default of 2. Hardens the e2e subprocess script to
resolve repo root and use the spawnable default assignee. Updates the
diagnostics severity fixture to assert error below the critical
threshold.
* feat(kanban): surface per-task model_override in show + tool output
Salvages #26897 by @loicnico96. The per-task model_override DB column
already exists on main, but it wasn't exposed in user-facing surfaces.
This adds:
- 'kanban show' prints 'model: <name>' when model_override is set
- kanban_show / kanban_list tool responses include the model_override field
Original branch was stale (PR was authored against an older field name
'model'); applied the substantive surface exposure manually using the
current 'model_override' field name.
* feat(cli): add kanban swarm topology helper
Salvages #26791 by @Niraven. Adds 'hermes kanban swarm' to create a
durable Kanban Swarm v1 graph: a completed root/blackboard card,
parallel worker cards, a verifier gated on all workers, and a
synthesizer gated on the verifier. Stores shared swarm blackboard
updates as structured JSON comments on the root card.
Self-contained: new hermes_cli/kanban_swarm.py module + CLI wiring +
unit tests.
* feat(kanban): add optional board parameter to all MCP tools
Salvages #27598 by @nnnet. Adds optional 'board' parameter to all 9
kanban_* MCP tools via shared _connect helper. Backwards compatible —
omitting board keeps current pinned-board behavior. Useful for
orchestrator profiles that route across multiple boards.
Two-file scope: tools/kanban_tools.py + tests.
* feat(kanban): stamp originating ACP session_id on tasks
Salvages #23208 by @awizemann. Tracks which chat session created a
kanban task so clients can render a per-session board without falling
back to tenant + time-window heuristics.
- Schema: tasks gains nullable session_id TEXT column with index
(additive migration in _migrate_add_optional_columns).
- ACP: server.py exposes the originating session id via HERMES_SESSION_ID
with save/restore around the agent loop.
- Tool: kanban_create reads HERMES_SESSION_ID (with explicit override).
- CLI: 'hermes kanban list --session <id>' filter; JSON output exposes
session_id.
* feat(kanban): wire dispatcher to dispatch review agents from review column
Salvages #23772 by @thewillhuang. Adds 'review' as a valid kanban task
status and extends dispatch_once to monitor the review column as a
second dispatch source (in addition to the existing ready column).
- Adds 'review' to VALID_STATUSES
- Adds claim_review_task() — atomically transitions review → running
- Adds has_spawnable_review() — health telemetry mirror
- Extends dispatch_once with a review column dispatch loop
- Review agents get 'sdlc-review' skill auto-loaded
Resolved 2 conflicts (VALID_STATUSES merge with main's 'scheduled' state,
test file additions). Adapted claim_review_task to main's
ttl_seconds: Optional[int] = None convention (matches claim_task).
* feat(kanban): stale detection for running tasks in dispatcher
Salvages #23790 by @thewillhuang. Adds detect_stale_running() to
the dispatcher cycle. Running tasks that have been started for longer
than dispatch_stale_timeout_seconds (default 14400 = 4h) without a
heartbeat in the last hour are auto-reclaimed to ready.
- New config kanban.dispatch_stale_timeout_seconds (default 14400, 0 disables)
- New 'stale' field on DispatchResult
- detect_stale_running() in kanban_db.py with heartbeat freshness check
- Records outcome='stale' on run close + 'stale' event; ticks failure counter
- Wires config through gateway embedded dispatcher
- Updates _cmd_dispatch verbose/JSON output and daemon logging
Resolved test-file end-of-file conflict by appending both halves.
* feat(kanban): filter tasks by workflow fields and runs by status/outcome
Salvages #26745 by @nehaaprasaad. Exposes filtering for the existing
workflow_template_id and current_step_key columns:
- list_tasks() accepts workflow_template_id and current_step_key kwargs
- 'hermes kanban list' adds matching CLI flags
- dashboard plugin_api also exposes the filters
Resolved a small conflict in list_tasks signature alongside main's
session_id and order_by additions; combined all three into the single
filter list.
* feat(kanban): add respawn guard to block repeat worker storms
Salvages #27484 by @fardoche6. Adds a respawn guard that skips worker
spawn for tasks where:
- a recent run already succeeded (recent_success — within guard window)
- the previous run hit a quota/auth error (blocker_auth, also auto-blocks)
- a recent task comment includes a GitHub PR URL (active_pr)
The guard prevents repeat worker storms on the same bug/task. Includes
the contributor's review-findings fixup (regex hardening, observability,
auth coverage).
Resolved a small DispatchResult conflict alongside main's 'stale' field;
kept both. Authorship preserved via rebase merge.
* feat(kanban): show dashboard cron jobs across profiles
Salvages #27568 by @SerenityTn. Dashboard cron page now lists cron
jobs from all profiles, with profile-aware filter UI and storage
routing. Includes test coverage for cross-profile listing, mutation,
deletion, and validation.
Also fixes orphan conflict markers in config.py left by an earlier
salvage merge (kanban.dispatch_stale_timeout_seconds was double-nested
in HEAD/PR markers from #28452 salvage of #23790).
* fix(kanban): remove orphan conflict markers from config.py (#28458)
PR #28452 (salvage of #23790, stale detection) merged with leftover
git conflict markers in hermes_cli/config.py around the
`dispatch_stale_timeout_seconds` config block, breaking config import
and any code path that loads it. Cleans up the markers and keeps both
config blocks (worker log rotation/orchestrator + stale detection).
Resolves a self-introduced regression.
* fix(kanban): remove orphan conflict markers from kanban.py (#28459)
PR #28454 (salvage of #26745, workflow filter) merged with leftover
git conflict markers in hermes_cli/kanban.py at three sites:
- _task_to_dict() (session_id alongside workflow_template_id/current_step_key)
- p_list parser (--sort alongside --workflow-template-id/--step-key)
- _cmd_list (order_by alongside the new filter kwargs)
Cleans up the markers and keeps both halves at each site.
Resolves a self-introduced regression.
* feat(kanban): configure worktree paths and branches
Salvages #26496 by @aqilaziz. Adds branch_name column + CLI flag so
tasks with workspace_kind='worktree' can pin a target branch on
create. Schema migration added to _migrate_add_optional_columns.
- Task.branch_name field + DB column + migration
- create_task accepts branch_name kwarg
- hermes kanban create --branch <name> flag
- kanban show output includes 'Branch: <name>' when set
Cherry-picked the substantive commit (a7558cf27); the PR's tip was
an unrelated service-path-dirs commit. Resolved 2 INSERT-column-list
and show-output conflicts alongside main's session_id and
max_runtime_seconds additions; kept all three.
* feat(skills): add skill bundles — alias /<name> loads multiple skills (#28373)
Skill bundles are tiny YAML files in ~/.hermes/skill-bundles/ that
group several skills under one slash command. Invoking /<bundle-name>
from any surface (CLI, TUI, dashboard, any gateway platform) loads
every referenced skill into a single combined user message.
Use cases:
- /backend-dev → loads github-code-review + test-driven-development
+ github-pr-workflow as one bundle.
- /research → loads several research skills together.
- Team task profiles shared via dotfiles.
Behavior:
- Bundles take precedence over individual skills when slugs collide.
- Missing skills are skipped with a note, not fatal.
- No system-prompt mutation — bundles generate a fresh user message
at invocation time, the same way /<skill> does. Prompt cache stays
intact.
- Works in CLI dispatch, gateway dispatch, autocomplete (CLI + TUI),
/help display.
Schema (~/.hermes/skill-bundles/<slug>.yaml):
name: backend-dev
description: Backend feature work.
skills:
- github-code-review
- test-driven-development
instruction: |
Optional extra guidance prepended to the loaded skills.
New module: agent/skill_bundles.py — load, scan, resolve, build
invocation message, save, delete. yaml.safe_load only; broken
bundles log a warning and are skipped, never raise.
New CLI subcommand: hermes bundles {list,show,create,delete,reload}.
Implementation in hermes_cli/bundles.py; wired in hermes_cli/main.py.
'bundles' added to _BUILTIN_SUBCOMMANDS so plugin discovery skips it.
New in-session slash command: /bundles lists installed bundles in
both CLI and gateway. /<bundle-name> dispatch added to CLI (cli.py)
and gateway (gateway/run.py) before the existing /<skill-name> path.
Autocomplete: SlashCommandCompleter gained an optional
skill_bundles_provider parameter that defaults to None — the prompt
shows '▣ <description> (N skills)' for bundles vs '⚡' for skills.
Tests:
- tests/agent/test_skill_bundles.py — 33 tests covering slugify,
scan/cache freshness, resolve (including underscore→hyphen
Telegram alias), build_bundle_invocation_message (loading, missing
skills, user/bundle instruction injection, dedup), save/delete,
reload diff, list sort.
- tests/hermes_cli/test_bundles.py — 8 tests for the CLI
subcommand (create/list/show/delete/reload, --force, missing
bundle errors).
- tests/gateway/test_bundles_command.py — 4 tests for the gateway
handler and bundle resolution priority.
Live E2E: verified subprocess invocations of hermes bundles
{list,create,show,reload,delete} round-trip correctly against an
isolated HERMES_HOME.
Docs:
- website/docs/user-guide/features/skills.md — new 'Skill Bundles'
section with quick example, YAML schema, management commands,
behavior notes.
- website/docs/reference/cli-commands.md — 'hermes bundles' added to
the top-level command table and given its own subcommand section.
* feat(kanban): add scheduled status for delayed follow-ups
Salvages #24533 by @roycepersonalassistant. Adds a first-class
'scheduled' Kanban status for time-delay follow-ups that aren't
waiting on human input.
- hermes kanban schedule <task_id> [reason] CLI command
- Dashboard/API transitions to/from Scheduled
- unblock_task() now releases both 'blocked' AND 'scheduled' tasks
(re-checking parent dependencies before moving to ready/todo)
- i18n + docs updates
Resolved conflicts: kept HEAD's failure-counter reset on unblock
alongside the PR's scheduled state, kept HEAD's 'running' direct-set
rejection, combined both bulk-status branches. Dropped the dist/
bundle changes (months-stale; would need rebuild from source).
* feat(kanban): drag-to-delete trash zone + bulk delete for task cards
Salvages #28125 by @Jpalmer95. Adds:
- Drag-to-delete trash zone in the kanban dashboard
- Bulk delete endpoint with cascading delete_task cleanup
- Frontend updates (drag visual + drop handler)
- Confirmation prompt before delete
Resolved end-of-file test conflict by appending both halves.
* docs: add Korean Kanban documentation
Salvages #21823 by @pochi-gio. Adds Korean (ko) Docusaurus locale and
translates Kanban documentation (kanban.md, kanban-tutorial.md) and the
two related skills (devops-kanban-orchestrator, devops-kanban-worker).
Purely additive — adds ko to the locales list in docusaurus.config.ts
and creates the website/i18n/ko/ tree.
* fix(tests): catch up six stale tests after compression/aux/kanban changes (#28465)
- aux_config: drop session_search from _AUX_TASKS and remove stale test
(PR #27590 removed auxiliary.session_search from DEFAULT_CONFIG)
- compression_boundary_hook: set compressor._last_compress_aborted=False
on MagicMock so the post-compress abort branch (PR #28117) doesn't
short-circuit before the session-id rotation under test
- kanban_dashboard_plugin: use consecutive_failures=3 so severity stays
'error' (failure_threshold default dropped from 3 to 2 in d9fef0c8a,
so failures=5 now crosses the critical floor of 2*2=4)
- cli_manual_compress: accept force kwarg on DummyAgent._compress_context
(cli._manual_compress now passes force=True)
* fix(telegram): render full clarify choice text in message body, use short button labels
When Telegram clarify prompts offer long choices, mobile clients
truncate the inline button labels, making options unreadable.
Previously only the question was shown in the message body with
truncated choice text in button labels.
Fix: append the full numbered option list to the message body
so users can read complete choice text on any client. Buttons
now use short numeric labels (1, 2, ...) to avoid Telegram
truncation. The 'Other (type answer)' button is unchanged.
Long choice labels are now rendered in full (not truncated to
57 chars + '...') since they appear in the body instead of
button labels.
Closes: #27497
* chore(release): map @asdlem for PR #27852 salvage
* fix(telegram): default streaming transport to edit
* fix(telegram): respect reply_to_mode for DM topic reply fallback
The DM topic reply fallback code in send() hardcoded should_thread=True
when telegram_dm_topic_reply_fallback metadata was present, bypassing
_should_thread_reply() and ignoring reply_to_mode config. This caused
quote bubbles on every response even with reply_to_mode: 'off'.
Fix:
- Add reply_to_mode param to _reply_to_message_id_for_send() and
_thread_kwargs_for_send() classmethods
- In send(), check self._reply_to_mode != 'off' for DM topic fallback
- Suppress reply anchor and reply_to_message_id when mode is 'off'
while preserving message_thread_id for correct topic routing
- Thread reply_to_mode through all 29 call sites
Regression coverage: 10 new tests in test_telegram_reply_mode.py
covering classmethod behavior, send() integration, and backward
compatibility.
Fixes reply_to_mode: 'off' ignored by Telegram DM topic reply fallback code #23994
* fix(gateway): route Telegram audio file attachments away from STT pipeline (#24870)
Telegram distinguishes three kinds of audio payloads:
- message.voice → Opus/OGG voice messages → STT pipeline ✓
- message.audio → audio file attachments → bypasses STT ← was broken
- message.document (audio mime) → generic file route
**Root cause** — the inbound message routing block in gateway/run.py
matched both MessageType.VOICE *and* MessageType.AUDIO into audio_paths,
which were then fed unconditionally to _enrich_message_with_transcription.
Audio file attachments (.mp3, .m4a, etc.) were therefore auto-transcribed
instead of being treated as files, making the transcribe skill unusable
from Telegram because the path it needed was never surfaced.
**Fix**
- Introduce a new audio_file_paths list populated exclusively by
MessageType.AUDIO events.
- Narrow the audio_paths selector to MessageType.VOICE (and bare
audio/ mime-type events that are not explicitly AUDIO or DOCUMENT).
- After the STT block, inject a document-style context note for each
audio_file_path, giving the agent the file path and asking what to do
with it (consistent with how plain documents are handled).
**Tests** — 5 new tests in test_telegram_audio_vs_voice.py:
- voice message still transcribed (regression guard)
- audio attachment skips STT (core fix)
- audio attachment context note format
- STT disabled still produces file note (not STT-disabled notice)
- MessageType.AUDIO != MessageType.VOICE sanity check
Fixes #24870
* chore(release): map bartok9 noreply for PR #24879 salvage
* fix(send_message): route standalone Telegram sends through TELEGRAM_PROXY
When the send_message tool runs outside the gateway process (agent loop,
TUI, cron, etc.), _gateway_runner_ref() returns None and the standalone
path in _send_telegram constructs Bot(token=token) directly, bypassing
any configured proxy. In regions where api.telegram.org is blocked, the
send times out after ~5s with 'Telegram send failed: Timed out' and
nothing ever shows up in gateway.log because the request never reaches
the gateway.
Resolve TELEGRAM_PROXY (via gateway.platforms.base.resolve_proxy_url,
which also honours HTTPS_PROXY/HTTP_PROXY/ALL_PROXY and NO_PROXY) just
before constructing the Bot. When a proxy is found, attach an
HTTPXRequest(proxy=...) for both 'request' and 'get_updates_request',
matching what gateway/platforms/telegram.py already does for in-gateway
sends and what the Discord standalone sender already does. Any
exception attaching the proxy falls back cleanly to a direct connection,
preserving prior behaviour for users without a proxy configured.
Adds tests/tools/test_send_message_telegram_proxy.py covering both the
proxy-configured and no-proxy cases.
* chore(release): map @pepelax for PR #25419 salvage
* fix(kanban-dashboard): restore implementations dropped during salvages (#28481)
Four kanban dashboard test failures, all from PR salvages that picked up
the test additions but dropped the corresponding implementations.
- BOARD_COLUMNS: add 'review' (status added by PR f55d94a1e but the
board API never grew the column → test_board_empty failed because
VALID_STATUSES - {archived} mismatched the rendered columns).
- update_task: enrich the 'ready' 409 detail with the blocking parent
list (id, title, status) and add _parents_blocking_ready helper.
Implementation lost in the #26744 salvage (commit e215558ba) which
pinned the test but not the server-side code.
- dist/index.js: add parseApiErrorMessage helper, wire it through the
drag/drop banner, add patchErr state to the TaskDrawer and surface
it inline by the action row. Lost in the same #26744 salvage.
- test_diagnostics_endpoint_severity_filter: update to at-or-above
semantics (PR a94ddd807 changed the filter from exact-match so the
warning filter now correctly includes error+critical too).
* fix(gateway): roll over Telegram tool progress bubbles
* fix(gateway): scope audio_file_paths outside media_urls guard
The audio-file-paths handling block at line 7334 references the variable
unconditionally, but #24879 initialized it inside the 'if event.media_urls'
block — so events without media_urls hit UnboundLocalError.
Found via test_run_agent_queued_message_does_not_treat_commentary_as_final
after PR #28478 landed.
* fix(gateway): keep tool-progress edits alive after Telegram flood control
When a progress-message edit hits Telegram flood control (RetryAfter),
can_edit was unconditionally set to False, permanently disabling coalescing
for the rest of the run. Subsequent tool updates were posted as separate
new messages instead of updating the existing progress bubble.
Fix: only set can_edit=False for non-recoverable edit errors. On flood
control, back off by resetting _last_edit_ts so the throttle interval is
respected before the next edit attempt.
Fixes #25188
* chore(release): map @erhnysr for PR #25198 salvage
* fix(telegram): preserve can_edit after transient network errors in progress edits (#27828)
When edit_message_text fails with a transient error (httpx.ConnectError,
NetworkError, server disconnected, timeouts), the progress-message sender
must not permanently set can_edit = False — that would convert a single
Telegram network hiccup into separate per-tool bubbles for the rest of the run.
Changes:
- gateway/platforms/telegram.py: edit_message now returns retryable=True for
transient network errors (ConnectError, NetworkError, timeouts, server
disconnects, temporarily unavailable). Permanent failures (flood control,
message-not-found, permissions) remain retryable=False.
- gateway/run.py: send_progress_messages checks result.retryable before
setting can_edit = False. Transient failures skip the fallback-send and
continue — the next edit cycle catches up with the accumulated lines.
Permanent failures (flood, message-not-found, etc.) still disable editing.
Tests: 22 new tests in test_telegram_progress_edit_transient.py covering
transient vs permanent error classification, SendResult.retryable semantics,
and the can_edit decision logic.
Fixes #27828
* fix(telegram): recover from post-update polling conflict without entering limbo
* fix(test+release): update conflict retry count for MAX=5; map @CryptoByz
* fix(gateway): route background-process notifications into Telegram DM topics
Background-process completion notifications (notify_on_complete) and
watch-pattern notifications were always delivered to the Telegram main
chat instead of the originating private-chat topic.
Hermes-created Telegram DM topic lanes only render a send when it carries
both message_thread_id and a reply anchor. The synthetic MessageEvent
injected on process completion had no message_id, so _reply_anchor_for_event
returned None and _thread_kwargs_for_send dropped message_thread_id
entirely — routing the notification to the main chat.
Capture the triggering message id at spawn time and thread it through to
the synthetic event so it can be reply-anchored back into the topic:
- session_context: add HERMES_SESSION_MESSAGE_ID context var
- telegram adapter: populate SessionSource.message_id on inbound messages
- terminal tool: persist watcher_message_id on the process session
- process registry: carry/persist message_id on watcher dicts + checkpoint
- gateway: set MessageEvent.message_id on injected notifications
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(release): map @fabiosiqueira for PR #27212 salvage
* fix(telegram): route resumed DM topic sends directly
* fix(telegram): enforce TELEGRAM_ALLOWED_USERS allowlist on inbound messages
TELEGRAM_ALLOWED_USERS was only checked for callback/inline-button
actions but not for inbound messages. Unauthorized users triggered an
'Unauthorized user' log warning but their messages were still processed
by the agent — a P0 security bypass (issue #23778).
Fix: add allowlist check in _should_process_message() which is called
for all message types (text, command, media, location). If the sender
is not in TELEGRAM_ALLOWED_USERS, the message is dropped immediately
with a warning log. Empty TELEGRAM_ALLOWED_USERS continues to allow
all users (existing behavior).
Fixes #23778
* fix(telegram): fail-closed auth fallback when TELEGRAM_ALLOWED_USERS is empty
The _is_callback_user_authorized fallback returned True when
TELEGRAM_ALLOWED_USERS was not set, allowing any Telegram user
to interact with the bot. Change to fail-closed: deny by default
unless GATEWAY_ALLOW_ALL_USERS=true is explicitly set.
Fixes #24457
* test(telegram): stub _is_callback_user_authorized in trigger-gating fixture
After PR #24468 made the empty-allowlist callback auth fail-closed
(and #23795 wired _is_callback_user_authorized into _should_process_message),
trigger-gating tests started failing because their fake messages from
user 111 hit the new deny-by-default path before trigger evaluation.
Force-authorize all senders in _make_adapter() so the trigger logic
under test runs. The fail-closed behavior itself is covered by
test_telegram_callback_auth_fail_closed.py.
* fix(telegram): reset sticky fallback IP on connect failure, retry primary DNS
When a sticky fallback IP (from DoH discovery) becomes unreachable,
the transport previously got stuck in an attempt_order that only
tried the dead IP. This prevented the gateway from recovering
until the service was restarted.
Changes:
- Always include primary DNS path (None) after the sticky IP in the
attempt_order so that a primary-path retry happens on sticky failure.
- Reset self._sticky_ip to None when the currently sticky IP hits
a connect timeout / connect error, allowing the next request to
retry from scratch.
Fixes silent Telegram disconnection when discovered fallback IPs
are transiently or permanently unreachable.
* test+release: align stale sticky-IP test for #24511; map @falconexe
* fix(telegram): propagate extra base_url config
* feat(send_message): auto-detect @username mentions and create Telegram entities
When sending messages containing @username patterns, auto-generate
MessageEntity(type='mention') entries so that the receiving bot's
require_mention filter can trigger. This enables proper bot-to-bot
interop where mention-based routing is used.
* test+release: align send_message mocks for MessageEntity import; map @fonhal
* fix(telegram): resume typing indicator after inline approval click (#27853)
The text /approve and /deny paths in gateway/run.py call
resume_typing_for_chat() after resolve_gateway_approval() succeeds, but
the Telegram inline-button (ea:*) callback in _handle_callback_query did
not. Typing is paused when the approval is sent (gateway/run.py:15658),
so without a matching resume the typing indicator stayed gone for the
remainder of a long-running turn after a button click.
Symmetry-match the text path: after a successful resolve, call
self.resume_typing_for_chat(str(query_chat_id)). Guarded by count > 0
to match /approve's "if not count" early-return — if nothing was
actually resolved, the agent thread was never unblocked, so typing
should remain paused.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(gateway): mark final voice reply as notify-worthy so Telegram delivers it audibly
In Telegram "important" notifications mode (default), TelegramPlatformAdapter
sets ``disable_notification=True`` on every send unless metadata carries
``notify=True``. GatewayRunner._send_voice_reply already passes thread
metadata through to ``adapter.send_voice``, but never marks the final
auto-TTS voice reply as notify-worthy — so users with the default mode get
the final voice note delivered silently with no push notification.
Mirror the final-text path in gateway/platforms/base.py (the existing
text-response final send already adds ``metadata["notify"] = True``).
Issue #27970 Bug 2. Bug 1 (MP3 vs. native OGG voice-note) is being
addressed by existing PRs #20182 / #20878 — this PR is intentionally
scoped to the silent-delivery bug only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: avoid Telegram group reply thread session splits
* chore(release): map @eliteworkstation94-ai for PR #28157 salvage
* fix(gateway): avoid duplicate Telegram text after auto-TTS voice replies
* chore(release): map @Zyrixtrex for PR #26754 salvage
* fix(telegram): escape send_slash_confirm preview with format_message
send_slash_confirm() sent the raw command preview with ParseMode.MARKDOWN,
skipping the format_message() conversion applied to every other dynamic
send in the adapter. Commands with underscores, dots, brackets, or other
MarkdownV2-sensitive characters raised BadRequest: Can't parse entities;
the exception was swallowed by the outer try/except, so the confirmation
prompt silently never appeared.
Fix: wrap preview through format_message() and switch to MARKDOWN_V2,
symmetric with send_update_prompt and the callback sends fixed in
a69404052.
* chore(release): map @nftpoetrist for PR #25856 salvage
* fix(telegram): retry wrapped connect timeouts
* chore(release): map @samahn0601 for PR #27887 salvage
* fix(tts): keep native audio outside Telegram voice delivery
* chore(release): map @aqilaziz for PR #26406 salvage
* fix(gateway): pin Telegram DM-topic routing to user's current topic
Topic-mode DM replies were fragmenting one conversation across many sessions: a Reply on a message in another topic delivered Telegram's message_thread_id for *that* topic, and #3206's strip routed plain replies to the lobby. Both pulled the user away from their current session. Fix: when topic mode is on, rewrite source.thread_id to the user's most-recent binding if the inbound id is missing/General or not a known topic. Non-topic-mode users unchanged.
* chore(release): map @karthikeyann for PR #26609 salvage
* fix(send_message): add thread-not-found retry for Telegram forum topic sends
The standalone _send_telegram path in send_message_tool lacked the
thread-not-found fallback that the gateway adapter has. When a forum
topic thread_id was stale or deleted, the send would fail entirely
instead of retrying to the General topic.
Changes:
- Add _is_telegram_thread_not_found() helper matching gateway adapter
- Add thread-not-found retry in text send path
- Add thread-not-found retry in media send path (with f.seek(0))
- Separate text_kwargs from thread_kwargs to prevent
disable_web_page_preview leaking into send_photo/send_video calls
Closes #27012
* test(send_message): add thread-not-found retry tests for Telegram forum topics
Adds two tests to TestSendTelegramThreadIdMapping:
- test_thread_not_found_retries_without_message_thread_id
- test_thread_not_found_for_media_retries_without_message_thread_id
Refs #27012
* test(send_message): add thread-not-found retry tests for Telegram topics
Three tests covering the #27012 fix:
- test_is_thread_not_found_matches_expected_errors
- test_text_send_retries_without_thread_id_on_thread_not_found
- test_disable_web_page_preview_not_leaked_to_media_sends
116/116 existing tests still pass (no regressions).
* chore(release): map @kunci115 for PR #27098 salvage
* fix(gateway): register Telegram commands for groups
Register Telegram bot commands across default, private, and group scopes so
the slash-command menu is available outside DMs.
Changes from review feedback:
- Add asyncio.Lock to prevent race condition in _ensure_forum_commands
- Extract MAX_COMMANDS_PER_SCOPE constant (30) to avoid magic number
- Upgrade error logging from debug->warning in forum registration
- Add tests covering lazy forum registration and concurrent safety
- Remove /start handler from this PR (separate feature)
Fixes review: needs_work (race, magic number, log levels, missing tests)
* test+release: fix test fixture for forum_commands; map @chromalinx
* fix(telegram): gate profile bots by allowed topics
* chore(release): map @booker1207 for PR #25132 salvage
* fix(cron): route Telegram cron deliveries to a dedicated topic via TELEGRAM_CRON_THREAD_ID
When Telegram topic mode is enabled, cron messages delivered to the bot's
root DM (TELEGRAM_HOME_CHANNEL without a thread id) land in the system
lobby — replies there are rebuffed with the lobby reminder and
reply_to_message_id is dropped, so users cannot interact with the cron
output (#24409).
Add an optional TELEGRAM_CRON_THREAD_ID env var that overrides
TELEGRAM_HOME_CHANNEL_THREAD_ID for cron deliveries only. Operators can
create a "Cron" forum topic in the DM, point this var at its thread id,
and replies to cron messages will land in that topic's existing session
instead of the lobby. The home-channel thread id (used elsewhere, e.g.
restart notifications) is unchanged, and explicit
deliver="telegram:chat:thread" targets continue to win over the env var.
Per the reporter's clarification on 2026-05-13, option (a) (cron-side
route to a dedicated topic + config knob) was chosen.
Fixes #24409
* fix(telegram): route image documents (.png/.jpg/.webp/.gif) through vision pipeline
When users send images as documents (Telegram file picker), they were
rejected with "Unsupported document type" because SUPPORTED_DOCUMENT_TYPES
only includes text/office formats. Add SUPPORTED_IMAGE_DOCUMENT_TYPES
to base.py and handle them in telegram.py before the document check.
- Add SUPPORTED_IMAGE_DOCUMENT_TYPES constant to base.py
- Add MIME reverse-lookup for image types in telegram.py
- Route image documents through cache_image_from_bytes + vision pipeline
- Handle media groups for image documents
Closes: #20128, #18620
* test+release: stub auth in test_telegram_documents fixture; map @kiranvk-2011
* fix(gateway): prevent Windows Telegram /restart leaving gateway stopped
* chore(release): map @rak135 for PR #25960 salvage
* fix(telegram): preserve topic metadata on overflow edits
* feat(telegram): add disable_topic_auto_rename gateway flag
When Hermes auto-titles a session in a Telegram DM topic it currently
renames the topic itself to the generated title. That works for
operator-managed lanes (extra.dm_topics) but is disruptive for
ad-hoc Threaded-Mode topics that users name by hand — every first
exchange overwrites their chosen title.
Add gateway.platforms.telegram.extra.disable_topic_auto_rename (default
False, preserving prior behaviour). When set, both
_schedule_telegram_topic_title_rename and the underlying
_rename_telegram_topic_for_session_title short-circuit before touching
the Telegram API. Internal session titles (sessions list, TUI) keep
working unchanged.
Also bridge the legacy top-level telegram.disable_topic_auto_rename key
through to gateway.platforms.telegram.extra so users on the older
config layout don't have to migrate to enable it.
- Tests cover the runtime flag, the scheduling entry-point, and string
truthiness coercion for YAML-loaded values.
- Docs updated in messaging/telegram.md with an example block.
* chore(release): map @B0Tch1 for PR #27634 salvage
* fix(gateway): restore Telegram DM topic thread_id after session split (#27166)
When context compression triggers a mid-turn session split, source.thread_id
can be None on synthetic/recovered events. _thread_metadata_for_source then
returns None, causing the Telegram adapter to send with no message_thread_id
and the response lands in the General thread instead of the active DM topic.
Fix:
- hermes_state.py: Add get_telegram_topic_binding_by_session() for reverse
lookup by session_id (enabled by the existing UNIQUE INDEX on session_id).
- gateway/run.py: After session-split detection, if source is a Telegram DM
and source.thread_id is None, recover it from the binding via the new
method so _thread_metadata_for_source produces the correct thread routing.
- tests/: Coverage for the new lookup method and the recovery flow.
* chore(release): map @jackjin1997 for PR #27239 salvage
* fix(gateway): allow chat-scoped telegram auth without sender user_id
* chore(release): map @soynchux for PR #27806 salvage
* fix(telegram): add DM topic typing fallback when message_thread_id rejected
When a DM topic lane's message_thread_id is rejected by Telegram
(e.g. stale or deleted topic), send_typing now falls back to sending
the typing indicator without thread_id so it at least appears in the
main DM view, rather than being silently swallowed.
Also adds test for the fallback behavior.
* fix(telegram): report cron topic fallback
* chore(release): map @el-analista for PR #25368 salvage
* fix(telegram): wire gt: callback dispatch for gmail-triage buttons
The gmail-triage skill's Telegram inline buttons emit callback_data of the
form `gt:<verb>:<arg>`, but `_handle_callback_query` had no `gt:` branch —
taps fell through silently and the spinner sat there until Telegram timed it
out.
Add `_handle_gmail_triage_callback`, dispatched from the existing callback
router, that:
- Authorizes the caller via the same `_is_callback_user_authorized` path as
the approval / slash-confirm / clarify handlers.
- Maps each verb to a script under `~/.hermes/scripts/gmail-triage/` and runs
it async with a 60s timeout.
- Splits verbs into one-shots (send / archive / draft / spam) — append the
confirmation and strip the keyboard so the action can't fire twice — and
sticky-state changes (mute / trust / vip ± -domain) — append the
confirmation but leave the keyboard tappable so the user can stack actions
on one email.
- On failure: toast only, keyboard preserved so the user can retry.
- Logs every callback outcome to gateway.log for debugging.
* chore(release): map @khungate for PR #25829 salvage
* feat(telegram): support quick-command-only menus
* chore(release): map @stevehq26-bot for PR #28015 salvage
* fix(telegram): handle channel post updates
* test: address telegram channel post review
* test+release: stub auth in channel_posts fixture; map @brndnsvr
* Quiet noisy Telegram gateway errors
* chore(release): map oracle@jarviss-mbp.home for PR #24014 salvage
* Route Telegram multi-bot mentions exclusively
* Document Telegram multi-profile gateway commands
* fix: ignore Telegram messages for other bots
* chore(release): map @OCWC22 for PR #24581 salvage
* feat(telegram): ignore_root_dm with system command lobby
* docs(telegram): document ignore_root_dm feature
* chore(release): map @ai-hana-ai for PR #23928 salvage
* feat(telegram): pin incoming user message for duration of agent turn
When a user sends a message on Telegram, the incoming message is now
automatically pinned at the start of processing and unpinned when the
agent finishes its turn. This gives the user a visual indicator that
their message is being worked on, and keeps the conversation anchored.
Changes:
- telegram.py: Added pinChatMessage in on_processing_start and
unpinChatMessage in on_processing_complete. Restructured both
hooks so pin/unpin runs independently of the reactions feature
(reactions are optional; pinning is always on).
- telegram.py: Pass message_id through SessionSource so it's
available in the session context.
- session_context.py: Added HERMES_SESSION_MESSAGE_ID context var.
- run.py: Pass source.message_id through set_session_vars.
Pinning is silent (disable_notification=True) and failures are
logged at debug level without interrupting message processing.
Only the user's incoming message is pinned -- never the agent's
replies. Auto-resume events (which have no message_id) are
correctly skipped.
* chore(release): map @indigokarasu for PR #26636 salvage
* feat(telegram): skip-STT audio path + 2GB cap via local Bot API server
Two coordinated changes that unblock downstream audio pipelines
(diarization, custom transcription, archival) on attachments larger
than the public Bot API's 20MB getFile ceiling.
- `stt.enabled: false` no longer drops voice/audio with a generic
"transcription disabled" note. The gateway probes the cached file's
duration (wave → mutagen → ffprobe ladder) and surfaces
`[The user sent a voice message: <abs path> (duration: M:SS)]` to
the agent so a skill or tool can pick up the raw file. The previous
placeholder is replaced rather than appended when present.
- `platforms.telegram.extra.base_url` set → adapter auto-lifts its
document size cap from 20MB to 2GB (the local telegram-bot-api
`--local` ceiling) and the "too large" reply reports the active
limit dynamically. No new config knob; presence of `base_url` is the
opt-in.
- `platforms.telegram.extra.local_mode: true` wires
`Application.builder().local_mode(True)` on the python-telegram-bot
builder. PTB then reads files from disk instead of HTTP, which is
required when telegram-bot-api runs in `--local` mode (the server
returns absolute filesystem paths, not `/file/bot...` URLs).
- gateway/run.py: rewrites the `stt.enabled: false` branch of
`_enrich_message_with_transcription`. New `_format_duration` +
`_probe_audio_duration` helpers.
- gateway/platforms/telegram.py: `_max_doc_bytes` instance attribute
derived from `extra.base_url`; `local_mode` builder wiring;
dynamic "too large" message.
- tests/gateway/test_stt_config.py: covers path-surfacing with and
without an existing user message, and placeholder replacement.
- tests/gateway/test_telegram_max_doc_bytes.py: 3 cases — default 20MB
without base_url, 2GB when set, empty-string base_url keeps default.
- website/docs/user-guide/messaging/telegram.md: new "Skipping STT"
subsection under Voice Messages and a full "Large Files (>20MB) via
Local Bot API Server" walkthrough (api_id/api_hash, docker-compose,
one-time `logOut` migration, `platforms.telegram.extra` config, the
`local_mode` disk-access requirement, the silent HTTP-fallback 404).
- website/docs/user-guide/features/voice-mode.md: documents the
`stt.enabled` knob in the config reference.
- `pytest tests/gateway/test_telegram_max_doc_bytes.py
tests/gateway/test_stt_config.py` → 9/9 passing.
- Verified end-to-end on a live deployment: gateway log shows
`Using custom Telegram base_url: http://...` and
`Using Telegram local_mode (read files from disk)` on startup;
voice messages above 20MB cache to disk and surface their path to
the agent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(release): map @alber70g for PR #25280 salvage
* fix(web): add scheduled column to i18n type definitions (#28549)
columnLabels and columnHelp in en.ts include a scheduled entry but the
Translations interface in types.ts did not declare it, causing a
TypeScript build failure in the Nix derivation. Made the field optional
since only en.ts provides it currently.
* docs: comprehensive 2-week sweep of feature/PR coverage gaps (#28497)
Catch the website docs up to two weeks of merged work (May 4 – May 18, 2026,
roughly 1,080 PRs). The audit found ~50 user-visible features that had landed
in code with no docs footprint, plus a handful of stale pages. This PR closes
every gap the scan turned up.
New pages
- user-guide/features/deliverable-mode.md — extension list, agent triggers,
kanban_complete artifacts pattern, [[as_document]] override (PR #27813).
- developer-guide/web-search-provider-plugin.md — authoring guide modeled on
image-gen-provider-plugin, covering brave_free / ddgs / etc. (PR #25448).
Providers / auth
- Rename "Alibaba Cloud" → "Qwen Cloud (Alibaba DashScope)" everywhere the
display label shows up; provider id stays `alibaba` (PR #24835).
- Document OAuth refresh-token quarantine for xAI / MiniMax / Codex (PRs
#28116 / #28118 / #28119).
- Document Nous JWT minting from refresh token + invalid-refresh quarantine
+ cross-profile shared token store (PRs #27663 / #19712).
- Add `## Microsoft Entra ID authentication (keyless)` section to
azure-foundry guide — DefaultAzureCredential, RBAC, OpenAI + Anthropic
routing details (PR #28101 / #9df9816da).
- Custom providers `api_mode` is now prompted-and-persisted, not just URL
autodetected (PR #25068).
- Delegation honours `api_mode` + auto-detects anthropic_messages base URLs
(PR #26824).
- `x_search` auto-enables when xAI credentials are present (PR #27376).
- Add `xAI Grok OAuth (SuperGrok)` row to providers headline table (PR
#26534).
- NVIDIA NIM billing-origin header is set automatically (PR #26585).
Windows / installer
- `install.ps1`: document `-Commit <sha>` and `-Tag <v>` pin params plus
the BOM-strip / git-retry hardening (PR #28169).
- Document Hermes Desktop thin installer + first-launch bootstrap (PR
#27822).
- Document `dep_ensure` Windows bootstrap (PR #27845).
- Document install-method auto-detection (pip / git / homebrew / nixos) and
the matching update command (PR #27843).
Gateway / messaging
- `/platform list|pause|resume` full description + circuit-breaker
semantics (PR #26600).
- Slack / Matrix / Mattermost get parallel `allowed_channels` /
`allowed_rooms` allowlist sections matching Telegram/Discord/DingTalk
(PR #21251).
- Discord `allow_any_attachment` + `max_attachment_bytes` (config and env
vars) (PR #27245).
- Discord clarify-choice button rendering (PR #25485).
- Telegram `guest_mode` @mention bypass for allowlisted groups (PR
#22759).
- Telegram `notifications` mode (`important` vs `all`) (PR #22793).
- `[[as_document]]` skill / response directive for forcing
document-style media delivery (PR #21210).
CLI / TUI
- `/new [name]` argument (PR #19637).
- `/subgoal` user-supplied criteria appended to `/goal` (PR #25449).
- `/exit --delete` flag confirmation prompts for destructive slash
commands (PR #22687).
- Status-bar additions: ▶ N background indicator (PR #27175), context
compression count (PR #21218), YOLO mode banner+statusbar warning (PR
#26238).
- `display.timestamps` + `docker_extra_args` config keys (PR #23599).
- TUI collapsible startup banner sections (PR #20625).
- `HERMES_SESSION_ID` exported to tool subprocesses (PR #23847).
i18n
- Refresh display.language locale list from 8 → 16 (en, zh, zh-hant, ja,
de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu) — matches
`agent/i18n.py:SUPPORTED_LANGUAGES`.
Tools / features
- `vision_analyze` native-pixel passthrough for vision-capable callers,
with auxiliary text-describer fallback (PR #22955).
- `session_search` rewrite to the single-shape tool (discovery / scroll /
browse modes) (PRs #27590 / #27840).
- Clarify MCP transport scope: client supports stdio + SSE; embedded
`hermes mcp serve` is stdio-only (PR #21227).
- Web search backends table: add Brave Search (free tier) and DDGS rows
(PR #21337).
- ACP session-scoped edit auto-approval modes (PR #27862).
- Curator rename map in the user-visible per-run summary (PR #22910).
- Prompt caching feature page reference in features/overview.md — Claude
cross-session 1-hour prefix cache on native Anthropic / OpenRouter /
Nous Portal (PR #23828).
- Cron per-job profile parameter (PR #28124).
- `--no-skills` flag for `hermes profile create` (PR #20986).
Build
- Verified with `npm run build` in `website/`; both `en` and `zh-Hans`
locales compile. Remaining broken-link/anchor warnings are pre-existing
(`rl-training.md` from learning-path / overview; the
zh-Hans translation lag the docs skill already calls out).
* chore(release): pre-stage AUTHOR_MAP for May 2026 LHF batch group 9 (#28571)
Pre-stages AUTHOR_MAP entries for 9 new/under-mapped contributors whose
PRs are being salvaged in the May 2026 LHF batch group 9.
Contributors:
- jdelmerico (#28278 — signal require_mention filter)
- justemu (#27996 — matrix thread_require_mention)
- YuanHanzhong (#28029 — dashboard browser scrollback)
- noctilust (#28080 — drop stale TUI resume env)
- MoonJuhan (#28288 — tolerate unreadable JSONL transcripts)
- outsourc-e (#28164 — cron emoji ZWJ sequences)
- Zyrixtrex (#28275 — Google OAuth urlopen timeout)
- ooovenenoso (#28256 — tool loop recovery hints)
- vanthinh6886 (#28018 — yaml/flock/atomic write guards; non-noreply email)
Per references/batch-pr-salvage-may14-additions.md.
* feat(signal): add require_mention filter for group chats
Add a configurable mention filter to the Signal adapter so the bot
only responds in groups when it is explicitly @mentioned.
Changes:
- gateway/platforms/signal.py: read require_mention from adapter
extra config or SIGNAL_REQUIRE_MENTION env var; skip group messages
that don't mention the bot account (checked in rendered text and
raw mention metadata)
- gateway/config.py: map signal.require_mention YAML key to the
SIGNAL_REQUIRE_MENTION env var (env var takes precedence)
Config example:
signal:
require_mention: true
Or via env var:
SIGNAL_REQUIRE_MENTION=true
* Revert "feat(telegram): pin incoming user message for duration of agent turn"
This reverts commit a724c3b9cf5f01e28365322ae5ae3a9579567806.
* Revert "feat(telegram): support quick-command-only menus"
This reverts commit b1acf80e17858e2e5ae7c0d412a3a573d7fcbca4.
* Revert "feat(send_message): auto-detect @username mentions and create Telegram entities"
This reverts commit cf814c96f613b38bd891ac941c32da653e81c7ad.
* Revert "fix(telegram): enforce TELEGRAM_ALLOWED_USERS allowlist on inbound messages"
This reverts commit db50af910be6b4171ea9cf54f4cc38be27ac1da6.
* fix(gateway): pre-mark sessions as resume_pending before drain to prevent data loss (#27856)
Pre-mark all running agent sessions as resume_pending BEFORE the drain
wait begins. If the service manager kills the process during the drain
(window), the durable marker is already written so the next gateway boot
can recover in-flight sessions. On graceful drain completion, clear the
early markers for sessions that finished successfully.
* fix(matrix): implement thread_require_mention to prevent multi-agent reply loops
In multi-agent shared Matrix rooms, multiple bots all participating in the
same thread could trigger infinite reply loops — each bot's reply re-engaged
the others because they were all in the bot-thread set. Discord has a
`thread_require_mention` opt-in for this; Matrix didn't.
Add `_parse_thread_require_mention(config)` (mirrors Discord's pattern).
In `_resolve_message_context`, when enabled and the message is in a
bot-participated thread (not a free-response room), require @mention
before processing.
Salvage of @justemu's 2-commit stack (#27996). Fixes #27995.
* fix(cli): show active profile in TUI prompt
* fix(tui): preserve dunder identifiers in markdown
* test(file_ops): add regression tests for git baseline warning in write_file
Adds TestGitBaselineCheck with 6 unit tests covering _check_git_baseline
and the warning field in write_file result:
- Git not available → None
- Not in a git repo → None
- Clean repo → None
- Dirty repo → returns warning string with branch name
- write_file result includes warning when dirty
- write_file result omits warning when clean
* fix(dashboard): use browser scrollback for chat wheel
* fix(cli): ignore stale HERMES_TUI_RESUME env
HERMES_TUI_RESUME is an internal env va…
Arvuno
added a commit
to Arvuno/hermes-agent
that referenced
this pull request
May 20, 2026
…ecture decisions (#4) * docs(kanban): document worker protocol auto-blocks Salvages #21585 by @helix4u. Documents the protocol_violation event (worker exits successfully while task is still running), adds --max-retries to the create flag list and --failure-limit to dispatch. * fix(oneshot): pass fallback_providers from profile config to AIAgent Salvages #23368 by @uzunkuyruk. Oneshot workers (e.g. kanban workers spawned via 'hermes -p <profile> chat -q ...') were not honouring the profile's fallback_providers / fallback_model chain because oneshot.py never read the config and never passed fallback_model= to AIAgent. Reads cfg.get('fallback_providers') (new list format) or cfg.get('fallback_model') (legacy single-dict) with the same normalization cli.py applies, then forwards as fallback_model=_fb. * fix(kanban): reject direct running transitions in dashboard bulk updates Salvages #24050 by @kronexoi. The single-task PATCH already rejects direct status='running' since it bypasses the dispatcher/claim invariant, but the bulk-update endpoint still accepted it. Aligns bulk with single by emitting an error result row for any 'running' entry. * feat(kanban): add initial-status for human-ops cards Salvages #27526 by @shunsuke-hikiyama. Adds an --initial-status flag (running|blocked, default running) to 'kanban create', threaded through kanban_db.create_task() and the kanban_create tool schema. 'blocked' parks the task directly in the blocked column for R3 human-ops review, skipping the brief running-to-blocked transition. Dropped the unrelated 'add' alias, WIFEXITED Windows compat, and slash-handler error formatting changes that were bundled in the original PR — those should ship as their own focused changes if still wanted. * fix(kanban): release scratch workspace and tmux session on task completion Salvages #27369 by @LeonJS. complete_task() now calls _cleanup_workspace() and _cleanup_worker_tmux() after marking a task complete. Scratch workspaces (used by swarm agents) accumulate on disk — hundreds of MB per task, never released. Stale tmux sessions from completed agents also persist indefinitely. Both gates are safe: - workspace_kind == 'scratch' gate preserves user worktree/dir workspaces - tmux #{pane_dead} == 1 gate only kills sessions where the worker has already exited - best-effort: cleanup failures never block task completion * fix(kanban): honor severity thresholds in diagnostics Salvages #26431 by @LeonSGP43. Dashboard plugin_api list_diagnostics was using exact-match (severity == filter), so '--severity warning' hid 'error' and 'critical' diagnostics. Adds severity_at_or_above() helper to kanban_diagnostics and uses it in the dashboard endpoint (CLI already used SEVERITY_ORDER comparison correctly). * test: isolate Kanban env pins in hermetic fixture Salvages the substantive part of #22295 by @steezkelly. Adds the missing HERMES_KANBAN_HOME, HERMES_KANBAN_RUN_ID, HERMES_KANBAN_CLAIM_LOCK, HERMES_KANBAN_DISPATCH_IN_GATEWAY entries to _HERMES_BEHAVIORAL_VARS so ambient developer-shell pins on those vars don't bleed into pytest runs. The frozenset extraction + standalone regression test from the original PR were dropped to keep the change minimal — main already maintains the list inline. * feat(kanban): add max_in_progress config to cap concurrent running tasks Salvages #22981 by @SimbaKingjoe. Adds 'kanban.max_in_progress' config that caps simultaneously running tasks. When the board already has N running, dispatcher skips spawning so slow workers (local LLMs, resource-constrained hosts) don't pile up and time out. Threads through dispatch_once(max_in_progress=) and gateway dispatcher config parsing with validation (warns on invalid/below-1 values). * fix(packaging): ship bundled skills in wheel Salvages #23738 by @LeonSGP43. Wheel installs were missing skills/ and optional-skills/ because pyproject's [tool.setuptools.packages.find] only includes Python packages — the skills directories don't have __init__.py so they were silently dropped from the wheel. Adds setup.py with data_files spec emitting skills/* and optional-skills/* under hermes_agent-<v>.data/data/, and a get_bundled_skills_dir() helper in hermes_constants that discovers the wheel-installed location via sysconfig before falling back to a source-checkout path. tools/skills_sync uses the helper so 'hermes update' works for pip-installed users. * fix: 4 small surgical bugs Salvages #23302 by @Bartok9. Four independent one-area fixes: 1. kanban boards delete alias now hard-deletes (not archives) — the alias didn't carry --delete, so getattr(args, 'delete', False) returned False. Detect boards_action=='delete' explicitly. 2. Gateway auto-title failures no longer leak as user-visible warnings — debug-log only since they're not actionable. 3. Background process completion notification snaps truncation to the next newline boundary, prepends a marker when content is dropped. 4. _cprint() schedules the run_in_terminal coroutine via asyncio.ensure_future so output isn't silently dropped from background threads (fixes #23185 Bug A). Skips the double-print fallback that would fire for mock paths. * perf(prompt): cache kanban worker guidance at session init Salvages #24402 by @RyanRana. The KANBAN_GUIDANCE block (~835 tokens) is session-static — the dispatcher decides at spawn time whether the process is a kanban worker via the kanban_show tool's check_fn (gated on HERMES_KANBAN_TASK env var). Re-checking 'kanban_show' in valid_tool_names and re-loading the reference on every system-prompt rebuild (init + each context compression) is wasted work. Caches the resolved string on agent._kanban_worker_guidance once in agent_init and consumes it in system_prompt.build_system_prompt(), with a getattr fallback for code paths that bypass agent_init. * feat(kanban): add --sort option to 'hermes kanban list' Salvages #25745 by @LizerAIDev. Adds --sort {created,created-desc, priority,priority-desc,status,assignee,title,updated} to 'hermes kanban list'. Validated against VALID_SORT_ORDERS map; invalid values raise ValueError. Default behaviour (priority DESC, created ASC) is unchanged when --sort is omitted. * docs: add kanban codex lane skill * feat(kanban): worker visibility endpoints (workers/active, runs/{id}, inspect) Adds three read-only endpoints to the kanban dashboard plugin so the SwitchUI workspace (and any other dashboard consumer) can track workers across tasks without N+1 round-trips through /tasks/{task_id}. - GET /workers/active Single SQL JOIN of task_runs + tasks where ended_at IS NULL, worker_pid IS NOT NULL, status='running'. Returns {workers: [...], count, checked_at}. - GET /runs/{run_id} Direct lookup of any task_run row by id. Reuses existing kanban_db.get_run() helper and _run_dict() serialiser. 404 when not found. Mirrors GET /tasks/{task_id} 404 shape. - GET /runs/{run_id}/inspect Live PID stats via psutil.Process.as_dict() — cpu_percent, memory_rss_bytes, memory_vms_bytes, num_threads, num_fds, status, create_time, cmdline. Short-circuits with alive:false when run has ended, has no worker_pid, the pid is gone, or psutil is unavailable. AccessDenied surfaces as alive:true with error rather than a 500. 11 new tests in tests/plugins/test_kanban_worker_runs.py cover the empty-board case, running-task case, ended-run filtering, missing-pid filtering, 404 paths, already-ended inspect, no-pid inspect, dead-pid inspect, and live-pid inspect (psutil mocked). All pass. Companion termination endpoint (POST /runs/{run_id}/terminate) is intentionally out of scope here — opening a separate issue first since the RBAC and dispatcher-mediated soft-cancel design needs maintainer input before code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): map contributor email for attribution check * test(kanban-dashboard): pin enriched 409 detail and inline error wiring (#26744) - Existing ``test_patch_drag_drop_move_todo_to_ready`` now asserts the enriched 409 detail names the blocking parent (id, quoted title, and current status), so the dashboard always has something actionable to render. - New bundle-assertion test ``test_dashboard_surfaces_ready_blocked_error_inline`` pins the frontend wiring: the ``parseApiErrorMessage`` helper exists, the drag/drop banner runs through it, and the drawer maintains a visible ``patchErr`` state that's cleared between PATCHes and tasks. * docs(codex_app_server): document multi-root Kanban writable_roots (#27941) Update the Codex app-server runtime guide's Kanban section to reflect the new behaviour: * The sandbox override now adds the board DB directory plus every Kanban path the dispatcher pinned (HERMES_KANBAN_WORKSPACES_ROOT, HERMES_KANBAN_WORKSPACE, legacy HERMES_KANBAN_ROOT) -- deduplicated, DB-dir first. * The motivation note now includes the cross-mount artifact-write scenario (e.g. ``/media/.../kanban-workspaces/...`` on a separate drive) and links to issue #27941 so readers can find the original bug report. * fix(gateway): quiet corrupt kanban dispatcher boards Salvages substantive part of #26490 by @aqilaziz. Detects corrupt board DBs ("file is not a database" / "database disk image is malformed") and disables them by fingerprint until they're repaired, instead of flooding the gateway log with repeated logger.exception tracebacks every tick. Cherry-picked the substantive commit (ea5b4ec2a); the tip commit was an unrelated _is_dir OSError fix for service-path lookup. Dropped a small test reformat that was bundled in the same commit. * docs: align kanban readiness docs and smoke tests Salvages #28199 by @bensargotest-sys. Aligns Kanban docs with current tool registration: dispatcher-spawned task workers get task tools, profiles that explicitly enable the kanban toolset get orchestrator routing tools (kanban_list, kanban_unblock). Corrects failure-limit text to current default of 2. Hardens the e2e subprocess script to resolve repo root and use the spawnable default assignee. Updates the diagnostics severity fixture to assert error below the critical threshold. * feat(kanban): surface per-task model_override in show + tool output Salvages #26897 by @loicnico96. The per-task model_override DB column already exists on main, but it wasn't exposed in user-facing surfaces. This adds: - 'kanban show' prints 'model: <name>' when model_override is set - kanban_show / kanban_list tool responses include the model_override field Original branch was stale (PR was authored against an older field name 'model'); applied the substantive surface exposure manually using the current 'model_override' field name. * feat(cli): add kanban swarm topology helper Salvages #26791 by @Niraven. Adds 'hermes kanban swarm' to create a durable Kanban Swarm v1 graph: a completed root/blackboard card, parallel worker cards, a verifier gated on all workers, and a synthesizer gated on the verifier. Stores shared swarm blackboard updates as structured JSON comments on the root card. Self-contained: new hermes_cli/kanban_swarm.py module + CLI wiring + unit tests. * feat(kanban): add optional board parameter to all MCP tools Salvages #27598 by @nnnet. Adds optional 'board' parameter to all 9 kanban_* MCP tools via shared _connect helper. Backwards compatible — omitting board keeps current pinned-board behavior. Useful for orchestrator profiles that route across multiple boards. Two-file scope: tools/kanban_tools.py + tests. * feat(kanban): stamp originating ACP session_id on tasks Salvages #23208 by @awizemann. Tracks which chat session created a kanban task so clients can render a per-session board without falling back to tenant + time-window heuristics. - Schema: tasks gains nullable session_id TEXT column with index (additive migration in _migrate_add_optional_columns). - ACP: server.py exposes the originating session id via HERMES_SESSION_ID with save/restore around the agent loop. - Tool: kanban_create reads HERMES_SESSION_ID (with explicit override). - CLI: 'hermes kanban list --session <id>' filter; JSON output exposes session_id. * feat(kanban): wire dispatcher to dispatch review agents from review column Salvages #23772 by @thewillhuang. Adds 'review' as a valid kanban task status and extends dispatch_once to monitor the review column as a second dispatch source (in addition to the existing ready column). - Adds 'review' to VALID_STATUSES - Adds claim_review_task() — atomically transitions review → running - Adds has_spawnable_review() — health telemetry mirror - Extends dispatch_once with a review column dispatch loop - Review agents get 'sdlc-review' skill auto-loaded Resolved 2 conflicts (VALID_STATUSES merge with main's 'scheduled' state, test file additions). Adapted claim_review_task to main's ttl_seconds: Optional[int] = None convention (matches claim_task). * feat(kanban): stale detection for running tasks in dispatcher Salvages #23790 by @thewillhuang. Adds detect_stale_running() to the dispatcher cycle. Running tasks that have been started for longer than dispatch_stale_timeout_seconds (default 14400 = 4h) without a heartbeat in the last hour are auto-reclaimed to ready. - New config kanban.dispatch_stale_timeout_seconds (default 14400, 0 disables) - New 'stale' field on DispatchResult - detect_stale_running() in kanban_db.py with heartbeat freshness check - Records outcome='stale' on run close + 'stale' event; ticks failure counter - Wires config through gateway embedded dispatcher - Updates _cmd_dispatch verbose/JSON output and daemon logging Resolved test-file end-of-file conflict by appending both halves. * feat(kanban): filter tasks by workflow fields and runs by status/outcome Salvages #26745 by @nehaaprasaad. Exposes filtering for the existing workflow_template_id and current_step_key columns: - list_tasks() accepts workflow_template_id and current_step_key kwargs - 'hermes kanban list' adds matching CLI flags - dashboard plugin_api also exposes the filters Resolved a small conflict in list_tasks signature alongside main's session_id and order_by additions; combined all three into the single filter list. * feat(kanban): add respawn guard to block repeat worker storms Salvages #27484 by @fardoche6. Adds a respawn guard that skips worker spawn for tasks where: - a recent run already succeeded (recent_success — within guard window) - the previous run hit a quota/auth error (blocker_auth, also auto-blocks) - a recent task comment includes a GitHub PR URL (active_pr) The guard prevents repeat worker storms on the same bug/task. Includes the contributor's review-findings fixup (regex hardening, observability, auth coverage). Resolved a small DispatchResult conflict alongside main's 'stale' field; kept both. Authorship preserved via rebase merge. * feat(kanban): show dashboard cron jobs across profiles Salvages #27568 by @SerenityTn. Dashboard cron page now lists cron jobs from all profiles, with profile-aware filter UI and storage routing. Includes test coverage for cross-profile listing, mutation, deletion, and validation. Also fixes orphan conflict markers in config.py left by an earlier salvage merge (kanban.dispatch_stale_timeout_seconds was double-nested in HEAD/PR markers from #28452 salvage of #23790). * fix(kanban): remove orphan conflict markers from config.py (#28458) PR #28452 (salvage of #23790, stale detection) merged with leftover git conflict markers in hermes_cli/config.py around the `dispatch_stale_timeout_seconds` config block, breaking config import and any code path that loads it. Cleans up the markers and keeps both config blocks (worker log rotation/orchestrator + stale detection). Resolves a self-introduced regression. * fix(kanban): remove orphan conflict markers from kanban.py (#28459) PR #28454 (salvage of #26745, workflow filter) merged with leftover git conflict markers in hermes_cli/kanban.py at three sites: - _task_to_dict() (session_id alongside workflow_template_id/current_step_key) - p_list parser (--sort alongside --workflow-template-id/--step-key) - _cmd_list (order_by alongside the new filter kwargs) Cleans up the markers and keeps both halves at each site. Resolves a self-introduced regression. * feat(kanban): configure worktree paths and branches Salvages #26496 by @aqilaziz. Adds branch_name column + CLI flag so tasks with workspace_kind='worktree' can pin a target branch on create. Schema migration added to _migrate_add_optional_columns. - Task.branch_name field + DB column + migration - create_task accepts branch_name kwarg - hermes kanban create --branch <name> flag - kanban show output includes 'Branch: <name>' when set Cherry-picked the substantive commit (a7558cf27); the PR's tip was an unrelated service-path-dirs commit. Resolved 2 INSERT-column-list and show-output conflicts alongside main's session_id and max_runtime_seconds additions; kept all three. * feat(skills): add skill bundles — alias /<name> loads multiple skills (#28373) Skill bundles are tiny YAML files in ~/.hermes/skill-bundles/ that group several skills under one slash command. Invoking /<bundle-name> from any surface (CLI, TUI, dashboard, any gateway platform) loads every referenced skill into a single combined user message. Use cases: - /backend-dev → loads github-code-review + test-driven-development + github-pr-workflow as one bundle. - /research → loads several research skills together. - Team task profiles shared via dotfiles. Behavior: - Bundles take precedence over individual skills when slugs collide. - Missing skills are skipped with a note, not fatal. - No system-prompt mutation — bundles generate a fresh user message at invocation time, the same way /<skill> does. Prompt cache stays intact. - Works in CLI dispatch, gateway dispatch, autocomplete (CLI + TUI), /help display. Schema (~/.hermes/skill-bundles/<slug>.yaml): name: backend-dev description: Backend feature work. skills: - github-code-review - test-driven-development instruction: | Optional extra guidance prepended to the loaded skills. New module: agent/skill_bundles.py — load, scan, resolve, build invocation message, save, delete. yaml.safe_load only; broken bundles log a warning and are skipped, never raise. New CLI subcommand: hermes bundles {list,show,create,delete,reload}. Implementation in hermes_cli/bundles.py; wired in hermes_cli/main.py. 'bundles' added to _BUILTIN_SUBCOMMANDS so plugin discovery skips it. New in-session slash command: /bundles lists installed bundles in both CLI and gateway. /<bundle-name> dispatch added to CLI (cli.py) and gateway (gateway/run.py) before the existing /<skill-name> path. Autocomplete: SlashCommandCompleter gained an optional skill_bundles_provider parameter that defaults to None — the prompt shows '▣ <description> (N skills)' for bundles vs '⚡' for skills. Tests: - tests/agent/test_skill_bundles.py — 33 tests covering slugify, scan/cache freshness, resolve (including underscore→hyphen Telegram alias), build_bundle_invocation_message (loading, missing skills, user/bundle instruction injection, dedup), save/delete, reload diff, list sort. - tests/hermes_cli/test_bundles.py — 8 tests for the CLI subcommand (create/list/show/delete/reload, --force, missing bundle errors). - tests/gateway/test_bundles_command.py — 4 tests for the gateway handler and bundle resolution priority. Live E2E: verified subprocess invocations of hermes bundles {list,create,show,reload,delete} round-trip correctly against an isolated HERMES_HOME. Docs: - website/docs/user-guide/features/skills.md — new 'Skill Bundles' section with quick example, YAML schema, management commands, behavior notes. - website/docs/reference/cli-commands.md — 'hermes bundles' added to the top-level command table and given its own subcommand section. * feat(kanban): add scheduled status for delayed follow-ups Salvages #24533 by @roycepersonalassistant. Adds a first-class 'scheduled' Kanban status for time-delay follow-ups that aren't waiting on human input. - hermes kanban schedule <task_id> [reason] CLI command - Dashboard/API transitions to/from Scheduled - unblock_task() now releases both 'blocked' AND 'scheduled' tasks (re-checking parent dependencies before moving to ready/todo) - i18n + docs updates Resolved conflicts: kept HEAD's failure-counter reset on unblock alongside the PR's scheduled state, kept HEAD's 'running' direct-set rejection, combined both bulk-status branches. Dropped the dist/ bundle changes (months-stale; would need rebuild from source). * feat(kanban): drag-to-delete trash zone + bulk delete for task cards Salvages #28125 by @Jpalmer95. Adds: - Drag-to-delete trash zone in the kanban dashboard - Bulk delete endpoint with cascading delete_task cleanup - Frontend updates (drag visual + drop handler) - Confirmation prompt before delete Resolved end-of-file test conflict by appending both halves. * docs: add Korean Kanban documentation Salvages #21823 by @pochi-gio. Adds Korean (ko) Docusaurus locale and translates Kanban documentation (kanban.md, kanban-tutorial.md) and the two related skills (devops-kanban-orchestrator, devops-kanban-worker). Purely additive — adds ko to the locales list in docusaurus.config.ts and creates the website/i18n/ko/ tree. * fix(tests): catch up six stale tests after compression/aux/kanban changes (#28465) - aux_config: drop session_search from _AUX_TASKS and remove stale test (PR #27590 removed auxiliary.session_search from DEFAULT_CONFIG) - compression_boundary_hook: set compressor._last_compress_aborted=False on MagicMock so the post-compress abort branch (PR #28117) doesn't short-circuit before the session-id rotation under test - kanban_dashboard_plugin: use consecutive_failures=3 so severity stays 'error' (failure_threshold default dropped from 3 to 2 in d9fef0c8a, so failures=5 now crosses the critical floor of 2*2=4) - cli_manual_compress: accept force kwarg on DummyAgent._compress_context (cli._manual_compress now passes force=True) * fix(telegram): render full clarify choice text in message body, use short button labels When Telegram clarify prompts offer long choices, mobile clients truncate the inline button labels, making options unreadable. Previously only the question was shown in the message body with truncated choice text in button labels. Fix: append the full numbered option list to the message body so users can read complete choice text on any client. Buttons now use short numeric labels (1, 2, ...) to avoid Telegram truncation. The 'Other (type answer)' button is unchanged. Long choice labels are now rendered in full (not truncated to 57 chars + '...') since they appear in the body instead of button labels. Closes: #27497 * chore(release): map @asdlem for PR #27852 salvage * fix(telegram): default streaming transport to edit * fix(telegram): respect reply_to_mode for DM topic reply fallback The DM topic reply fallback code in send() hardcoded should_thread=True when telegram_dm_topic_reply_fallback metadata was present, bypassing _should_thread_reply() and ignoring reply_to_mode config. This caused quote bubbles on every response even with reply_to_mode: 'off'. Fix: - Add reply_to_mode param to _reply_to_message_id_for_send() and _thread_kwargs_for_send() classmethods - In send(), check self._reply_to_mode != 'off' for DM topic fallback - Suppress reply anchor and reply_to_message_id when mode is 'off' while preserving message_thread_id for correct topic routing - Thread reply_to_mode through all 29 call sites Regression coverage: 10 new tests in test_telegram_reply_mode.py covering classmethod behavior, send() integration, and backward compatibility. Fixes reply_to_mode: 'off' ignored by Telegram DM topic reply fallback code #23994 * fix(gateway): route Telegram audio file attachments away from STT pipeline (#24870) Telegram distinguishes three kinds of audio payloads: - message.voice → Opus/OGG voice messages → STT pipeline ✓ - message.audio → audio file attachments → bypasses STT ← was broken - message.document (audio mime) → generic file route **Root cause** — the inbound message routing block in gateway/run.py matched both MessageType.VOICE *and* MessageType.AUDIO into audio_paths, which were then fed unconditionally to _enrich_message_with_transcription. Audio file attachments (.mp3, .m4a, etc.) were therefore auto-transcribed instead of being treated as files, making the transcribe skill unusable from Telegram because the path it needed was never surfaced. **Fix** - Introduce a new audio_file_paths list populated exclusively by MessageType.AUDIO events. - Narrow the audio_paths selector to MessageType.VOICE (and bare audio/ mime-type events that are not explicitly AUDIO or DOCUMENT). - After the STT block, inject a document-style context note for each audio_file_path, giving the agent the file path and asking what to do with it (consistent with how plain documents are handled). **Tests** — 5 new tests in test_telegram_audio_vs_voice.py: - voice message still transcribed (regression guard) - audio attachment skips STT (core fix) - audio attachment context note format - STT disabled still produces file note (not STT-disabled notice) - MessageType.AUDIO != MessageType.VOICE sanity check Fixes #24870 * chore(release): map bartok9 noreply for PR #24879 salvage * fix(send_message): route standalone Telegram sends through TELEGRAM_PROXY When the send_message tool runs outside the gateway process (agent loop, TUI, cron, etc.), _gateway_runner_ref() returns None and the standalone path in _send_telegram constructs Bot(token=token) directly, bypassing any configured proxy. In regions where api.telegram.org is blocked, the send times out after ~5s with 'Telegram send failed: Timed out' and nothing ever shows up in gateway.log because the request never reaches the gateway. Resolve TELEGRAM_PROXY (via gateway.platforms.base.resolve_proxy_url, which also honours HTTPS_PROXY/HTTP_PROXY/ALL_PROXY and NO_PROXY) just before constructing the Bot. When a proxy is found, attach an HTTPXRequest(proxy=...) for both 'request' and 'get_updates_request', matching what gateway/platforms/telegram.py already does for in-gateway sends and what the Discord standalone sender already does. Any exception attaching the proxy falls back cleanly to a direct connection, preserving prior behaviour for users without a proxy configured. Adds tests/tools/test_send_message_telegram_proxy.py covering both the proxy-configured and no-proxy cases. * chore(release): map @pepelax for PR #25419 salvage * fix(kanban-dashboard): restore implementations dropped during salvages (#28481) Four kanban dashboard test failures, all from PR salvages that picked up the test additions but dropped the corresponding implementations. - BOARD_COLUMNS: add 'review' (status added by PR f55d94a1e but the board API never grew the column → test_board_empty failed because VALID_STATUSES - {archived} mismatched the rendered columns). - update_task: enrich the 'ready' 409 detail with the blocking parent list (id, title, status) and add _parents_blocking_ready helper. Implementation lost in the #26744 salvage (commit e215558ba) which pinned the test but not the server-side code. - dist/index.js: add parseApiErrorMessage helper, wire it through the drag/drop banner, add patchErr state to the TaskDrawer and surface it inline by the action row. Lost in the same #26744 salvage. - test_diagnostics_endpoint_severity_filter: update to at-or-above semantics (PR a94ddd807 changed the filter from exact-match so the warning filter now correctly includes error+critical too). * fix(gateway): roll over Telegram tool progress bubbles * fix(gateway): scope audio_file_paths outside media_urls guard The audio-file-paths handling block at line 7334 references the variable unconditionally, but #24879 initialized it inside the 'if event.media_urls' block — so events without media_urls hit UnboundLocalError. Found via test_run_agent_queued_message_does_not_treat_commentary_as_final after PR #28478 landed. * fix(gateway): keep tool-progress edits alive after Telegram flood control When a progress-message edit hits Telegram flood control (RetryAfter), can_edit was unconditionally set to False, permanently disabling coalescing for the rest of the run. Subsequent tool updates were posted as separate new messages instead of updating the existing progress bubble. Fix: only set can_edit=False for non-recoverable edit errors. On flood control, back off by resetting _last_edit_ts so the throttle interval is respected before the next edit attempt. Fixes #25188 * chore(release): map @erhnysr for PR #25198 salvage * fix(telegram): preserve can_edit after transient network errors in progress edits (#27828) When edit_message_text fails with a transient error (httpx.ConnectError, NetworkError, server disconnected, timeouts), the progress-message sender must not permanently set can_edit = False — that would convert a single Telegram network hiccup into separate per-tool bubbles for the rest of the run. Changes: - gateway/platforms/telegram.py: edit_message now returns retryable=True for transient network errors (ConnectError, NetworkError, timeouts, server disconnects, temporarily unavailable). Permanent failures (flood control, message-not-found, permissions) remain retryable=False. - gateway/run.py: send_progress_messages checks result.retryable before setting can_edit = False. Transient failures skip the fallback-send and continue — the next edit cycle catches up with the accumulated lines. Permanent failures (flood, message-not-found, etc.) still disable editing. Tests: 22 new tests in test_telegram_progress_edit_transient.py covering transient vs permanent error classification, SendResult.retryable semantics, and the can_edit decision logic. Fixes #27828 * fix(telegram): recover from post-update polling conflict without entering limbo * fix(test+release): update conflict retry count for MAX=5; map @CryptoByz * fix(gateway): route background-process notifications into Telegram DM topics Background-process completion notifications (notify_on_complete) and watch-pattern notifications were always delivered to the Telegram main chat instead of the originating private-chat topic. Hermes-created Telegram DM topic lanes only render a send when it carries both message_thread_id and a reply anchor. The synthetic MessageEvent injected on process completion had no message_id, so _reply_anchor_for_event returned None and _thread_kwargs_for_send dropped message_thread_id entirely — routing the notification to the main chat. Capture the triggering message id at spawn time and thread it through to the synthetic event so it can be reply-anchored back into the topic: - session_context: add HERMES_SESSION_MESSAGE_ID context var - telegram adapter: populate SessionSource.message_id on inbound messages - terminal tool: persist watcher_message_id on the process session - process registry: carry/persist message_id on watcher dicts + checkpoint - gateway: set MessageEvent.message_id on injected notifications Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): map @fabiosiqueira for PR #27212 salvage * fix(telegram): route resumed DM topic sends directly * fix(telegram): enforce TELEGRAM_ALLOWED_USERS allowlist on inbound messages TELEGRAM_ALLOWED_USERS was only checked for callback/inline-button actions but not for inbound messages. Unauthorized users triggered an 'Unauthorized user' log warning but their messages were still processed by the agent — a P0 security bypass (issue #23778). Fix: add allowlist check in _should_process_message() which is called for all message types (text, command, media, location). If the sender is not in TELEGRAM_ALLOWED_USERS, the message is dropped immediately with a warning log. Empty TELEGRAM_ALLOWED_USERS continues to allow all users (existing behavior). Fixes #23778 * fix(telegram): fail-closed auth fallback when TELEGRAM_ALLOWED_USERS is empty The _is_callback_user_authorized fallback returned True when TELEGRAM_ALLOWED_USERS was not set, allowing any Telegram user to interact with the bot. Change to fail-closed: deny by default unless GATEWAY_ALLOW_ALL_USERS=true is explicitly set. Fixes #24457 * test(telegram): stub _is_callback_user_authorized in trigger-gating fixture After PR #24468 made the empty-allowlist callback auth fail-closed (and #23795 wired _is_callback_user_authorized into _should_process_message), trigger-gating tests started failing because their fake messages from user 111 hit the new deny-by-default path before trigger evaluation. Force-authorize all senders in _make_adapter() so the trigger logic under test runs. The fail-closed behavior itself is covered by test_telegram_callback_auth_fail_closed.py. * fix(telegram): reset sticky fallback IP on connect failure, retry primary DNS When a sticky fallback IP (from DoH discovery) becomes unreachable, the transport previously got stuck in an attempt_order that only tried the dead IP. This prevented the gateway from recovering until the service was restarted. Changes: - Always include primary DNS path (None) after the sticky IP in the attempt_order so that a primary-path retry happens on sticky failure. - Reset self._sticky_ip to None when the currently sticky IP hits a connect timeout / connect error, allowing the next request to retry from scratch. Fixes silent Telegram disconnection when discovered fallback IPs are transiently or permanently unreachable. * test+release: align stale sticky-IP test for #24511; map @falconexe * fix(telegram): propagate extra base_url config * feat(send_message): auto-detect @username mentions and create Telegram entities When sending messages containing @username patterns, auto-generate MessageEntity(type='mention') entries so that the receiving bot's require_mention filter can trigger. This enables proper bot-to-bot interop where mention-based routing is used. * test+release: align send_message mocks for MessageEntity import; map @fonhal * fix(telegram): resume typing indicator after inline approval click (#27853) The text /approve and /deny paths in gateway/run.py call resume_typing_for_chat() after resolve_gateway_approval() succeeds, but the Telegram inline-button (ea:*) callback in _handle_callback_query did not. Typing is paused when the approval is sent (gateway/run.py:15658), so without a matching resume the typing indicator stayed gone for the remainder of a long-running turn after a button click. Symmetry-match the text path: after a successful resolve, call self.resume_typing_for_chat(str(query_chat_id)). Guarded by count > 0 to match /approve's "if not count" early-return — if nothing was actually resolved, the agent thread was never unblocked, so typing should remain paused. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(gateway): mark final voice reply as notify-worthy so Telegram delivers it audibly In Telegram "important" notifications mode (default), TelegramPlatformAdapter sets ``disable_notification=True`` on every send unless metadata carries ``notify=True``. GatewayRunner._send_voice_reply already passes thread metadata through to ``adapter.send_voice``, but never marks the final auto-TTS voice reply as notify-worthy — so users with the default mode get the final voice note delivered silently with no push notification. Mirror the final-text path in gateway/platforms/base.py (the existing text-response final send already adds ``metadata["notify"] = True``). Issue #27970 Bug 2. Bug 1 (MP3 vs. native OGG voice-note) is being addressed by existing PRs #20182 / #20878 — this PR is intentionally scoped to the silent-delivery bug only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: avoid Telegram group reply thread session splits * chore(release): map @eliteworkstation94-ai for PR #28157 salvage * fix(gateway): avoid duplicate Telegram text after auto-TTS voice replies * chore(release): map @Zyrixtrex for PR #26754 salvage * fix(telegram): escape send_slash_confirm preview with format_message send_slash_confirm() sent the raw command preview with ParseMode.MARKDOWN, skipping the format_message() conversion applied to every other dynamic send in the adapter. Commands with underscores, dots, brackets, or other MarkdownV2-sensitive characters raised BadRequest: Can't parse entities; the exception was swallowed by the outer try/except, so the confirmation prompt silently never appeared. Fix: wrap preview through format_message() and switch to MARKDOWN_V2, symmetric with send_update_prompt and the callback sends fixed in a69404052. * chore(release): map @nftpoetrist for PR #25856 salvage * fix(telegram): retry wrapped connect timeouts * chore(release): map @samahn0601 for PR #27887 salvage * fix(tts): keep native audio outside Telegram voice delivery * chore(release): map @aqilaziz for PR #26406 salvage * fix(gateway): pin Telegram DM-topic routing to user's current topic Topic-mode DM replies were fragmenting one conversation across many sessions: a Reply on a message in another topic delivered Telegram's message_thread_id for *that* topic, and #3206's strip routed plain replies to the lobby. Both pulled the user away from their current session. Fix: when topic mode is on, rewrite source.thread_id to the user's most-recent binding if the inbound id is missing/General or not a known topic. Non-topic-mode users unchanged. * chore(release): map @karthikeyann for PR #26609 salvage * fix(send_message): add thread-not-found retry for Telegram forum topic sends The standalone _send_telegram path in send_message_tool lacked the thread-not-found fallback that the gateway adapter has. When a forum topic thread_id was stale or deleted, the send would fail entirely instead of retrying to the General topic. Changes: - Add _is_telegram_thread_not_found() helper matching gateway adapter - Add thread-not-found retry in text send path - Add thread-not-found retry in media send path (with f.seek(0)) - Separate text_kwargs from thread_kwargs to prevent disable_web_page_preview leaking into send_photo/send_video calls Closes #27012 * test(send_message): add thread-not-found retry tests for Telegram forum topics Adds two tests to TestSendTelegramThreadIdMapping: - test_thread_not_found_retries_without_message_thread_id - test_thread_not_found_for_media_retries_without_message_thread_id Refs #27012 * test(send_message): add thread-not-found retry tests for Telegram topics Three tests covering the #27012 fix: - test_is_thread_not_found_matches_expected_errors - test_text_send_retries_without_thread_id_on_thread_not_found - test_disable_web_page_preview_not_leaked_to_media_sends 116/116 existing tests still pass (no regressions). * chore(release): map @kunci115 for PR #27098 salvage * fix(gateway): register Telegram commands for groups Register Telegram bot commands across default, private, and group scopes so the slash-command menu is available outside DMs. Changes from review feedback: - Add asyncio.Lock to prevent race condition in _ensure_forum_commands - Extract MAX_COMMANDS_PER_SCOPE constant (30) to avoid magic number - Upgrade error logging from debug->warning in forum registration - Add tests covering lazy forum registration and concurrent safety - Remove /start handler from this PR (separate feature) Fixes review: needs_work (race, magic number, log levels, missing tests) * test+release: fix test fixture for forum_commands; map @chromalinx * fix(telegram): gate profile bots by allowed topics * chore(release): map @booker1207 for PR #25132 salvage * fix(cron): route Telegram cron deliveries to a dedicated topic via TELEGRAM_CRON_THREAD_ID When Telegram topic mode is enabled, cron messages delivered to the bot's root DM (TELEGRAM_HOME_CHANNEL without a thread id) land in the system lobby — replies there are rebuffed with the lobby reminder and reply_to_message_id is dropped, so users cannot interact with the cron output (#24409). Add an optional TELEGRAM_CRON_THREAD_ID env var that overrides TELEGRAM_HOME_CHANNEL_THREAD_ID for cron deliveries only. Operators can create a "Cron" forum topic in the DM, point this var at its thread id, and replies to cron messages will land in that topic's existing session instead of the lobby. The home-channel thread id (used elsewhere, e.g. restart notifications) is unchanged, and explicit deliver="telegram:chat:thread" targets continue to win over the env var. Per the reporter's clarification on 2026-05-13, option (a) (cron-side route to a dedicated topic + config knob) was chosen. Fixes #24409 * fix(telegram): route image documents (.png/.jpg/.webp/.gif) through vision pipeline When users send images as documents (Telegram file picker), they were rejected with "Unsupported document type" because SUPPORTED_DOCUMENT_TYPES only includes text/office formats. Add SUPPORTED_IMAGE_DOCUMENT_TYPES to base.py and handle them in telegram.py before the document check. - Add SUPPORTED_IMAGE_DOCUMENT_TYPES constant to base.py - Add MIME reverse-lookup for image types in telegram.py - Route image documents through cache_image_from_bytes + vision pipeline - Handle media groups for image documents Closes: #20128, #18620 * test+release: stub auth in test_telegram_documents fixture; map @kiranvk-2011 * fix(gateway): prevent Windows Telegram /restart leaving gateway stopped * chore(release): map @rak135 for PR #25960 salvage * fix(telegram): preserve topic metadata on overflow edits * feat(telegram): add disable_topic_auto_rename gateway flag When Hermes auto-titles a session in a Telegram DM topic it currently renames the topic itself to the generated title. That works for operator-managed lanes (extra.dm_topics) but is disruptive for ad-hoc Threaded-Mode topics that users name by hand — every first exchange overwrites their chosen title. Add gateway.platforms.telegram.extra.disable_topic_auto_rename (default False, preserving prior behaviour). When set, both _schedule_telegram_topic_title_rename and the underlying _rename_telegram_topic_for_session_title short-circuit before touching the Telegram API. Internal session titles (sessions list, TUI) keep working unchanged. Also bridge the legacy top-level telegram.disable_topic_auto_rename key through to gateway.platforms.telegram.extra so users on the older config layout don't have to migrate to enable it. - Tests cover the runtime flag, the scheduling entry-point, and string truthiness coercion for YAML-loaded values. - Docs updated in messaging/telegram.md with an example block. * chore(release): map @B0Tch1 for PR #27634 salvage * fix(gateway): restore Telegram DM topic thread_id after session split (#27166) When context compression triggers a mid-turn session split, source.thread_id can be None on synthetic/recovered events. _thread_metadata_for_source then returns None, causing the Telegram adapter to send with no message_thread_id and the response lands in the General thread instead of the active DM topic. Fix: - hermes_state.py: Add get_telegram_topic_binding_by_session() for reverse lookup by session_id (enabled by the existing UNIQUE INDEX on session_id). - gateway/run.py: After session-split detection, if source is a Telegram DM and source.thread_id is None, recover it from the binding via the new method so _thread_metadata_for_source produces the correct thread routing. - tests/: Coverage for the new lookup method and the recovery flow. * chore(release): map @jackjin1997 for PR #27239 salvage * fix(gateway): allow chat-scoped telegram auth without sender user_id * chore(release): map @soynchux for PR #27806 salvage * fix(telegram): add DM topic typing fallback when message_thread_id rejected When a DM topic lane's message_thread_id is rejected by Telegram (e.g. stale or deleted topic), send_typing now falls back to sending the typing indicator without thread_id so it at least appears in the main DM view, rather than being silently swallowed. Also adds test for the fallback behavior. * fix(telegram): report cron topic fallback * chore(release): map @el-analista for PR #25368 salvage * fix(telegram): wire gt: callback dispatch for gmail-triage buttons The gmail-triage skill's Telegram inline buttons emit callback_data of the form `gt:<verb>:<arg>`, but `_handle_callback_query` had no `gt:` branch — taps fell through silently and the spinner sat there until Telegram timed it out. Add `_handle_gmail_triage_callback`, dispatched from the existing callback router, that: - Authorizes the caller via the same `_is_callback_user_authorized` path as the approval / slash-confirm / clarify handlers. - Maps each verb to a script under `~/.hermes/scripts/gmail-triage/` and runs it async with a 60s timeout. - Splits verbs into one-shots (send / archive / draft / spam) — append the confirmation and strip the keyboard so the action can't fire twice — and sticky-state changes (mute / trust / vip ± -domain) — append the confirmation but leave the keyboard tappable so the user can stack actions on one email. - On failure: toast only, keyboard preserved so the user can retry. - Logs every callback outcome to gateway.log for debugging. * chore(release): map @khungate for PR #25829 salvage * feat(telegram): support quick-command-only menus * chore(release): map @stevehq26-bot for PR #28015 salvage * fix(telegram): handle channel post updates * test: address telegram channel post review * test+release: stub auth in channel_posts fixture; map @brndnsvr * Quiet noisy Telegram gateway errors * chore(release): map oracle@jarviss-mbp.home for PR #24014 salvage * Route Telegram multi-bot mentions exclusively * Document Telegram multi-profile gateway commands * fix: ignore Telegram messages for other bots * chore(release): map @OCWC22 for PR #24581 salvage * feat(telegram): ignore_root_dm with system command lobby * docs(telegram): document ignore_root_dm feature * chore(release): map @ai-hana-ai for PR #23928 salvage * feat(telegram): pin incoming user message for duration of agent turn When a user sends a message on Telegram, the incoming message is now automatically pinned at the start of processing and unpinned when the agent finishes its turn. This gives the user a visual indicator that their message is being worked on, and keeps the conversation anchored. Changes: - telegram.py: Added pinChatMessage in on_processing_start and unpinChatMessage in on_processing_complete. Restructured both hooks so pin/unpin runs independently of the reactions feature (reactions are optional; pinning is always on). - telegram.py: Pass message_id through SessionSource so it's available in the session context. - session_context.py: Added HERMES_SESSION_MESSAGE_ID context var. - run.py: Pass source.message_id through set_session_vars. Pinning is silent (disable_notification=True) and failures are logged at debug level without interrupting message processing. Only the user's incoming message is pinned -- never the agent's replies. Auto-resume events (which have no message_id) are correctly skipped. * chore(release): map @indigokarasu for PR #26636 salvage * feat(telegram): skip-STT audio path + 2GB cap via local Bot API server Two coordinated changes that unblock downstream audio pipelines (diarization, custom transcription, archival) on attachments larger than the public Bot API's 20MB getFile ceiling. - `stt.enabled: false` no longer drops voice/audio with a generic "transcription disabled" note. The gateway probes the cached file's duration (wave → mutagen → ffprobe ladder) and surfaces `[The user sent a voice message: <abs path> (duration: M:SS)]` to the agent so a skill or tool can pick up the raw file. The previous placeholder is replaced rather than appended when present. - `platforms.telegram.extra.base_url` set → adapter auto-lifts its document size cap from 20MB to 2GB (the local telegram-bot-api `--local` ceiling) and the "too large" reply reports the active limit dynamically. No new config knob; presence of `base_url` is the opt-in. - `platforms.telegram.extra.local_mode: true` wires `Application.builder().local_mode(True)` on the python-telegram-bot builder. PTB then reads files from disk instead of HTTP, which is required when telegram-bot-api runs in `--local` mode (the server returns absolute filesystem paths, not `/file/bot...` URLs). - gateway/run.py: rewrites the `stt.enabled: false` branch of `_enrich_message_with_transcription`. New `_format_duration` + `_probe_audio_duration` helpers. - gateway/platforms/telegram.py: `_max_doc_bytes` instance attribute derived from `extra.base_url`; `local_mode` builder wiring; dynamic "too large" message. - tests/gateway/test_stt_config.py: covers path-surfacing with and without an existing user message, and placeholder replacement. - tests/gateway/test_telegram_max_doc_bytes.py: 3 cases — default 20MB without base_url, 2GB when set, empty-string base_url keeps default. - website/docs/user-guide/messaging/telegram.md: new "Skipping STT" subsection under Voice Messages and a full "Large Files (>20MB) via Local Bot API Server" walkthrough (api_id/api_hash, docker-compose, one-time `logOut` migration, `platforms.telegram.extra` config, the `local_mode` disk-access requirement, the silent HTTP-fallback 404). - website/docs/user-guide/features/voice-mode.md: documents the `stt.enabled` knob in the config reference. - `pytest tests/gateway/test_telegram_max_doc_bytes.py tests/gateway/test_stt_config.py` → 9/9 passing. - Verified end-to-end on a live deployment: gateway log shows `Using custom Telegram base_url: http://...` and `Using Telegram local_mode (read files from disk)` on startup; voice messages above 20MB cache to disk and surface their path to the agent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(release): map @alber70g for PR #25280 salvage * fix(web): add scheduled column to i18n type definitions (#28549) columnLabels and columnHelp in en.ts include a scheduled entry but the Translations interface in types.ts did not declare it, causing a TypeScript build failure in the Nix derivation. Made the field optional since only en.ts provides it currently. * docs: comprehensive 2-week sweep of feature/PR coverage gaps (#28497) Catch the website docs up to two weeks of merged work (May 4 – May 18, 2026, roughly 1,080 PRs). The audit found ~50 user-visible features that had landed in code with no docs footprint, plus a handful of stale pages. This PR closes every gap the scan turned up. New pages - user-guide/features/deliverable-mode.md — extension list, agent triggers, kanban_complete artifacts pattern, [[as_document]] override (PR #27813). - developer-guide/web-search-provider-plugin.md — authoring guide modeled on image-gen-provider-plugin, covering brave_free / ddgs / etc. (PR #25448). Providers / auth - Rename "Alibaba Cloud" → "Qwen Cloud (Alibaba DashScope)" everywhere the display label shows up; provider id stays `alibaba` (PR #24835). - Document OAuth refresh-token quarantine for xAI / MiniMax / Codex (PRs #28116 / #28118 / #28119). - Document Nous JWT minting from refresh token + invalid-refresh quarantine + cross-profile shared token store (PRs #27663 / #19712). - Add `## Microsoft Entra ID authentication (keyless)` section to azure-foundry guide — DefaultAzureCredential, RBAC, OpenAI + Anthropic routing details (PR #28101 / #9df9816da). - Custom providers `api_mode` is now prompted-and-persisted, not just URL autodetected (PR #25068). - Delegation honours `api_mode` + auto-detects anthropic_messages base URLs (PR #26824). - `x_search` auto-enables when xAI credentials are present (PR #27376). - Add `xAI Grok OAuth (SuperGrok)` row to providers headline table (PR #26534). - NVIDIA NIM billing-origin header is set automatically (PR #26585). Windows / installer - `install.ps1`: document `-Commit <sha>` and `-Tag <v>` pin params plus the BOM-strip / git-retry hardening (PR #28169). - Document Hermes Desktop thin installer + first-launch bootstrap (PR #27822). - Document `dep_ensure` Windows bootstrap (PR #27845). - Document install-method auto-detection (pip / git / homebrew / nixos) and the matching update command (PR #27843). Gateway / messaging - `/platform list|pause|resume` full description + circuit-breaker semantics (PR #26600). - Slack / Matrix / Mattermost get parallel `allowed_channels` / `allowed_rooms` allowlist sections matching Telegram/Discord/DingTalk (PR #21251). - Discord `allow_any_attachment` + `max_attachment_bytes` (config and env vars) (PR #27245). - Discord clarify-choice button rendering (PR #25485). - Telegram `guest_mode` @mention bypass for allowlisted groups (PR #22759). - Telegram `notifications` mode (`important` vs `all`) (PR #22793). - `[[as_document]]` skill / response directive for forcing document-style media delivery (PR #21210). CLI / TUI - `/new [name]` argument (PR #19637). - `/subgoal` user-supplied criteria appended to `/goal` (PR #25449). - `/exit --delete` flag confirmation prompts for destructive slash commands (PR #22687). - Status-bar additions: ▶ N background indicator (PR #27175), context compression count (PR #21218), YOLO mode banner+statusbar warning (PR #26238). - `display.timestamps` + `docker_extra_args` config keys (PR #23599). - TUI collapsible startup banner sections (PR #20625). - `HERMES_SESSION_ID` exported to tool subprocesses (PR #23847). i18n - Refresh display.language locale list from 8 → 16 (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu) — matches `agent/i18n.py:SUPPORTED_LANGUAGES`. Tools / features - `vision_analyze` native-pixel passthrough for vision-capable callers, with auxiliary text-describer fallback (PR #22955). - `session_search` rewrite to the single-shape tool (discovery / scroll / browse modes) (PRs #27590 / #27840). - Clarify MCP transport scope: client supports stdio + SSE; embedded `hermes mcp serve` is stdio-only (PR #21227). - Web search backends table: add Brave Search (free tier) and DDGS rows (PR #21337). - ACP session-scoped edit auto-approval modes (PR #27862). - Curator rename map in the user-visible per-run summary (PR #22910). - Prompt caching feature page reference in features/overview.md — Claude cross-session 1-hour prefix cache on native Anthropic / OpenRouter / Nous Portal (PR #23828). - Cron per-job profile parameter (PR #28124). - `--no-skills` flag for `hermes profile create` (PR #20986). Build - Verified with `npm run build` in `website/`; both `en` and `zh-Hans` locales compile. Remaining broken-link/anchor warnings are pre-existing (`rl-training.md` from learning-path / overview; the zh-Hans translation lag the docs skill already calls out). * chore(release): pre-stage AUTHOR_MAP for May 2026 LHF batch group 9 (#28571) Pre-stages AUTHOR_MAP entries for 9 new/under-mapped contributors whose PRs are being salvaged in the May 2026 LHF batch group 9. Contributors: - jdelmerico (#28278 — signal require_mention filter) - justemu (#27996 — matrix thread_require_mention) - YuanHanzhong (#28029 — dashboard browser scrollback) - noctilust (#28080 — drop stale TUI resume env) - MoonJuhan (#28288 — tolerate unreadable JSONL transcripts) - outsourc-e (#28164 — cron emoji ZWJ sequences) - Zyrixtrex (#28275 — Google OAuth urlopen timeout) - ooovenenoso (#28256 — tool loop recovery hints) - vanthinh6886 (#28018 — yaml/flock/atomic write guards; non-noreply email) Per references/batch-pr-salvage-may14-additions.md. * feat(signal): add require_mention filter for group chats Add a configurable mention filter to the Signal adapter so the bot only responds in groups when it is explicitly @mentioned. Changes: - gateway/platforms/signal.py: read require_mention from adapter extra config or SIGNAL_REQUIRE_MENTION env var; skip group messages that don't mention the bot account (checked in rendered text and raw mention metadata) - gateway/config.py: map signal.require_mention YAML key to the SIGNAL_REQUIRE_MENTION env var (env var takes precedence) Config example: signal: require_mention: true Or via env var: SIGNAL_REQUIRE_MENTION=true * Revert "feat(telegram): pin incoming user message for duration of agent turn" This reverts commit a724c3b9cf5f01e28365322ae5ae3a9579567806. * Revert "feat(telegram): support quick-command-only menus" This reverts commit b1acf80e17858e2e5ae7c0d412a3a573d7fcbca4. * Revert "feat(send_message): auto-detect @username mentions and create Telegram entities" This reverts commit cf814c96f613b38bd891ac941c32da653e81c7ad. * Revert "fix(telegram): enforce TELEGRAM_ALLOWED_USERS allowlist on inbound messages" This reverts commit db50af910be6b4171ea9cf54f4cc38be27ac1da6. * fix(gateway): pre-mark sessions as resume_pending before drain to prevent data loss (#27856) Pre-mark all running agent sessions as resume_pending BEFORE the drain wait begins. If the service manager kills the process during the drain (window), the durable marker is already written so the next gateway boot can recover in-flight sessions. On graceful drain completion, clear the early markers for sessions that finished successfully. * fix(matrix): implement thread_require_mention to prevent multi-agent reply loops In multi-agent shared Matrix rooms, multiple bots all participating in the same thread could trigger infinite reply loops — each bot's reply re-engaged the others because they were all in the bot-thread set. Discord has a `thread_require_mention` opt-in for this; Matrix didn't. Add `_parse_thread_require_mention(config)` (mirrors Discord's pattern). In `_resolve_message_context`, when enabled and the message is in a bot-participated thread (not a free-response room), require @mention before processing. Salvage of @justemu's 2-commit stack (#27996). Fixes #27995. * fix(cli): show active profile in TUI prompt * fix(tui): preserve dunder identifiers in markdown * test(file_ops): add regression tests for git baseline warning in write_file Adds TestGitBaselineCheck with 6 unit tests covering _check_git_baseline and the warning field in write_file result: - Git not available → None - Not in a git repo → None - Clean repo → None - Dirty repo → returns warning string with branch name - write_file result includes warning when dirty - write_file result omits warning when clean * fix(dashboard): use browser scrollback for chat wheel * fix(cli): ignore stale HERMES_TUI_RESUME env HERMES_TUI_RESUME is an internal env var the Python wrapper exports to hand a session ID off to the Ink TUI. Because _launch_tui started from os.environ.copy(), any exported/stale value in the user's shell leaked through — so plain `hermes --tui` would try to resume a missing session and leave the UI at 'error: session not found' with no live session. Drop HERMES_TUI_RESUME from the env before conditionally re-setting it from the argparse-resolved resume_session_id. Tests cover both the drop path and the set-from-arg path. Salvage of #28080 by @noctilust. * fix(cron): allow emoji ZWJ sequences in prompts * fix: tolerate unreadable gateway JSONL transcripts * fix(skills): add timeout to Google OAuth urlopen calls * fix: add recovery hints to loop guard warnings * fix: guard yaml.safe_load, flock unlock, TOCTOU races, and atomic writes 1. trajectory_compressor.py: yaml.safe_load() returns None on empty files, crashing with TypeError on `if 'tokenizer' in data`. Fix by adding `or {}` fallback. (HIGH — blocks startup with empty config) 2. 6 files with fcntl.flock(LOCK_UN) in finally blocks without try/except: cron/scheduler.py, hermes_cli/auth.py, agent/shell_hooks.py, tools/skill_usage.py, tools/environments/file_sync.py, tools/memory_tool.py. If unlock raises OSError, fd.close() is skipped and the lock is held forever. The msvcrt branches already had try/except; the fcntl branches did not. Fix by wrapping in try/except (OSError, IOError): pass. 3. agent/copilot_acp_client.py line 639: TOCTOU race — path.exists() followed by path.read_text() with no try/except. If file is deleted between the check and the read, FileNotFoundError propagates. Fix by using try/except FileNotFoundError. 4. gateway/sticker_cache.py: non-atomic write via Path.write_text() can leave truncated JSON on crash, causing JSONDecodeError on next load. Fix by writing to tempfile + fsync + os.replace (atomic). * chore(release): alias xxxigm noreply for upcoming #27986 salvage (#28594) Adds the canonical noreply form (54813621+xxxigm@users.noreply.github.com) alongside the existing plain-email mapping so the salvage commit for @xxxigm's codex doctor PR doesn't fail AUTHOR_MAP CI. * fix(doctor): attach codex CLI hint to OpenAI Codex auth warning for #27975 `hermes doctor` printed 'codex CLI not installed (optional — ...)' as a generic info line at the bottom of the auth section, several rows below 'OpenAI Codex auth (not logged in)' and after MiniMax/Gemini auth checks. Users reading sequentially mistook it for MiniMax-related advice. Move the hint up under the Codex auth warning so it's adjacent to the row it actually pertains to. Behavior unchanged when the codex CLI is installed (success path keeps its 'codex CLI ✓' row at the bottom). Tests cover both placement and suppression cases. Salvage of @xxxigm's 3-commit stack (#27986). Closes #27975. * fix(tests): catch up 25 stale tests after recent merges (#28626) Sweep of all CI failures on origin/main, grouped by drift source: Telegram allowlist gate (db50af910 added user-authz to _should_process_message): - Hardcoded "[Telegram]" prefix in the logger.warning so the call no longer dereferences self.name → self.platform, which test fixtures built via object.__new__ never set. - test_telegram_format / test_allowed_channels_widening fixtures stub _is_callback_user_authorized → True so the new gate doesn't reject guest-mode / allowed-channels test messages. - test_telegram_approval_buttons::test_update_prompt_callback_not_affected sets TELEGRAM_ALLOWED_USERS="*" so the fail-closed default doesn't reject the callback before it writes .update_response. Approval surface (6d495d9e7 renamed status, 214b95392 detached stdin): - test_no_callback_returns_approval_required: status is now "pending_approval" (was "approval_required"). - test_close_stdin_allows_eof_driven_process_to_finish: switch to use_pty=True; non-PTY now uses stdin=DEVNULL. Mattermost (send() now resolves root_id via _api_get first): - test_send_with_thread_reply mocks _session.get with a thread-root response so the new resolver doesn't TypeError on a bare AsyncMock. Kanban (d8ad431de rename, f55d94a1e review column, _kanban_worker_skill_available): - _safe_int → _to_epoch in the two test_kanban_db tests. - Spawn-skills tests (×3) monkey-patch _kanban_worker_skill_available to True since the isolated kanban_home fixture has no devops/kanban-worker tree. - test_gateway_dispatcher_disables_corrupt_board: connect count 3 → 5 (review-column probe now also runs per tick). Aux-config severity at_or_above (a94ddd807): - test_diagnostics_endpoint_severity_filter expects warning filter to include error+critical now (was exact-match). Anthropic error handling (conversation loop extracted from run_agent): - _no_backoff_wait fixture patches BOTH run_agent.jittered_backoff AND agent.conversation_loop.jittered_backoff. The latter is the actual call site; without the second patch tests burn ~2s per retry and hit the 30s SIGALRM timeout on CI. Other test pollution / drift: - test_auto_does_not_select_copilot_from_github_token: patch agent.bedrock_adapter.has_aws_credentials → False so boto3's credential chain can't auto-pick Bedrock from developer ~/.aws. - test_setup_openclaw_migration: patch hermes_cli.gateway.get_env_value in addition to setup_mod.get_env_value — _platform_status reads through the gateway module's binding. - test_gateway_prefix: COMPONENT_PREFIXES["gateway"] now includes "hermes_plugins" too. - test_recommended_update_command_defaults_to_hermes_update: also short-circuit get_managed_update_command in case a stray ~/.hermes/.managed marker is present. - test_user_id_is_not_explicit: _parse_target_ref now returns is_explicit=False for Slack U.../W... IDs (chat.postMessage rejects them — a DM must be opened first via conversations.open). * feat(update): syntax-validate critical files post-pull, auto-rollback on failure (#28669) Catch the PR #28452 failure mode (orphan merge-conflict markers in hermes_cli/config.py) on the user side: after git pull succeeds, compile the files every 'hermes' invocation imports at startup. If any has a syntax error, git reset --hard back to the pre-pull SHA so the install stays bootable. User can retry once a fix lands upstream. - New _capture…
Lillard01
pushed a commit
to Lillard01/hermes-agent
that referenced
this pull request
May 21, 2026
…earch#28497) Catch the website docs up to two weeks of merged work (May 4 – May 18, 2026, roughly 1,080 PRs). The audit found ~50 user-visible features that had landed in code with no docs footprint, plus a handful of stale pages. This PR closes every gap the scan turned up. New pages - user-guide/features/deliverable-mode.md — extension list, agent triggers, kanban_complete artifacts pattern, [[as_document]] override (PR NousResearch#27813). - developer-guide/web-search-provider-plugin.md — authoring guide modeled on image-gen-provider-plugin, covering brave_free / ddgs / etc. (PR NousResearch#25448). Providers / auth - Rename "Alibaba Cloud" → "Qwen Cloud (Alibaba DashScope)" everywhere the display label shows up; provider id stays `alibaba` (PR NousResearch#24835). - Document OAuth refresh-token quarantine for xAI / MiniMax / Codex (PRs NousResearch#28116 / NousResearch#28118 / NousResearch#28119). - Document Nous JWT minting from refresh token + invalid-refresh quarantine + cross-profile shared token store (PRs NousResearch#27663 / NousResearch#19712). - Add `## Microsoft Entra ID authentication (keyless)` section to azure-foundry guide — DefaultAzureCredential, RBAC, OpenAI + Anthropic routing details (PR NousResearch#28101 / #9df9816da). - Custom providers `api_mode` is now prompted-and-persisted, not just URL autodetected (PR NousResearch#25068). - Delegation honours `api_mode` + auto-detects anthropic_messages base URLs (PR NousResearch#26824). - `x_search` auto-enables when xAI credentials are present (PR NousResearch#27376). - Add `xAI Grok OAuth (SuperGrok)` row to providers headline table (PR NousResearch#26534). - NVIDIA NIM billing-origin header is set automatically (PR NousResearch#26585). Windows / installer - `install.ps1`: document `-Commit <sha>` and `-Tag <v>` pin params plus the BOM-strip / git-retry hardening (PR NousResearch#28169). - Document Hermes Desktop thin installer + first-launch bootstrap (PR NousResearch#27822). - Document `dep_ensure` Windows bootstrap (PR NousResearch#27845). - Document install-method auto-detection (pip / git / homebrew / nixos) and the matching update command (PR NousResearch#27843). Gateway / messaging - `/platform list|pause|resume` full description + circuit-breaker semantics (PR NousResearch#26600). - Slack / Matrix / Mattermost get parallel `allowed_channels` / `allowed_rooms` allowlist sections matching Telegram/Discord/DingTalk (PR NousResearch#21251). - Discord `allow_any_attachment` + `max_attachment_bytes` (config and env vars) (PR NousResearch#27245). - Discord clarify-choice button rendering (PR NousResearch#25485). - Telegram `guest_mode` @mention bypass for allowlisted groups (PR NousResearch#22759). - Telegram `notifications` mode (`important` vs `all`) (PR NousResearch#22793). - `[[as_document]]` skill / response directive for forcing document-style media delivery (PR NousResearch#21210). CLI / TUI - `/new [name]` argument (PR NousResearch#19637). - `/subgoal` user-supplied criteria appended to `/goal` (PR NousResearch#25449). - `/exit --delete` flag confirmation prompts for destructive slash commands (PR NousResearch#22687). - Status-bar additions: ▶ N background indicator (PR NousResearch#27175), context compression count (PR NousResearch#21218), YOLO mode banner+statusbar warning (PR NousResearch#26238). - `display.timestamps` + `docker_extra_args` config keys (PR NousResearch#23599). - TUI collapsible startup banner sections (PR NousResearch#20625). - `HERMES_SESSION_ID` exported to tool subprocesses (PR NousResearch#23847). i18n - Refresh display.language locale list from 8 → 16 (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu) — matches `agent/i18n.py:SUPPORTED_LANGUAGES`. Tools / features - `vision_analyze` native-pixel passthrough for vision-capable callers, with auxiliary text-describer fallback (PR NousResearch#22955). - `session_search` rewrite to the single-shape tool (discovery / scroll / browse modes) (PRs NousResearch#27590 / NousResearch#27840). - Clarify MCP transport scope: client supports stdio + SSE; embedded `hermes mcp serve` is stdio-only (PR NousResearch#21227). - Web search backends table: add Brave Search (free tier) and DDGS rows (PR NousResearch#21337). - ACP session-scoped edit auto-approval modes (PR NousResearch#27862). - Curator rename map in the user-visible per-run summary (PR NousResearch#22910). - Prompt caching feature page reference in features/overview.md — Claude cross-session 1-hour prefix cache on native Anthropic / OpenRouter / Nous Portal (PR NousResearch#23828). - Cron per-job profile parameter (PR NousResearch#28124). - `--no-skills` flag for `hermes profile create` (PR NousResearch#20986). Build - Verified with `npm run build` in `website/`; both `en` and `zh-Hans` locales compile. Remaining broken-link/anchor warnings are pre-existing (`rl-training.md` from learning-path / overview; the zh-Hans translation lag the docs skill already calls out).
bot-ted
added a commit
to bot-ted/hermes-agent
that referenced
this pull request
May 21, 2026
* feat(kanban): configure worktree paths and branches
Salvages #26496 by @aqilaziz. Adds branch_name column + CLI flag so
tasks with workspace_kind='worktree' can pin a target branch on
create. Schema migration added to _migrate_add_optional_columns.
- Task.branch_name field + DB column + migration
- create_task accepts branch_name kwarg
- hermes kanban create --branch <name> flag
- kanban show output includes 'Branch: <name>' when set
Cherry-picked the substantive commit (a7558cf27); the PR's tip was
an unrelated service-path-dirs commit. Resolved 2 INSERT-column-list
and show-output conflicts alongside main's session_id and
max_runtime_seconds additions; kept all three.
* feat(skills): add skill bundles — alias /<name> loads multiple skills (#28373)
Skill bundles are tiny YAML files in ~/.hermes/skill-bundles/ that
group several skills under one slash command. Invoking /<bundle-name>
from any surface (CLI, TUI, dashboard, any gateway platform) loads
every referenced skill into a single combined user message.
Use cases:
- /backend-dev → loads github-code-review + test-driven-development
+ github-pr-workflow as one bundle.
- /research → loads several research skills together.
- Team task profiles shared via dotfiles.
Behavior:
- Bundles take precedence over individual skills when slugs collide.
- Missing skills are skipped with a note, not fatal.
- No system-prompt mutation — bundles generate a fresh user message
at invocation time, the same way /<skill> does. Prompt cache stays
intact.
- Works in CLI dispatch, gateway dispatch, autocomplete (CLI + TUI),
/help display.
Schema (~/.hermes/skill-bundles/<slug>.yaml):
name: backend-dev
description: Backend feature work.
skills:
- github-code-review
- test-driven-development
instruction: |
Optional extra guidance prepended to the loaded skills.
New module: agent/skill_bundles.py — load, scan, resolve, build
invocation message, save, delete. yaml.safe_load only; broken
bundles log a warning and are skipped, never raise.
New CLI subcommand: hermes bundles {list,show,create,delete,reload}.
Implementation in hermes_cli/bundles.py; wired in hermes_cli/main.py.
'bundles' added to _BUILTIN_SUBCOMMANDS so plugin discovery skips it.
New in-session slash command: /bundles lists installed bundles in
both CLI and gateway. /<bundle-name> dispatch added to CLI (cli.py)
and gateway (gateway/run.py) before the existing /<skill-name> path.
Autocomplete: SlashCommandCompleter gained an optional
skill_bundles_provider parameter that defaults to None — the prompt
shows '▣ <description> (N skills)' for bundles vs '⚡' for skills.
Tests:
- tests/agent/test_skill_bundles.py — 33 tests covering slugify,
scan/cache freshness, resolve (including underscore→hyphen
Telegram alias), build_bundle_invocation_message (loading, missing
skills, user/bundle instruction injection, dedup), save/delete,
reload diff, list sort.
- tests/hermes_cli/test_bundles.py — 8 tests for the CLI
subcommand (create/list/show/delete/reload, --force, missing
bundle errors).
- tests/gateway/test_bundles_command.py — 4 tests for the gateway
handler and bundle resolution priority.
Live E2E: verified subprocess invocations of hermes bundles
{list,create,show,reload,delete} round-trip correctly against an
isolated HERMES_HOME.
Docs:
- website/docs/user-guide/features/skills.md — new 'Skill Bundles'
section with quick example, YAML schema, management commands,
behavior notes.
- website/docs/reference/cli-commands.md — 'hermes bundles' added to
the top-level command table and given its own subcommand section.
* feat(kanban): add scheduled status for delayed follow-ups
Salvages #24533 by @roycepersonalassistant. Adds a first-class
'scheduled' Kanban status for time-delay follow-ups that aren't
waiting on human input.
- hermes kanban schedule <task_id> [reason] CLI command
- Dashboard/API transitions to/from Scheduled
- unblock_task() now releases both 'blocked' AND 'scheduled' tasks
(re-checking parent dependencies before moving to ready/todo)
- i18n + docs updates
Resolved conflicts: kept HEAD's failure-counter reset on unblock
alongside the PR's scheduled state, kept HEAD's 'running' direct-set
rejection, combined both bulk-status branches. Dropped the dist/
bundle changes (months-stale; would need rebuild from source).
* feat(kanban): drag-to-delete trash zone + bulk delete for task cards
Salvages #28125 by @Jpalmer95. Adds:
- Drag-to-delete trash zone in the kanban dashboard
- Bulk delete endpoint with cascading delete_task cleanup
- Frontend updates (drag visual + drop handler)
- Confirmation prompt before delete
Resolved end-of-file test conflict by appending both halves.
* docs: add Korean Kanban documentation
Salvages #21823 by @pochi-gio. Adds Korean (ko) Docusaurus locale and
translates Kanban documentation (kanban.md, kanban-tutorial.md) and the
two related skills (devops-kanban-orchestrator, devops-kanban-worker).
Purely additive — adds ko to the locales list in docusaurus.config.ts
and creates the website/i18n/ko/ tree.
* fix(tests): catch up six stale tests after compression/aux/kanban changes (#28465)
- aux_config: drop session_search from _AUX_TASKS and remove stale test
(PR #27590 removed auxiliary.session_search from DEFAULT_CONFIG)
- compression_boundary_hook: set compressor._last_compress_aborted=False
on MagicMock so the post-compress abort branch (PR #28117) doesn't
short-circuit before the session-id rotation under test
- kanban_dashboard_plugin: use consecutive_failures=3 so severity stays
'error' (failure_threshold default dropped from 3 to 2 in d9fef0c8a,
so failures=5 now crosses the critical floor of 2*2=4)
- cli_manual_compress: accept force kwarg on DummyAgent._compress_context
(cli._manual_compress now passes force=True)
* fix(telegram): render full clarify choice text in message body, use short button labels
When Telegram clarify prompts offer long choices, mobile clients
truncate the inline button labels, making options unreadable.
Previously only the question was shown in the message body with
truncated choice text in button labels.
Fix: append the full numbered option list to the message body
so users can read complete choice text on any client. Buttons
now use short numeric labels (1, 2, ...) to avoid Telegram
truncation. The 'Other (type answer)' button is unchanged.
Long choice labels are now rendered in full (not truncated to
57 chars + '...') since they appear in the body instead of
button labels.
Closes: #27497
* chore(release): map @asdlem for PR #27852 salvage
* fix(telegram): default streaming transport to edit
* fix(telegram): respect reply_to_mode for DM topic reply fallback
The DM topic reply fallback code in send() hardcoded should_thread=True
when telegram_dm_topic_reply_fallback metadata was present, bypassing
_should_thread_reply() and ignoring reply_to_mode config. This caused
quote bubbles on every response even with reply_to_mode: 'off'.
Fix:
- Add reply_to_mode param to _reply_to_message_id_for_send() and
_thread_kwargs_for_send() classmethods
- In send(), check self._reply_to_mode != 'off' for DM topic fallback
- Suppress reply anchor and reply_to_message_id when mode is 'off'
while preserving message_thread_id for correct topic routing
- Thread reply_to_mode through all 29 call sites
Regression coverage: 10 new tests in test_telegram_reply_mode.py
covering classmethod behavior, send() integration, and backward
compatibility.
Fixes reply_to_mode: 'off' ignored by Telegram DM topic reply fallback code #23994
* fix(gateway): route Telegram audio file attachments away from STT pipeline (#24870)
Telegram distinguishes three kinds of audio payloads:
- message.voice → Opus/OGG voice messages → STT pipeline ✓
- message.audio → audio file attachments → bypasses STT ← was broken
- message.document (audio mime) → generic file route
**Root cause** — the inbound message routing block in gateway/run.py
matched both MessageType.VOICE *and* MessageType.AUDIO into audio_paths,
which were then fed unconditionally to _enrich_message_with_transcription.
Audio file attachments (.mp3, .m4a, etc.) were therefore auto-transcribed
instead of being treated as files, making the transcribe skill unusable
from Telegram because the path it needed was never surfaced.
**Fix**
- Introduce a new audio_file_paths list populated exclusively by
MessageType.AUDIO events.
- Narrow the audio_paths selector to MessageType.VOICE (and bare
audio/ mime-type events that are not explicitly AUDIO or DOCUMENT).
- After the STT block, inject a document-style context note for each
audio_file_path, giving the agent the file path and asking what to do
with it (consistent with how plain documents are handled).
**Tests** — 5 new tests in test_telegram_audio_vs_voice.py:
- voice message still transcribed (regression guard)
- audio attachment skips STT (core fix)
- audio attachment context note format
- STT disabled still produces file note (not STT-disabled notice)
- MessageType.AUDIO != MessageType.VOICE sanity check
Fixes #24870
* chore(release): map bartok9 noreply for PR #24879 salvage
* fix(send_message): route standalone Telegram sends through TELEGRAM_PROXY
When the send_message tool runs outside the gateway process (agent loop,
TUI, cron, etc.), _gateway_runner_ref() returns None and the standalone
path in _send_telegram constructs Bot(token=token) directly, bypassing
any configured proxy. In regions where api.telegram.org is blocked, the
send times out after ~5s with 'Telegram send failed: Timed out' and
nothing ever shows up in gateway.log because the request never reaches
the gateway.
Resolve TELEGRAM_PROXY (via gateway.platforms.base.resolve_proxy_url,
which also honours HTTPS_PROXY/HTTP_PROXY/ALL_PROXY and NO_PROXY) just
before constructing the Bot. When a proxy is found, attach an
HTTPXRequest(proxy=...) for both 'request' and 'get_updates_request',
matching what gateway/platforms/telegram.py already does for in-gateway
sends and what the Discord standalone sender already does. Any
exception attaching the proxy falls back cleanly to a direct connection,
preserving prior behaviour for users without a proxy configured.
Adds tests/tools/test_send_message_telegram_proxy.py covering both the
proxy-configured and no-proxy cases.
* chore(release): map @pepelax for PR #25419 salvage
* fix(kanban-dashboard): restore implementations dropped during salvages (#28481)
Four kanban dashboard test failures, all from PR salvages that picked up
the test additions but dropped the corresponding implementations.
- BOARD_COLUMNS: add 'review' (status added by PR f55d94a1e but the
board API never grew the column → test_board_empty failed because
VALID_STATUSES - {archived} mismatched the rendered columns).
- update_task: enrich the 'ready' 409 detail with the blocking parent
list (id, title, status) and add _parents_blocking_ready helper.
Implementation lost in the #26744 salvage (commit e215558ba) which
pinned the test but not the server-side code.
- dist/index.js: add parseApiErrorMessage helper, wire it through the
drag/drop banner, add patchErr state to the TaskDrawer and surface
it inline by the action row. Lost in the same #26744 salvage.
- test_diagnostics_endpoint_severity_filter: update to at-or-above
semantics (PR a94ddd807 changed the filter from exact-match so the
warning filter now correctly includes error+critical too).
* fix(gateway): roll over Telegram tool progress bubbles
* fix(gateway): scope audio_file_paths outside media_urls guard
The audio-file-paths handling block at line 7334 references the variable
unconditionally, but #24879 initialized it inside the 'if event.media_urls'
block — so events without media_urls hit UnboundLocalError.
Found via test_run_agent_queued_message_does_not_treat_commentary_as_final
after PR #28478 landed.
* fix(gateway): keep tool-progress edits alive after Telegram flood control
When a progress-message edit hits Telegram flood control (RetryAfter),
can_edit was unconditionally set to False, permanently disabling coalescing
for the rest of the run. Subsequent tool updates were posted as separate
new messages instead of updating the existing progress bubble.
Fix: only set can_edit=False for non-recoverable edit errors. On flood
control, back off by resetting _last_edit_ts so the throttle interval is
respected before the next edit attempt.
Fixes #25188
* chore(release): map @erhnysr for PR #25198 salvage
* fix(telegram): preserve can_edit after transient network errors in progress edits (#27828)
When edit_message_text fails with a transient error (httpx.ConnectError,
NetworkError, server disconnected, timeouts), the progress-message sender
must not permanently set can_edit = False — that would convert a single
Telegram network hiccup into separate per-tool bubbles for the rest of the run.
Changes:
- gateway/platforms/telegram.py: edit_message now returns retryable=True for
transient network errors (ConnectError, NetworkError, timeouts, server
disconnects, temporarily unavailable). Permanent failures (flood control,
message-not-found, permissions) remain retryable=False.
- gateway/run.py: send_progress_messages checks result.retryable before
setting can_edit = False. Transient failures skip the fallback-send and
continue — the next edit cycle catches up with the accumulated lines.
Permanent failures (flood, message-not-found, etc.) still disable editing.
Tests: 22 new tests in test_telegram_progress_edit_transient.py covering
transient vs permanent error classification, SendResult.retryable semantics,
and the can_edit decision logic.
Fixes #27828
* fix(telegram): recover from post-update polling conflict without entering limbo
* fix(test+release): update conflict retry count for MAX=5; map @CryptoByz
* fix(gateway): route background-process notifications into Telegram DM topics
Background-process completion notifications (notify_on_complete) and
watch-pattern notifications were always delivered to the Telegram main
chat instead of the originating private-chat topic.
Hermes-created Telegram DM topic lanes only render a send when it carries
both message_thread_id and a reply anchor. The synthetic MessageEvent
injected on process completion had no message_id, so _reply_anchor_for_event
returned None and _thread_kwargs_for_send dropped message_thread_id
entirely — routing the notification to the main chat.
Capture the triggering message id at spawn time and thread it through to
the synthetic event so it can be reply-anchored back into the topic:
- session_context: add HERMES_SESSION_MESSAGE_ID context var
- telegram adapter: populate SessionSource.message_id on inbound messages
- terminal tool: persist watcher_message_id on the process session
- process registry: carry/persist message_id on watcher dicts + checkpoint
- gateway: set MessageEvent.message_id on injected notifications
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(release): map @fabiosiqueira for PR #27212 salvage
* fix(telegram): route resumed DM topic sends directly
* fix(telegram): enforce TELEGRAM_ALLOWED_USERS allowlist on inbound messages
TELEGRAM_ALLOWED_USERS was only checked for callback/inline-button
actions but not for inbound messages. Unauthorized users triggered an
'Unauthorized user' log warning but their messages were still processed
by the agent — a P0 security bypass (issue #23778).
Fix: add allowlist check in _should_process_message() which is called
for all message types (text, command, media, location). If the sender
is not in TELEGRAM_ALLOWED_USERS, the message is dropped immediately
with a warning log. Empty TELEGRAM_ALLOWED_USERS continues to allow
all users (existing behavior).
Fixes #23778
* fix(telegram): fail-closed auth fallback when TELEGRAM_ALLOWED_USERS is empty
The _is_callback_user_authorized fallback returned True when
TELEGRAM_ALLOWED_USERS was not set, allowing any Telegram user
to interact with the bot. Change to fail-closed: deny by default
unless GATEWAY_ALLOW_ALL_USERS=true is explicitly set.
Fixes #24457
* test(telegram): stub _is_callback_user_authorized in trigger-gating fixture
After PR #24468 made the empty-allowlist callback auth fail-closed
(and #23795 wired _is_callback_user_authorized into _should_process_message),
trigger-gating tests started failing because their fake messages from
user 111 hit the new deny-by-default path before trigger evaluation.
Force-authorize all senders in _make_adapter() so the trigger logic
under test runs. The fail-closed behavior itself is covered by
test_telegram_callback_auth_fail_closed.py.
* fix(telegram): reset sticky fallback IP on connect failure, retry primary DNS
When a sticky fallback IP (from DoH discovery) becomes unreachable,
the transport previously got stuck in an attempt_order that only
tried the dead IP. This prevented the gateway from recovering
until the service was restarted.
Changes:
- Always include primary DNS path (None) after the sticky IP in the
attempt_order so that a primary-path retry happens on sticky failure.
- Reset self._sticky_ip to None when the currently sticky IP hits
a connect timeout / connect error, allowing the next request to
retry from scratch.
Fixes silent Telegram disconnection when discovered fallback IPs
are transiently or permanently unreachable.
* test+release: align stale sticky-IP test for #24511; map @falconexe
* fix(telegram): propagate extra base_url config
* feat(send_message): auto-detect @username mentions and create Telegram entities
When sending messages containing @username patterns, auto-generate
MessageEntity(type='mention') entries so that the receiving bot's
require_mention filter can trigger. This enables proper bot-to-bot
interop where mention-based routing is used.
* test+release: align send_message mocks for MessageEntity import; map @fonhal
* fix(telegram): resume typing indicator after inline approval click (#27853)
The text /approve and /deny paths in gateway/run.py call
resume_typing_for_chat() after resolve_gateway_approval() succeeds, but
the Telegram inline-button (ea:*) callback in _handle_callback_query did
not. Typing is paused when the approval is sent (gateway/run.py:15658),
so without a matching resume the typing indicator stayed gone for the
remainder of a long-running turn after a button click.
Symmetry-match the text path: after a successful resolve, call
self.resume_typing_for_chat(str(query_chat_id)). Guarded by count > 0
to match /approve's "if not count" early-return — if nothing was
actually resolved, the agent thread was never unblocked, so typing
should remain paused.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(gateway): mark final voice reply as notify-worthy so Telegram delivers it audibly
In Telegram "important" notifications mode (default), TelegramPlatformAdapter
sets ``disable_notification=True`` on every send unless metadata carries
``notify=True``. GatewayRunner._send_voice_reply already passes thread
metadata through to ``adapter.send_voice``, but never marks the final
auto-TTS voice reply as notify-worthy — so users with the default mode get
the final voice note delivered silently with no push notification.
Mirror the final-text path in gateway/platforms/base.py (the existing
text-response final send already adds ``metadata["notify"] = True``).
Issue #27970 Bug 2. Bug 1 (MP3 vs. native OGG voice-note) is being
addressed by existing PRs #20182 / #20878 — this PR is intentionally
scoped to the silent-delivery bug only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: avoid Telegram group reply thread session splits
* chore(release): map @eliteworkstation94-ai for PR #28157 salvage
* fix(gateway): avoid duplicate Telegram text after auto-TTS voice replies
* chore(release): map @Zyrixtrex for PR #26754 salvage
* fix(telegram): escape send_slash_confirm preview with format_message
send_slash_confirm() sent the raw command preview with ParseMode.MARKDOWN,
skipping the format_message() conversion applied to every other dynamic
send in the adapter. Commands with underscores, dots, brackets, or other
MarkdownV2-sensitive characters raised BadRequest: Can't parse entities;
the exception was swallowed by the outer try/except, so the confirmation
prompt silently never appeared.
Fix: wrap preview through format_message() and switch to MARKDOWN_V2,
symmetric with send_update_prompt and the callback sends fixed in
a69404052.
* chore(release): map @nftpoetrist for PR #25856 salvage
* fix(telegram): retry wrapped connect timeouts
* chore(release): map @samahn0601 for PR #27887 salvage
* fix(tts): keep native audio outside Telegram voice delivery
* chore(release): map @aqilaziz for PR #26406 salvage
* fix(gateway): pin Telegram DM-topic routing to user's current topic
Topic-mode DM replies were fragmenting one conversation across many sessions: a Reply on a message in another topic delivered Telegram's message_thread_id for *that* topic, and #3206's strip routed plain replies to the lobby. Both pulled the user away from their current session. Fix: when topic mode is on, rewrite source.thread_id to the user's most-recent binding if the inbound id is missing/General or not a known topic. Non-topic-mode users unchanged.
* chore(release): map @karthikeyann for PR #26609 salvage
* fix(send_message): add thread-not-found retry for Telegram forum topic sends
The standalone _send_telegram path in send_message_tool lacked the
thread-not-found fallback that the gateway adapter has. When a forum
topic thread_id was stale or deleted, the send would fail entirely
instead of retrying to the General topic.
Changes:
- Add _is_telegram_thread_not_found() helper matching gateway adapter
- Add thread-not-found retry in text send path
- Add thread-not-found retry in media send path (with f.seek(0))
- Separate text_kwargs from thread_kwargs to prevent
disable_web_page_preview leaking into send_photo/send_video calls
Closes #27012
* test(send_message): add thread-not-found retry tests for Telegram forum topics
Adds two tests to TestSendTelegramThreadIdMapping:
- test_thread_not_found_retries_without_message_thread_id
- test_thread_not_found_for_media_retries_without_message_thread_id
Refs #27012
* test(send_message): add thread-not-found retry tests for Telegram topics
Three tests covering the #27012 fix:
- test_is_thread_not_found_matches_expected_errors
- test_text_send_retries_without_thread_id_on_thread_not_found
- test_disable_web_page_preview_not_leaked_to_media_sends
116/116 existing tests still pass (no regressions).
* chore(release): map @kunci115 for PR #27098 salvage
* fix(gateway): register Telegram commands for groups
Register Telegram bot commands across default, private, and group scopes so
the slash-command menu is available outside DMs.
Changes from review feedback:
- Add asyncio.Lock to prevent race condition in _ensure_forum_commands
- Extract MAX_COMMANDS_PER_SCOPE constant (30) to avoid magic number
- Upgrade error logging from debug->warning in forum registration
- Add tests covering lazy forum registration and concurrent safety
- Remove /start handler from this PR (separate feature)
Fixes review: needs_work (race, magic number, log levels, missing tests)
* test+release: fix test fixture for forum_commands; map @chromalinx
* fix(telegram): gate profile bots by allowed topics
* chore(release): map @booker1207 for PR #25132 salvage
* fix(cron): route Telegram cron deliveries to a dedicated topic via TELEGRAM_CRON_THREAD_ID
When Telegram topic mode is enabled, cron messages delivered to the bot's
root DM (TELEGRAM_HOME_CHANNEL without a thread id) land in the system
lobby — replies there are rebuffed with the lobby reminder and
reply_to_message_id is dropped, so users cannot interact with the cron
output (#24409).
Add an optional TELEGRAM_CRON_THREAD_ID env var that overrides
TELEGRAM_HOME_CHANNEL_THREAD_ID for cron deliveries only. Operators can
create a "Cron" forum topic in the DM, point this var at its thread id,
and replies to cron messages will land in that topic's existing session
instead of the lobby. The home-channel thread id (used elsewhere, e.g.
restart notifications) is unchanged, and explicit
deliver="telegram:chat:thread" targets continue to win over the env var.
Per the reporter's clarification on 2026-05-13, option (a) (cron-side
route to a dedicated topic + config knob) was chosen.
Fixes #24409
* fix(telegram): route image documents (.png/.jpg/.webp/.gif) through vision pipeline
When users send images as documents (Telegram file picker), they were
rejected with "Unsupported document type" because SUPPORTED_DOCUMENT_TYPES
only includes text/office formats. Add SUPPORTED_IMAGE_DOCUMENT_TYPES
to base.py and handle them in telegram.py before the document check.
- Add SUPPORTED_IMAGE_DOCUMENT_TYPES constant to base.py
- Add MIME reverse-lookup for image types in telegram.py
- Route image documents through cache_image_from_bytes + vision pipeline
- Handle media groups for image documents
Closes: #20128, #18620
* test+release: stub auth in test_telegram_documents fixture; map @kiranvk-2011
* fix(gateway): prevent Windows Telegram /restart leaving gateway stopped
* chore(release): map @rak135 for PR #25960 salvage
* fix(telegram): preserve topic metadata on overflow edits
* feat(telegram): add disable_topic_auto_rename gateway flag
When Hermes auto-titles a session in a Telegram DM topic it currently
renames the topic itself to the generated title. That works for
operator-managed lanes (extra.dm_topics) but is disruptive for
ad-hoc Threaded-Mode topics that users name by hand — every first
exchange overwrites their chosen title.
Add gateway.platforms.telegram.extra.disable_topic_auto_rename (default
False, preserving prior behaviour). When set, both
_schedule_telegram_topic_title_rename and the underlying
_rename_telegram_topic_for_session_title short-circuit before touching
the Telegram API. Internal session titles (sessions list, TUI) keep
working unchanged.
Also bridge the legacy top-level telegram.disable_topic_auto_rename key
through to gateway.platforms.telegram.extra so users on the older
config layout don't have to migrate to enable it.
- Tests cover the runtime flag, the scheduling entry-point, and string
truthiness coercion for YAML-loaded values.
- Docs updated in messaging/telegram.md with an example block.
* chore(release): map @B0Tch1 for PR #27634 salvage
* fix(gateway): restore Telegram DM topic thread_id after session split (#27166)
When context compression triggers a mid-turn session split, source.thread_id
can be None on synthetic/recovered events. _thread_metadata_for_source then
returns None, causing the Telegram adapter to send with no message_thread_id
and the response lands in the General thread instead of the active DM topic.
Fix:
- hermes_state.py: Add get_telegram_topic_binding_by_session() for reverse
lookup by session_id (enabled by the existing UNIQUE INDEX on session_id).
- gateway/run.py: After session-split detection, if source is a Telegram DM
and source.thread_id is None, recover it from the binding via the new
method so _thread_metadata_for_source produces the correct thread routing.
- tests/: Coverage for the new lookup method and the recovery flow.
* chore(release): map @jackjin1997 for PR #27239 salvage
* fix(gateway): allow chat-scoped telegram auth without sender user_id
* chore(release): map @soynchux for PR #27806 salvage
* fix(telegram): add DM topic typing fallback when message_thread_id rejected
When a DM topic lane's message_thread_id is rejected by Telegram
(e.g. stale or deleted topic), send_typing now falls back to sending
the typing indicator without thread_id so it at least appears in the
main DM view, rather than being silently swallowed.
Also adds test for the fallback behavior.
* fix(telegram): report cron topic fallback
* chore(release): map @el-analista for PR #25368 salvage
* fix(telegram): wire gt: callback dispatch for gmail-triage buttons
The gmail-triage skill's Telegram inline buttons emit callback_data of the
form `gt:<verb>:<arg>`, but `_handle_callback_query` had no `gt:` branch —
taps fell through silently and the spinner sat there until Telegram timed it
out.
Add `_handle_gmail_triage_callback`, dispatched from the existing callback
router, that:
- Authorizes the caller via the same `_is_callback_user_authorized` path as
the approval / slash-confirm / clarify handlers.
- Maps each verb to a script under `~/.hermes/scripts/gmail-triage/` and runs
it async with a 60s timeout.
- Splits verbs into one-shots (send / archive / draft / spam) — append the
confirmation and strip the keyboard so the action can't fire twice — and
sticky-state changes (mute / trust / vip ± -domain) — append the
confirmation but leave the keyboard tappable so the user can stack actions
on one email.
- On failure: toast only, keyboard preserved so the user can retry.
- Logs every callback outcome to gateway.log for debugging.
* chore(release): map @khungate for PR #25829 salvage
* feat(telegram): support quick-command-only menus
* chore(release): map @stevehq26-bot for PR #28015 salvage
* fix(telegram): handle channel post updates
* test: address telegram channel post review
* test+release: stub auth in channel_posts fixture; map @brndnsvr
* Quiet noisy Telegram gateway errors
* chore(release): map oracle@jarviss-mbp.home for PR #24014 salvage
* Route Telegram multi-bot mentions exclusively
* Document Telegram multi-profile gateway commands
* fix: ignore Telegram messages for other bots
* chore(release): map @OCWC22 for PR #24581 salvage
* feat(telegram): ignore_root_dm with system command lobby
* docs(telegram): document ignore_root_dm feature
* chore(release): map @ai-hana-ai for PR #23928 salvage
* feat(telegram): pin incoming user message for duration of agent turn
When a user sends a message on Telegram, the incoming message is now
automatically pinned at the start of processing and unpinned when the
agent finishes its turn. This gives the user a visual indicator that
their message is being worked on, and keeps the conversation anchored.
Changes:
- telegram.py: Added pinChatMessage in on_processing_start and
unpinChatMessage in on_processing_complete. Restructured both
hooks so pin/unpin runs independently of the reactions feature
(reactions are optional; pinning is always on).
- telegram.py: Pass message_id through SessionSource so it's
available in the session context.
- session_context.py: Added HERMES_SESSION_MESSAGE_ID context var.
- run.py: Pass source.message_id through set_session_vars.
Pinning is silent (disable_notification=True) and failures are
logged at debug level without interrupting message processing.
Only the user's incoming message is pinned -- never the agent's
replies. Auto-resume events (which have no message_id) are
correctly skipped.
* chore(release): map @indigokarasu for PR #26636 salvage
* feat(telegram): skip-STT audio path + 2GB cap via local Bot API server
Two coordinated changes that unblock downstream audio pipelines
(diarization, custom transcription, archival) on attachments larger
than the public Bot API's 20MB getFile ceiling.
- `stt.enabled: false` no longer drops voice/audio with a generic
"transcription disabled" note. The gateway probes the cached file's
duration (wave → mutagen → ffprobe ladder) and surfaces
`[The user sent a voice message: <abs path> (duration: M:SS)]` to
the agent so a skill or tool can pick up the raw file. The previous
placeholder is replaced rather than appended when present.
- `platforms.telegram.extra.base_url` set → adapter auto-lifts its
document size cap from 20MB to 2GB (the local telegram-bot-api
`--local` ceiling) and the "too large" reply reports the active
limit dynamically. No new config knob; presence of `base_url` is the
opt-in.
- `platforms.telegram.extra.local_mode: true` wires
`Application.builder().local_mode(True)` on the python-telegram-bot
builder. PTB then reads files from disk instead of HTTP, which is
required when telegram-bot-api runs in `--local` mode (the server
returns absolute filesystem paths, not `/file/bot...` URLs).
- gateway/run.py: rewrites the `stt.enabled: false` branch of
`_enrich_message_with_transcription`. New `_format_duration` +
`_probe_audio_duration` helpers.
- gateway/platforms/telegram.py: `_max_doc_bytes` instance attribute
derived from `extra.base_url`; `local_mode` builder wiring;
dynamic "too large" message.
- tests/gateway/test_stt_config.py: covers path-surfacing with and
without an existing user message, and placeholder replacement.
- tests/gateway/test_telegram_max_doc_bytes.py: 3 cases — default 20MB
without base_url, 2GB when set, empty-string base_url keeps default.
- website/docs/user-guide/messaging/telegram.md: new "Skipping STT"
subsection under Voice Messages and a full "Large Files (>20MB) via
Local Bot API Server" walkthrough (api_id/api_hash, docker-compose,
one-time `logOut` migration, `platforms.telegram.extra` config, the
`local_mode` disk-access requirement, the silent HTTP-fallback 404).
- website/docs/user-guide/features/voice-mode.md: documents the
`stt.enabled` knob in the config reference.
- `pytest tests/gateway/test_telegram_max_doc_bytes.py
tests/gateway/test_stt_config.py` → 9/9 passing.
- Verified end-to-end on a live deployment: gateway log shows
`Using custom Telegram base_url: http://...` and
`Using Telegram local_mode (read files from disk)` on startup;
voice messages above 20MB cache to disk and surface their path to
the agent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(release): map @alber70g for PR #25280 salvage
* fix(web): add scheduled column to i18n type definitions (#28549)
columnLabels and columnHelp in en.ts include a scheduled entry but the
Translations interface in types.ts did not declare it, causing a
TypeScript build failure in the Nix derivation. Made the field optional
since only en.ts provides it currently.
* docs: comprehensive 2-week sweep of feature/PR coverage gaps (#28497)
Catch the website docs up to two weeks of merged work (May 4 – May 18, 2026,
roughly 1,080 PRs). The audit found ~50 user-visible features that had landed
in code with no docs footprint, plus a handful of stale pages. This PR closes
every gap the scan turned up.
New pages
- user-guide/features/deliverable-mode.md — extension list, agent triggers,
kanban_complete artifacts pattern, [[as_document]] override (PR #27813).
- developer-guide/web-search-provider-plugin.md — authoring guide modeled on
image-gen-provider-plugin, covering brave_free / ddgs / etc. (PR #25448).
Providers / auth
- Rename "Alibaba Cloud" → "Qwen Cloud (Alibaba DashScope)" everywhere the
display label shows up; provider id stays `alibaba` (PR #24835).
- Document OAuth refresh-token quarantine for xAI / MiniMax / Codex (PRs
#28116 / #28118 / #28119).
- Document Nous JWT minting from refresh token + invalid-refresh quarantine
+ cross-profile shared token store (PRs #27663 / #19712).
- Add `## Microsoft Entra ID authentication (keyless)` section to
azure-foundry guide — DefaultAzureCredential, RBAC, OpenAI + Anthropic
routing details (PR #28101 / #9df9816da).
- Custom providers `api_mode` is now prompted-and-persisted, not just URL
autodetected (PR #25068).
- Delegation honours `api_mode` + auto-detects anthropic_messages base URLs
(PR #26824).
- `x_search` auto-enables when xAI credentials are present (PR #27376).
- Add `xAI Grok OAuth (SuperGrok)` row to providers headline table (PR
#26534).
- NVIDIA NIM billing-origin header is set automatically (PR #26585).
Windows / installer
- `install.ps1`: document `-Commit <sha>` and `-Tag <v>` pin params plus
the BOM-strip / git-retry hardening (PR #28169).
- Document Hermes Desktop thin installer + first-launch bootstrap (PR
#27822).
- Document `dep_ensure` Windows bootstrap (PR #27845).
- Document install-method auto-detection (pip / git / homebrew / nixos) and
the matching update command (PR #27843).
Gateway / messaging
- `/platform list|pause|resume` full description + circuit-breaker
semantics (PR #26600).
- Slack / Matrix / Mattermost get parallel `allowed_channels` /
`allowed_rooms` allowlist sections matching Telegram/Discord/DingTalk
(PR #21251).
- Discord `allow_any_attachment` + `max_attachment_bytes` (config and env
vars) (PR #27245).
- Discord clarify-choice button rendering (PR #25485).
- Telegram `guest_mode` @mention bypass for allowlisted groups (PR
#22759).
- Telegram `notifications` mode (`important` vs `all`) (PR #22793).
- `[[as_document]]` skill / response directive for forcing
document-style media delivery (PR #21210).
CLI / TUI
- `/new [name]` argument (PR #19637).
- `/subgoal` user-supplied criteria appended to `/goal` (PR #25449).
- `/exit --delete` flag confirmation prompts for destructive slash
commands (PR #22687).
- Status-bar additions: ▶ N background indicator (PR #27175), context
compression count (PR #21218), YOLO mode banner+statusbar warning (PR
#26238).
- `display.timestamps` + `docker_extra_args` config keys (PR #23599).
- TUI collapsible startup banner sections (PR #20625).
- `HERMES_SESSION_ID` exported to tool subprocesses (PR #23847).
i18n
- Refresh display.language locale list from 8 → 16 (en, zh, zh-hant, ja,
de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu) — matches
`agent/i18n.py:SUPPORTED_LANGUAGES`.
Tools / features
- `vision_analyze` native-pixel passthrough for vision-capable callers,
with auxiliary text-describer fallback (PR #22955).
- `session_search` rewrite to the single-shape tool (discovery / scroll /
browse modes) (PRs #27590 / #27840).
- Clarify MCP transport scope: client supports stdio + SSE; embedded
`hermes mcp serve` is stdio-only (PR #21227).
- Web search backends table: add Brave Search (free tier) and DDGS rows
(PR #21337).
- ACP session-scoped edit auto-approval modes (PR #27862).
- Curator rename map in the user-visible per-run summary (PR #22910).
- Prompt caching feature page reference in features/overview.md — Claude
cross-session 1-hour prefix cache on native Anthropic / OpenRouter /
Nous Portal (PR #23828).
- Cron per-job profile parameter (PR #28124).
- `--no-skills` flag for `hermes profile create` (PR #20986).
Build
- Verified with `npm run build` in `website/`; both `en` and `zh-Hans`
locales compile. Remaining broken-link/anchor warnings are pre-existing
(`rl-training.md` from learning-path / overview; the
zh-Hans translation lag the docs skill already calls out).
* chore(release): pre-stage AUTHOR_MAP for May 2026 LHF batch group 9 (#28571)
Pre-stages AUTHOR_MAP entries for 9 new/under-mapped contributors whose
PRs are being salvaged in the May 2026 LHF batch group 9.
Contributors:
- jdelmerico (#28278 — signal require_mention filter)
- justemu (#27996 — matrix thread_require_mention)
- YuanHanzhong (#28029 — dashboard browser scrollback)
- noctilust (#28080 — drop stale TUI resume env)
- MoonJuhan (#28288 — tolerate unreadable JSONL transcripts)
- outsourc-e (#28164 — cron emoji ZWJ sequences)
- Zyrixtrex (#28275 — Google OAuth urlopen timeout)
- ooovenenoso (#28256 — tool loop recovery hints)
- vanthinh6886 (#28018 — yaml/flock/atomic write guards; non-noreply email)
Per references/batch-pr-salvage-may14-additions.md.
* feat(signal): add require_mention filter for group chats
Add a configurable mention filter to the Signal adapter so the bot
only responds in groups when it is explicitly @mentioned.
Changes:
- gateway/platforms/signal.py: read require_mention from adapter
extra config or SIGNAL_REQUIRE_MENTION env var; skip group messages
that don't mention the bot account (checked in rendered text and
raw mention metadata)
- gateway/config.py: map signal.require_mention YAML key to the
SIGNAL_REQUIRE_MENTION env var (env var takes precedence)
Config example:
signal:
require_mention: true
Or via env var:
SIGNAL_REQUIRE_MENTION=true
* Revert "feat(telegram): pin incoming user message for duration of agent turn"
This reverts commit a724c3b9cf5f01e28365322ae5ae3a9579567806.
* Revert "feat(telegram): support quick-command-only menus"
This reverts commit b1acf80e17858e2e5ae7c0d412a3a573d7fcbca4.
* Revert "feat(send_message): auto-detect @username mentions and create Telegram entities"
This reverts commit cf814c96f613b38bd891ac941c32da653e81c7ad.
* Revert "fix(telegram): enforce TELEGRAM_ALLOWED_USERS allowlist on inbound messages"
This reverts commit db50af910be6b4171ea9cf54f4cc38be27ac1da6.
* fix(gateway): pre-mark sessions as resume_pending before drain to prevent data loss (#27856)
Pre-mark all running agent sessions as resume_pending BEFORE the drain
wait begins. If the service manager kills the process during the drain
(window), the durable marker is already written so the next gateway boot
can recover in-flight sessions. On graceful drain completion, clear the
early markers for sessions that finished successfully.
* fix(matrix): implement thread_require_mention to prevent multi-agent reply loops
In multi-agent shared Matrix rooms, multiple bots all participating in the
same thread could trigger infinite reply loops — each bot's reply re-engaged
the others because they were all in the bot-thread set. Discord has a
`thread_require_mention` opt-in for this; Matrix didn't.
Add `_parse_thread_require_mention(config)` (mirrors Discord's pattern).
In `_resolve_message_context`, when enabled and the message is in a
bot-participated thread (not a free-response room), require @mention
before processing.
Salvage of @justemu's 2-commit stack (#27996). Fixes #27995.
* fix(cli): show active profile in TUI prompt
* fix(tui): preserve dunder identifiers in markdown
* test(file_ops): add regression tests for git baseline warning in write_file
Adds TestGitBaselineCheck with 6 unit tests covering _check_git_baseline
and the warning field in write_file result:
- Git not available → None
- Not in a git repo → None
- Clean repo → None
- Dirty repo → returns warning string with branch name
- write_file result includes warning when dirty
- write_file result omits warning when clean
* fix(dashboard): use browser scrollback for chat wheel
* fix(cli): ignore stale HERMES_TUI_RESUME env
HERMES_TUI_RESUME is an internal env var the Python wrapper exports to hand
a session ID off to the Ink TUI. Because _launch_tui started from
os.environ.copy(), any exported/stale value in the user's shell leaked
through — so plain `hermes --tui` would try to resume a missing session
and leave the UI at 'error: session not found' with no live session.
Drop HERMES_TUI_RESUME from the env before conditionally re-setting it
from the argparse-resolved resume_session_id. Tests cover both the drop
path and the set-from-arg path.
Salvage of #28080 by @noctilust.
* fix(cron): allow emoji ZWJ sequences in prompts
* fix: tolerate unreadable gateway JSONL transcripts
* fix(skills): add timeout to Google OAuth urlopen calls
* fix: add recovery hints to loop guard warnings
* fix: guard yaml.safe_load, flock unlock, TOCTOU races, and atomic writes
1. trajectory_compressor.py: yaml.safe_load() returns None on empty
files, crashing with TypeError on `if 'tokenizer' in data`. Fix by
adding `or {}` fallback. (HIGH — blocks startup with empty config)
2. 6 files with fcntl.flock(LOCK_UN) in finally blocks without
try/except: cron/scheduler.py, hermes_cli/auth.py,
agent/shell_hooks.py, tools/skill_usage.py,
tools/environments/file_sync.py, tools/memory_tool.py. If unlock
raises OSError, fd.close() is skipped and the lock is held forever.
The msvcrt branches already had try/except; the fcntl branches did
not. Fix by wrapping in try/except (OSError, IOError): pass.
3. agent/copilot_acp_client.py line 639: TOCTOU race — path.exists()
followed by path.read_text() with no try/except. If file is deleted
between the check and the read, FileNotFoundError propagates. Fix
by using try/except FileNotFoundError.
4. gateway/sticker_cache.py: non-atomic write via Path.write_text()
can leave truncated JSON on crash, causing JSONDecodeError on next
load. Fix by writing to tempfile + fsync + os.replace (atomic).
* chore(release): alias xxxigm noreply for upcoming #27986 salvage (#28594)
Adds the canonical noreply form (54813621+xxxigm@users.noreply.github.com)
alongside the existing plain-email mapping so the salvage commit for
@xxxigm's codex doctor PR doesn't fail AUTHOR_MAP CI.
* fix(doctor): attach codex CLI hint to OpenAI Codex auth warning for #27975
`hermes doctor` printed 'codex CLI not installed (optional — ...)' as a
generic info line at the bottom of the auth section, several rows below
'OpenAI Codex auth (not logged in)' and after MiniMax/Gemini auth checks.
Users reading sequentially mistook it for MiniMax-related advice.
Move the hint up under the Codex auth warning so it's adjacent to the
row it actually pertains to. Behavior unchanged when the codex CLI is
installed (success path keeps its 'codex CLI ✓' row at the bottom).
Tests cover both placement and suppression cases.
Salvage of @xxxigm's 3-commit stack (#27986).
Closes #27975.
* fix(tests): catch up 25 stale tests after recent merges (#28626)
Sweep of all CI failures on origin/main, grouped by drift source:
Telegram allowlist gate (db50af910 added user-authz to _should_process_message):
- Hardcoded "[Telegram]" prefix in the logger.warning so the call no
longer dereferences self.name → self.platform, which test fixtures
built via object.__new__ never set.
- test_telegram_format / test_allowed_channels_widening fixtures stub
_is_callback_user_authorized → True so the new gate doesn't reject
guest-mode / allowed-channels test messages.
- test_telegram_approval_buttons::test_update_prompt_callback_not_affected
sets TELEGRAM_ALLOWED_USERS="*" so the fail-closed default doesn't
reject the callback before it writes .update_response.
Approval surface (6d495d9e7 renamed status, 214b95392 detached stdin):
- test_no_callback_returns_approval_required: status is now
"pending_approval" (was "approval_required").
- test_close_stdin_allows_eof_driven_process_to_finish: switch to
use_pty=True; non-PTY now uses stdin=DEVNULL.
Mattermost (send() now resolves root_id via _api_get first):
- test_send_with_thread_reply mocks _session.get with a thread-root
response so the new resolver doesn't TypeError on a bare AsyncMock.
Kanban (d8ad431de rename, f55d94a1e review column, _kanban_worker_skill_available):
- _safe_int → _to_epoch in the two test_kanban_db tests.
- Spawn-skills tests (×3) monkey-patch _kanban_worker_skill_available
to True since the isolated kanban_home fixture has no devops/kanban-worker tree.
- test_gateway_dispatcher_disables_corrupt_board: connect count
3 → 5 (review-column probe now also runs per tick).
Aux-config severity at_or_above (a94ddd807):
- test_diagnostics_endpoint_severity_filter expects warning filter to
include error+critical now (was exact-match).
Anthropic error handling (conversation loop extracted from run_agent):
- _no_backoff_wait fixture patches BOTH run_agent.jittered_backoff AND
agent.conversation_loop.jittered_backoff. The latter is the actual
call site; without the second patch tests burn ~2s per retry and
hit the 30s SIGALRM timeout on CI.
Other test pollution / drift:
- test_auto_does_not_select_copilot_from_github_token: patch
agent.bedrock_adapter.has_aws_credentials → False so boto3's
credential chain can't auto-pick Bedrock from developer ~/.aws.
- test_setup_openclaw_migration: patch hermes_cli.gateway.get_env_value
in addition to setup_mod.get_env_value — _platform_status reads
through the gateway module's binding.
- test_gateway_prefix: COMPONENT_PREFIXES["gateway"] now includes
"hermes_plugins" too.
- test_recommended_update_command_defaults_to_hermes_update: also
short-circuit get_managed_update_command in case a stray
~/.hermes/.managed marker is present.
- test_user_id_is_not_explicit: _parse_target_ref now returns
is_explicit=False for Slack U.../W... IDs (chat.postMessage rejects
them — a DM must be opened first via conversations.open).
* feat(update): syntax-validate critical files post-pull, auto-rollback on failure (#28669)
Catch the PR #28452 failure mode (orphan merge-conflict markers in
hermes_cli/config.py) on the user side: after git pull succeeds, compile
the files every 'hermes' invocation imports at startup. If any has a
syntax error, git reset --hard back to the pre-pull SHA so the install
stays bootable. User can retry once a fix lands upstream.
- New _capture_head_sha() + _validate_critical_files_syntax() helpers
- Wires both into _cmd_update_impl after the pull/reset succeeds
- Tests cover the helpers, the rollback flow, and a production-tree
invariant (CI fails if main itself has a syntax error in a critical
file — catches future broken commits before users hit them)
* feat: show names of user-modified skills in bundled skill sync summary
When 'hermes update' syncs bundled skills, the summary line only shows
the count of user-modified skills that were kept (e.g. '3 user-modified
(kept)'), but not *which* skills. Once the update finishes, the user
has no way to know which skills need triage.
Append the skill names to the summary line, truncated to 5 with a
'+N more' suffix for long lists:
Done: 12 new, 3 updated, 7 unchanged, 3 user-modified (kept):
hermes-agent, debugging-hermes-tui-commands, system-health.
25 total bundled.
Closes #28121
* fix(acp): use tempfile.gettempdir() in workspace auto-approve
#28063 fixed the macOS `/tmp`→`/private/tmp` symlink issue by checking
the RAW path (pre-resolve) against startswith('/tmp/'). That works on
Linux + macOS but not on Windows — Path('/tmp/foo').resolve() returns
C:\\tmp\\foo and isn't the real Windows temp anyway.
Replace the hardcoded '/tmp/' prefix with Path(tempfile.gettempdir()).
resolve() + Path.relative_to() — same idiom as the cwd branch just
below. Works correctly on Linux (/tmp), macOS (/private/var/folders/...),
and Windows (%LOCALAPPDATA%\\Temp).
Test rewritten to use tempfile.gettempdir() so the assertion exercises
the same code path on every platform.
Conflict against the just-merged #28063 (raw_path approach) resolved
by replacing the whole raw_path block — tempfile.gettempdir() is
strictly better than that intermediate fix.
Salvage of #28262 by @Zyrixtrex.
* fix(kanban): stale reclaim must not tick failure counter (#28680)
Follow-up to #28452. detect_stale_running() was calling
_record_task_failure() on every reclaim, which ticked the
consecutive_failures counter. With the default failure_limit=2,
two legitimately long-running tasks (>4 h without explicit
heartbeat) would auto-block via the spawn-failure circuit
breaker — even though no worker actually failed.
Stale reclaim is dispatcher-side absence-of-heartbeat detection,
not a worker fault. Removed the _record_task_failure() call;
the 'stale' event in task_events is still the audit surface,
but the failure counter is now reserved for spawn_failed /
timed_out / crashed (real failures).
Also documents the heartbeat requirement:
- KANBAN_GUIDANCE in agent/prompt_builder.py now states the
rule ('call kanban_heartbeat at least once an hour for tasks
running longer than 1 hour') so workers learn the contract.
- kanban.md adds the stale event row to the events table and
flags the heartbeat requirement in the worker lifecycle list.
New regression test: test_detect_stale_does_not_tick_failure_counter
locks in the new behaviour.
* fix(telegram): address post-merge audit follow-ups (#28670, #28672, #28674, #28676, #28678)
Five small fixes against issues filed during the post-merge salvage audit:
* #28670: `_GATEWAY_PROVIDER_ERROR_RE` false-positives on legitimate prose.
Replace the regex with an anchored `_GATEWAY_PROVIDER_ERROR_SHAPE_RE` and
add a length-cap heuristic to `_looks_like_gateway_provider_error`:
short envelope at the start of the message → real provider error; long
prose containing 'HTTP 404' → assistant answer, leave alone.
* #28672: drop the pointless 1s asyncio.sleep on Telegram thread-not-found
retries. The same-thread retry is preserved (catches Telegram's
occasional transient flake exercised by
test_send_retries_transient_thread_not_found_before_fallback) but with
no artificial delay.
* #28674: broaden `_should_retry_without_dm_topic_reply_anchor` to also
fire when Bot API rejects `direct_messages_topic_id` for synthetic /
resumed sends that have no reply anchor. Avoids dropping post-resume
background notifications if the topic id goes stale.
* #28676: delete the dead image-document branch superseded by bd0c54d17
(which returns early on the same extension set).
* #28678: extend chat-scoped allowlist (`TELEGRAM_GROUP_ALLOWED_CHATS`)
to also cover `chat_type == 'channel'`, so operators can authorize
channel posts by chat id without falling back to per-user allowlists.
Tests:
- scripts/run_tests.sh tests/gateway/test_telegram_thread_fallback.py -q → 41/41
- scripts/run_tests.sh tests/cron/test_scheduler.py -q → 127/127
- broader test set: same 3 pre-existing test-pollution failures reproduce
on plain main.
* chore(actions)(deps): bump the actions-minor-patch group across 1 directory with 2 updates
Bumps the actions-minor-patch group with 2 updates in the / directory: [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action) and [sigstore/gh-action-sigstore-python](https://github.com/sigstore/gh-action-sigstore-python).
Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml` from 2.3.5 to 2.3.8
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/c51854704019a247608d928f370c98740469d4b5...9a498708959aeaef5ef730655706c5a1df1edbc2)
Updates `sigstore/gh-action-sigstore-python` from 3.0.0 to 3.3.0
- [Release notes](https://github.com/sigstore/gh-action-sigstore-python/releases)
- [Changelog](https://github.com/sigstore/gh-action-sigstore-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sigstore/gh-action-sigstore-python/compare/f514d46b907ebcd5bedc05145c03b69c1edd8b46...04cffa1d795717b140764e8b640de88853c92acc)
---
updated-dependencies:
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml
dependency-version: 2.3.8
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: actions-minor-patch
- dependency-name: sigstore/gh-action-sigstore-python
dependency-version: 3.3.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: actions-minor-patch
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(actions)(deps): bump docker/login-action from 3.7.0 to 4.1.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.7.0 to 4.1.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/c94ce9fb468520275223c153574b00df6fe4bcc9...4907a6ddec9925e35a0a9e82d7399ccc52663121)
---
updated-dependencies:
- dependency-name: docker/login-action
dependency-version: 4.1.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(actions)(deps): bump docker/build-push-action from 6.19.2 to 7.1.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.1.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...bcafcacb16a39f128d818304e6c9c0c18556b85f)
---
updated-dependencies:
- dependency-name: docker/build-push-action
dependency-version: 7.1.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(actions)(deps): bump actions/setup-python from 5.3.0 to 6.2.0
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.3.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5.3.0...a309ff8b426b58ec0e2a45f0f869d46889d02405)
---
updated-dependencies:
- dependency-name: actions/setup-python
dependency-version: 6.2.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
* fix(kanban): respawn guard defers blocker_auth instead of auto-blocking (#28683)
Follow-up to #28455. The respawn guard's blocker_auth rule (last error
matched a quota/auth/429 pattern) was auto-blocking the task on first
occurrence. That's too aggressive: transient rate limits typically
clear in seconds to minutes, but the auto-block puts the task in
'blocked' status which requires manual unblock.
Now treats blocker_auth the same as recent_success and active_pr:
defer the spawn this tick, leave the task in 'ready', let the next
tick try again. If the auth error genuinely persists, the existing
consecutive_failures counter trips the auto-block circuit breaker
after failure_limit failures via the normal path — so a persistent
401/403/quota-exhausted still ends up blocked, just not on first hit.
Also documents the respawn_guarded event in kanban.md's events table
with the three guard reasons.
Updated test_dispatch_respawn_guard_auto_blocks_auth_error → renamed
to test_dispatch_respawn_guard_defers_auth_error_without_auto_block;
asserts task stays in 'ready' and the guard reason is recorded.
* chore(actions)(deps): bump actions/checkout from 4.3.1 to 6.0.2
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: 6.0.2
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
* fix(dashboard): add scheduled kanban i18n strings (#28534)
Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): exit prompt_toolkit cleanly on SIGTERM/SIGHUP instead of raising KeyboardInterrupt (#28688)
The SIGTERM/SIGHUP handler raised KeyboardInterrupt() at the end of its
agent-interrupt + grace-window sequence. Python delivers signals between
bytecodes on the main thread, so when the signal hit mid-event-loop
(typically inside prompt_toolkit's '_poll_output_size' coroutine's
'await asyncio.sleep()'), the KeyboardInterrupt unwound INTO that
coroutine. prompt_toolkit's Task captured it as a BaseException;
prompt_toolkit's '_handle_exception' then printed 'Unhandled exception
in event loop' + the full asyncio traceback and parked the terminal on
'Press ENTER to continue...' before exiting.
Same root cause as #13710, different surface: there the failure was an
EIO cascade after a logging-cache KeyError escaped the handler; here
it's the KBI raise itself landing inside an asyncio Task. The fix is
the same shape — let the event loop unwind on its own terms.
Now: schedule 'app.exit()' via 'loop.call_soon_threadsafe()'. The
prompt_toolkit Application returns normally from 'app.run()' and the
existing '(EOFError, KeyboardInterrupt, BrokenPipeError)' handler in
the input loop catches everything else. Fallback to 'raise
KeyboardInterrupt()' preserved for contexts where prompt_toolkit isn't
the active app (e.g. -q one-shot mode).
The agent interrupt + 1.5 s grace window run unchanged before the new
exit path, so subprocess-group cleanup ('os.killpg' on Linux) still
gets its window.
Tested live: external SIGTERM to the CLI (with 'kill <pid>') now exits
cleanly with no traceback dump and no ENTER pause.
* chore(deps): bump dompurify from 3.3.3 to 3.4.2 in /website
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.3 to 3.4.2.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.3.3...3.4.2)
---
updated-dependencies:
- dependency-name: dompurify
dependency-version: 3.4.2
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump follow-redirects from 1.15.11 to 1.16.0 in /website
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)
---
updated-dependencies:
- dependency-name: follow-redirects
dependency-version: 1.16.0
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump lodash from 4.17.23 to 4.18.1 in /website
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)
---
updated-dependencies:
- dependency-name: lodash
dependency-version: 4.18.1
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump lodash-es and langium in /website
Bumps [lodash-es](https://github.com/lodash/lodash) and [langium](https://github.com/eclipse-langium/langium/tree/HEAD/packages/langium). These dependencies needed to be updated together.
Updates `lodash-es` from 4.17.23 to 4.18.1
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)
Updates `langium` from 4.2.1 to 4.2.3
- [Release notes](https://github.com/eclipse-langium/langium/releases)
- [Changelog](https://github.com/eclipse-langium/langium/blob/main/packages/langium/CHANGELOG.md)
- [Commits](https://github.com/eclipse-langium/langium/commits/HEAD/packages/langium)
---
updated-dependencies:
- dependency-name: lodash-es
dependency-version: 4.18.1
dependency-type: indirect
- dependency-name: langium
dependency-version: 4.2.3
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump python-multipart from 0.0.22 to 0.0.27
Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.22 to 0.0.27.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.22...0.0.27)
---
updated-dependencies:
- dependency-name: python-multipart
dependency-version: 0.0.27
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump python-dotenv from 1.2.1 to 1.2.2
Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/theskumar/python-dotenv/releases)
- [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2)
---
updated-dependencies:
- dependency-name: python-dotenv
dependency-version: 1.2.2
dependency-type: direct:production
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump fast-uri from 3.1.0 to 3.1.2 in /website
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)
---
updated-dependencies:
- dependency-name: fast-uri
dependency-version: 3.1.2
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump @babel/plugin-transform-modules-systemjs in /website
Bumps [@babel/plugin-transform-modules-systemjs](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-modules-systemjs) from 7.29.0 to 7.29.4.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.4/packages/babel-plugin-transform-modules-systemjs)
---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-modules-systemjs"
dependency-version: 7.29.4
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* fix(web): consume bundled design system assets (#26391)
* fix: update design system…
bot-ted
added a commit
to bot-ted/hermes-agent
that referenced
this pull request
May 22, 2026
* fix(gateway): allow chat-scoped telegram auth without sender user_id
* chore(release): map @soynchux for PR #27806 salvage
* fix(telegram): add DM topic typing fallback when message_thread_id rejected
When a DM topic lane's message_thread_id is rejected by Telegram
(e.g. stale or deleted topic), send_typing now falls back to sending
the typing indicator without thread_id so it at least appears in the
main DM view, rather than being silently swallowed.
Also adds test for the fallback behavior.
* fix(telegram): report cron topic fallback
* chore(release): map @el-analista for PR #25368 salvage
* fix(telegram): wire gt: callback dispatch for gmail-triage buttons
The gmail-triage skill's Telegram inline buttons emit callback_data of the
form `gt:<verb>:<arg>`, but `_handle_callback_query` had no `gt:` branch —
taps fell through silently and the spinner sat there until Telegram timed it
out.
Add `_handle_gmail_triage_callback`, dispatched from the existing callback
router, that:
- Authorizes the caller via the same `_is_callback_user_authorized` path as
the approval / slash-confirm / clarify handlers.
- Maps each verb to a script under `~/.hermes/scripts/gmail-triage/` and runs
it async with a 60s timeout.
- Splits verbs into one-shots (send / archive / draft / spam) — append the
confirmation and strip the keyboard so the action can't fire twice — and
sticky-state changes (mute / trust / vip ± -domain) — append the
confirmation but leave the keyboard tappable so the user can stack actions
on one email.
- On failure: toast only, keyboard preserved so the user can retry.
- Logs every callback outcome to gateway.log for debugging.
* chore(release): map @khungate for PR #25829 salvage
* feat(telegram): support quick-command-only menus
* chore(release): map @stevehq26-bot for PR #28015 salvage
* fix(telegram): handle channel post updates
* test: address telegram channel post review
* test+release: stub auth in channel_posts fixture; map @brndnsvr
* Quiet noisy Telegram gateway errors
* chore(release): map oracle@jarviss-mbp.home for PR #24014 salvage
* Route Telegram multi-bot mentions exclusively
* Document Telegram multi-profile gateway commands
* fix: ignore Telegram messages for other bots
* chore(release): map @OCWC22 for PR #24581 salvage
* feat(telegram): ignore_root_dm with system command lobby
* docs(telegram): document ignore_root_dm feature
* chore(release): map @ai-hana-ai for PR #23928 salvage
* feat(telegram): pin incoming user message for duration of agent turn
When a user sends a message on Telegram, the incoming message is now
automatically pinned at the start of processing and unpinned when the
agent finishes its turn. This gives the user a visual indicator that
their message is being worked on, and keeps the conversation anchored.
Changes:
- telegram.py: Added pinChatMessage in on_processing_start and
unpinChatMessage in on_processing_complete. Restructured both
hooks so pin/unpin runs independently of the reactions feature
(reactions are optional; pinning is always on).
- telegram.py: Pass message_id through SessionSource so it's
available in the session context.
- session_context.py: Added HERMES_SESSION_MESSAGE_ID context var.
- run.py: Pass source.message_id through set_session_vars.
Pinning is silent (disable_notification=True) and failures are
logged at debug level without interrupting message processing.
Only the user's incoming message is pinned -- never the agent's
replies. Auto-resume events (which have no message_id) are
correctly skipped.
* chore(release): map @indigokarasu for PR #26636 salvage
* feat(telegram): skip-STT audio path + 2GB cap via local Bot API server
Two coordinated changes that unblock downstream audio pipelines
(diarization, custom transcription, archival) on attachments larger
than the public Bot API's 20MB getFile ceiling.
- `stt.enabled: false` no longer drops voice/audio with a generic
"transcription disabled" note. The gateway probes the cached file's
duration (wave → mutagen → ffprobe ladder) and surfaces
`[The user sent a voice message: <abs path> (duration: M:SS)]` to
the agent so a skill or tool can pick up the raw file. The previous
placeholder is replaced rather than appended when present.
- `platforms.telegram.extra.base_url` set → adapter auto-lifts its
document size cap from 20MB to 2GB (the local telegram-bot-api
`--local` ceiling) and the "too large" reply reports the active
limit dynamically. No new config knob; presence of `base_url` is the
opt-in.
- `platforms.telegram.extra.local_mode: true` wires
`Application.builder().local_mode(True)` on the python-telegram-bot
builder. PTB then reads files from disk instead of HTTP, which is
required when telegram-bot-api runs in `--local` mode (the server
returns absolute filesystem paths, not `/file/bot...` URLs).
- gateway/run.py: rewrites the `stt.enabled: false` branch of
`_enrich_message_with_transcription`. New `_format_duration` +
`_probe_audio_duration` helpers.
- gateway/platforms/telegram.py: `_max_doc_bytes` instance attribute
derived from `extra.base_url`; `local_mode` builder wiring;
dynamic "too large" message.
- tests/gateway/test_stt_config.py: covers path-surfacing with and
without an existing user message, and placeholder replacement.
- tests/gateway/test_telegram_max_doc_bytes.py: 3 cases — default 20MB
without base_url, 2GB when set, empty-string base_url keeps default.
- website/docs/user-guide/messaging/telegram.md: new "Skipping STT"
subsection under Voice Messages and a full "Large Files (>20MB) via
Local Bot API Server" walkthrough (api_id/api_hash, docker-compose,
one-time `logOut` migration, `platforms.telegram.extra` config, the
`local_mode` disk-access requirement, the silent HTTP-fallback 404).
- website/docs/user-guide/features/voice-mode.md: documents the
`stt.enabled` knob in the config reference.
- `pytest tests/gateway/test_telegram_max_doc_bytes.py
tests/gateway/test_stt_config.py` → 9/9 passing.
- Verified end-to-end on a live deployment: gateway log shows
`Using custom Telegram base_url: http://...` and
`Using Telegram local_mode (read files from disk)` on startup;
voice messages above 20MB cache to disk and surface their path to
the agent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(release): map @alber70g for PR #25280 salvage
* fix(web): add scheduled column to i18n type definitions (#28549)
columnLabels and columnHelp in en.ts include a scheduled entry but the
Translations interface in types.ts did not declare it, causing a
TypeScript build failure in the Nix derivation. Made the field optional
since only en.ts provides it currently.
* docs: comprehensive 2-week sweep of feature/PR coverage gaps (#28497)
Catch the website docs up to two weeks of merged work (May 4 – May 18, 2026,
roughly 1,080 PRs). The audit found ~50 user-visible features that had landed
in code with no docs footprint, plus a handful of stale pages. This PR closes
every gap the scan turned up.
New pages
- user-guide/features/deliverable-mode.md — extension list, agent triggers,
kanban_complete artifacts pattern, [[as_document]] override (PR #27813).
- developer-guide/web-search-provider-plugin.md — authoring guide modeled on
image-gen-provider-plugin, covering brave_free / ddgs / etc. (PR #25448).
Providers / auth
- Rename "Alibaba Cloud" → "Qwen Cloud (Alibaba DashScope)" everywhere the
display label shows up; provider id stays `alibaba` (PR #24835).
- Document OAuth refresh-token quarantine for xAI / MiniMax / Codex (PRs
#28116 / #28118 / #28119).
- Document Nous JWT minting from refresh token + invalid-refresh quarantine
+ cross-profile shared token store (PRs #27663 / #19712).
- Add `## Microsoft Entra ID authentication (keyless)` section to
azure-foundry guide — DefaultAzureCredential, RBAC, OpenAI + Anthropic
routing details (PR #28101 / #9df9816da).
- Custom providers `api_mode` is now prompted-and-persisted, not just URL
autodetected (PR #25068).
- Delegation honours `api_mode` + auto-detects anthropic_messages base URLs
(PR #26824).
- `x_search` auto-enables when xAI credentials are present (PR #27376).
- Add `xAI Grok OAuth (SuperGrok)` row to providers headline table (PR
#26534).
- NVIDIA NIM billing-origin header is set automatically (PR #26585).
Windows / installer
- `install.ps1`: document `-Commit <sha>` and `-Tag <v>` pin params plus
the BOM-strip / git-retry hardening (PR #28169).
- Document Hermes Desktop thin installer + first-launch bootstrap (PR
#27822).
- Document `dep_ensure` Windows bootstrap (PR #27845).
- Document install-method auto-detection (pip / git / homebrew / nixos) and
the matching update command (PR #27843).
Gateway / messaging
- `/platform list|pause|resume` full description + circuit-breaker
semantics (PR #26600).
- Slack / Matrix / Mattermost get parallel `allowed_channels` /
`allowed_rooms` allowlist sections matching Telegram/Discord/DingTalk
(PR #21251).
- Discord `allow_any_attachment` + `max_attachment_bytes` (config and env
vars) (PR #27245).
- Discord clarify-choice button rendering (PR #25485).
- Telegram `guest_mode` @mention bypass for allowlisted groups (PR
#22759).
- Telegram `notifications` mode (`important` vs `all`) (PR #22793).
- `[[as_document]]` skill / response directive for forcing
document-style media delivery (PR #21210).
CLI / TUI
- `/new [name]` argument (PR #19637).
- `/subgoal` user-supplied criteria appended to `/goal` (PR #25449).
- `/exit --delete` flag confirmation prompts for destructive slash
commands (PR #22687).
- Status-bar additions: ▶ N background indicator (PR #27175), context
compression count (PR #21218), YOLO mode banner+statusbar warning (PR
#26238).
- `display.timestamps` + `docker_extra_args` config keys (PR #23599).
- TUI collapsible startup banner sections (PR #20625).
- `HERMES_SESSION_ID` exported to tool subprocesses (PR #23847).
i18n
- Refresh display.language locale list from 8 → 16 (en, zh, zh-hant, ja,
de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu) — matches
`agent/i18n.py:SUPPORTED_LANGUAGES`.
Tools / features
- `vision_analyze` native-pixel passthrough for vision-capable callers,
with auxiliary text-describer fallback (PR #22955).
- `session_search` rewrite to the single-shape tool (discovery / scroll /
browse modes) (PRs #27590 / #27840).
- Clarify MCP transport scope: client supports stdio + SSE; embedded
`hermes mcp serve` is stdio-only (PR #21227).
- Web search backends table: add Brave Search (free tier) and DDGS rows
(PR #21337).
- ACP session-scoped edit auto-approval modes (PR #27862).
- Curator rename map in the user-visible per-run summary (PR #22910).
- Prompt caching feature page reference in features/overview.md — Claude
cross-session 1-hour prefix cache on native Anthropic / OpenRouter /
Nous Portal (PR #23828).
- Cron per-job profile parameter (PR #28124).
- `--no-skills` flag for `hermes profile create` (PR #20986).
Build
- Verified with `npm run build` in `website/`; both `en` and `zh-Hans`
locales compile. Remaining broken-link/anchor warnings are pre-existing
(`rl-training.md` from learning-path / overview; the
zh-Hans translation lag the docs skill already calls out).
* chore(release): pre-stage AUTHOR_MAP for May 2026 LHF batch group 9 (#28571)
Pre-stages AUTHOR_MAP entries for 9 new/under-mapped contributors whose
PRs are being salvaged in the May 2026 LHF batch group 9.
Contributors:
- jdelmerico (#28278 — signal require_mention filter)
- justemu (#27996 — matrix thread_require_mention)
- YuanHanzhong (#28029 — dashboard browser scrollback)
- noctilust (#28080 — drop stale TUI resume env)
- MoonJuhan (#28288 — tolerate unreadable JSONL transcripts)
- outsourc-e (#28164 — cron emoji ZWJ sequences)
- Zyrixtrex (#28275 — Google OAuth urlopen timeout)
- ooovenenoso (#28256 — tool loop recovery hints)
- vanthinh6886 (#28018 — yaml/flock/atomic write guards; non-noreply email)
Per references/batch-pr-salvage-may14-additions.md.
* feat(signal): add require_mention filter for group chats
Add a configurable mention filter to the Signal adapter so the bot
only responds in groups when it is explicitly @mentioned.
Changes:
- gateway/platforms/signal.py: read require_mention from adapter
extra config or SIGNAL_REQUIRE_MENTION env var; skip group messages
that don't mention the bot account (checked in rendered text and
raw mention metadata)
- gateway/config.py: map signal.require_mention YAML key to the
SIGNAL_REQUIRE_MENTION env var (env var takes precedence)
Config example:
signal:
require_mention: true
Or via env var:
SIGNAL_REQUIRE_MENTION=true
* Revert "feat(telegram): pin incoming user message for duration of agent turn"
This reverts commit a724c3b9cf5f01e28365322ae5ae3a9579567806.
* Revert "feat(telegram): support quick-command-only menus"
This reverts commit b1acf80e17858e2e5ae7c0d412a3a573d7fcbca4.
* Revert "feat(send_message): auto-detect @username mentions and create Telegram entities"
This reverts commit cf814c96f613b38bd891ac941c32da653e81c7ad.
* Revert "fix(telegram): enforce TELEGRAM_ALLOWED_USERS allowlist on inbound messages"
This reverts commit db50af910be6b4171ea9cf54f4cc38be27ac1da6.
* fix(gateway): pre-mark sessions as resume_pending before drain to prevent data loss (#27856)
Pre-mark all running agent sessions as resume_pending BEFORE the drain
wait begins. If the service manager kills the process during the drain
(window), the durable marker is already written so the next gateway boot
can recover in-flight sessions. On graceful drain completion, clear the
early markers for sessions that finished successfully.
* fix(matrix): implement thread_require_mention to prevent multi-agent reply loops
In multi-agent shared Matrix rooms, multiple bots all participating in the
same thread could trigger infinite reply loops — each bot's reply re-engaged
the others because they were all in the bot-thread set. Discord has a
`thread_require_mention` opt-in for this; Matrix didn't.
Add `_parse_thread_require_mention(config)` (mirrors Discord's pattern).
In `_resolve_message_context`, when enabled and the message is in a
bot-participated thread (not a free-response room), require @mention
before processing.
Salvage of @justemu's 2-commit stack (#27996). Fixes #27995.
* fix(cli): show active profile in TUI prompt
* fix(tui): preserve dunder identifiers in markdown
* test(file_ops): add regression tests for git baseline warning in write_file
Adds TestGitBaselineCheck with 6 unit tests covering _check_git_baseline
and the warning field in write_file result:
- Git not available → None
- Not in a git repo → None
- Clean repo → None
- Dirty repo → returns warning string with branch name
- write_file result includes warning when dirty
- write_file result omits warning when clean
* fix(dashboard): use browser scrollback for chat wheel
* fix(cli): ignore stale HERMES_TUI_RESUME env
HERMES_TUI_RESUME is an internal env var the Python wrapper exports to hand
a session ID off to the Ink TUI. Because _launch_tui started from
os.environ.copy(), any exported/stale value in the user's shell leaked
through — so plain `hermes --tui` would try to resume a missing session
and leave the UI at 'error: session not found' with no live session.
Drop HERMES_TUI_RESUME from the env before conditionally re-setting it
from the argparse-resolved resume_session_id. Tests cover both the drop
path and the set-from-arg path.
Salvage of #28080 by @noctilust.
* fix(cron): allow emoji ZWJ sequences in prompts
* fix: tolerate unreadable gateway JSONL transcripts
* fix(skills): add timeout to Google OAuth urlopen calls
* fix: add recovery hints to loop guard warnings
* fix: guard yaml.safe_load, flock unlock, TOCTOU races, and atomic writes
1. trajectory_compressor.py: yaml.safe_load() returns None on empty
files, crashing with TypeError on `if 'tokenizer' in data`. Fix by
adding `or {}` fallback. (HIGH — blocks startup with empty config)
2. 6 files with fcntl.flock(LOCK_UN) in finally blocks without
try/except: cron/scheduler.py, hermes_cli/auth.py,
agent/shell_hooks.py, tools/skill_usage.py,
tools/environments/file_sync.py, tools/memory_tool.py. If unlock
raises OSError, fd.close() is skipped and the lock is held forever.
The msvcrt branches already had try/except; the fcntl branches did
not. Fix by wrapping in try/except (OSError, IOError): pass.
3. agent/copilot_acp_client.py line 639: TOCTOU race — path.exists()
followed by path.read_text() with no try/except. If file is deleted
between the check and the read, FileNotFoundError propagates. Fix
by using try/except FileNotFoundError.
4. gateway/sticker_cache.py: non-atomic write via Path.write_text()
can leave truncated JSON on crash, causing JSONDecodeError on next
load. Fix by writing to tempfile + fsync + os.replace (atomic).
* chore(release): alias xxxigm noreply for upcoming #27986 salvage (#28594)
Adds the canonical noreply form (54813621+xxxigm@users.noreply.github.com)
alongside the existing plain-email mapping so the salvage commit for
@xxxigm's codex doctor PR doesn't fail AUTHOR_MAP CI.
* fix(doctor): attach codex CLI hint to OpenAI Codex auth warning for #27975
`hermes doctor` printed 'codex CLI not installed (optional — ...)' as a
generic info line at the bottom of the auth section, several rows below
'OpenAI Codex auth (not logged in)' and after MiniMax/Gemini auth checks.
Users reading sequentially mistook it for MiniMax-related advice.
Move the hint up under the Codex auth warning so it's adjacent to the
row it actually pertains to. Behavior unchanged when the codex CLI is
installed (success path keeps its 'codex CLI ✓' row at the bottom).
Tests cover both placement and suppression cases.
Salvage of @xxxigm's 3-commit stack (#27986).
Closes #27975.
* fix(tests): catch up 25 stale tests after recent merges (#28626)
Sweep of all CI failures on origin/main, grouped by drift source:
Telegram allowlist gate (db50af910 added user-authz to _should_process_message):
- Hardcoded "[Telegram]" prefix in the logger.warning so the call no
longer dereferences self.name → self.platform, which test fixtures
built via object.__new__ never set.
- test_telegram_format / test_allowed_channels_widening fixtures stub
_is_callback_user_authorized → True so the new gate doesn't reject
guest-mode / allowed-channels test messages.
- test_telegram_approval_buttons::test_update_prompt_callback_not_affected
sets TELEGRAM_ALLOWED_USERS="*" so the fail-closed default doesn't
reject the callback before it writes .update_response.
Approval surface (6d495d9e7 renamed status, 214b95392 detached stdin):
- test_no_callback_returns_approval_required: status is now
"pending_approval" (was "approval_required").
- test_close_stdin_allows_eof_driven_process_to_finish: switch to
use_pty=True; non-PTY now uses stdin=DEVNULL.
Mattermost (send() now resolves root_id via _api_get first):
- test_send_with_thread_reply mocks _session.get with a thread-root
response so the new resolver doesn't TypeError on a bare AsyncMock.
Kanban (d8ad431de rename, f55d94a1e review column, _kanban_worker_skill_available):
- _safe_int → _to_epoch in the two test_kanban_db tests.
- Spawn-skills tests (×3) monkey-patch _kanban_worker_skill_available
to True since the isolated kanban_home fixture has no devops/kanban-worker tree.
- test_gateway_dispatcher_disables_corrupt_board: connect count
3 → 5 (review-column probe now also runs per tick).
Aux-config severity at_or_above (a94ddd807):
- test_diagnostics_endpoint_severity_filter expects warning filter to
include error+critical now (was exact-match).
Anthropic error handling (conversation loop extracted from run_agent):
- _no_backoff_wait fixture patches BOTH run_agent.jittered_backoff AND
agent.conversation_loop.jittered_backoff. The latter is the actual
call site; without the second patch tests burn ~2s per retry and
hit the 30s SIGALRM timeout on CI.
Other test pollution / drift:
- test_auto_does_not_select_copilot_from_github_token: patch
agent.bedrock_adapter.has_aws_credentials → False so boto3's
credential chain can't auto-pick Bedrock from developer ~/.aws.
- test_setup_openclaw_migration: patch hermes_cli.gateway.get_env_value
in addition to setup_mod.get_env_value — _platform_status reads
through the gateway module's binding.
- test_gateway_prefix: COMPONENT_PREFIXES["gateway"] now includes
"hermes_plugins" too.
- test_recommended_update_command_defaults_to_hermes_update: also
short-circuit get_managed_update_command in case a stray
~/.hermes/.managed marker is present.
- test_user_id_is_not_explicit: _parse_target_ref now returns
is_explicit=False for Slack U.../W... IDs (chat.postMessage rejects
them — a DM must be opened first via conversations.open).
* feat(update): syntax-validate critical files post-pull, auto-rollback on failure (#28669)
Catch the PR #28452 failure mode (orphan merge-conflict markers in
hermes_cli/config.py) on the user side: after git pull succeeds, compile
the files every 'hermes' invocation imports at startup. If any has a
syntax error, git reset --hard back to the pre-pull SHA so the install
stays bootable. User can retry once a fix lands upstream.
- New _capture_head_sha() + _validate_critical_files_syntax() helpers
- Wires both into _cmd_update_impl after the pull/reset succeeds
- Tests cover the helpers, the rollback flow, and a production-tree
invariant (CI fails if main itself has a syntax error in a critical
file — catches future broken commits before users hit them)
* feat: show names of user-modified skills in bundled skill sync summary
When 'hermes update' syncs bundled skills, the summary line only shows
the count of user-modified skills that were kept (e.g. '3 user-modified
(kept)'), but not *which* skills. Once the update finishes, the user
has no way to know which skills need triage.
Append the skill names to the summary line, truncated to 5 with a
'+N more' suffix for long lists:
Done: 12 new, 3 updated, 7 unchanged, 3 user-modified (kept):
hermes-agent, debugging-hermes-tui-commands, system-health.
25 total bundled.
Closes #28121
* fix(acp): use tempfile.gettempdir() in workspace auto-approve
#28063 fixed the macOS `/tmp`→`/private/tmp` symlink issue by checking
the RAW path (pre-resolve) against startswith('/tmp/'). That works on
Linux + macOS but not on Windows — Path('/tmp/foo').resolve() returns
C:\\tmp\\foo and isn't the real Windows temp anyway.
Replace the hardcoded '/tmp/' prefix with Path(tempfile.gettempdir()).
resolve() + Path.relative_to() — same idiom as the cwd branch just
below. Works correctly on Linux (/tmp), macOS (/private/var/folders/...),
and Windows (%LOCALAPPDATA%\\Temp).
Test rewritten to use tempfile.gettempdir() so the assertion exercises
the same code path on every platform.
Conflict against the just-merged #28063 (raw_path approach) resolved
by replacing the whole raw_path block — tempfile.gettempdir() is
strictly better than that intermediate fix.
Salvage of #28262 by @Zyrixtrex.
* fix(kanban): stale reclaim must not tick failure counter (#28680)
Follow-up to #28452. detect_stale_running() was calling
_record_task_failure() on every reclaim, which ticked the
consecutive_failures counter. With the default failure_limit=2,
two legitimately long-running tasks (>4 h without explicit
heartbeat) would auto-block via the spawn-failure circuit
breaker — even though no worker actually failed.
Stale reclaim is dispatcher-side absence-of-heartbeat detection,
not a worker fault. Removed the _record_task_failure() call;
the 'stale' event in task_events is still the audit surface,
but the failure counter is now reserved for spawn_failed /
timed_out / crashed (real failures).
Also documents the heartbeat requirement:
- KANBAN_GUIDANCE in agent/prompt_builder.py now states the
rule ('call kanban_heartbeat at least once an hour for tasks
running longer than 1 hour') so workers learn the contract.
- kanban.md adds the stale event row to the events table and
flags the heartbeat requirement in the worker lifecycle list.
New regression test: test_detect_stale_does_not_tick_failure_counter
locks in the new behaviour.
* fix(telegram): address post-merge audit follow-ups (#28670, #28672, #28674, #28676, #28678)
Five small fixes against issues filed during the post-merge salvage audit:
* #28670: `_GATEWAY_PROVIDER_ERROR_RE` false-positives on legitimate prose.
Replace the regex with an anchored `_GATEWAY_PROVIDER_ERROR_SHAPE_RE` and
add a length-cap heuristic to `_looks_like_gateway_provider_error`:
short envelope at the start of the message → real provider error; long
prose containing 'HTTP 404' → assistant answer, leave alone.
* #28672: drop the pointless 1s asyncio.sleep on Telegram thread-not-found
retries. The same-thread retry is preserved (catches Telegram's
occasional transient flake exercised by
test_send_retries_transient_thread_not_found_before_fallback) but with
no artificial delay.
* #28674: broaden `_should_retry_without_dm_topic_reply_anchor` to also
fire when Bot API rejects `direct_messages_topic_id` for synthetic /
resumed sends that have no reply anchor. Avoids dropping post-resume
background notifications if the topic id goes stale.
* #28676: delete the dead image-document branch superseded by bd0c54d17
(which returns early on the same extension set).
* #28678: extend chat-scoped allowlist (`TELEGRAM_GROUP_ALLOWED_CHATS`)
to also cover `chat_type == 'channel'`, so operators can authorize
channel posts by chat id without falling back to per-user allowlists.
Tests:
- scripts/run_tests.sh tests/gateway/test_telegram_thread_fallback.py -q → 41/41
- scripts/run_tests.sh tests/cron/test_scheduler.py -q → 127/127
- broader test set: same 3 pre-existing test-pollution failures reproduce
on plain main.
* chore(actions)(deps): bump the actions-minor-patch group across 1 directory with 2 updates
Bumps the actions-minor-patch group with 2 updates in the / directory: [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action) and [sigstore/gh-action-sigstore-python](https://github.com/sigstore/gh-action-sigstore-python).
Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml` from 2.3.5 to 2.3.8
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/c51854704019a247608d928f370c98740469d4b5...9a498708959aeaef5ef730655706c5a1df1edbc2)
Updates `sigstore/gh-action-sigstore-python` from 3.0.0 to 3.3.0
- [Release notes](https://github.com/sigstore/gh-action-sigstore-python/releases)
- [Changelog](https://github.com/sigstore/gh-action-sigstore-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sigstore/gh-action-sigstore-python/compare/f514d46b907ebcd5bedc05145c03b69c1edd8b46...04cffa1d795717b140764e8b640de88853c92acc)
---
updated-dependencies:
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml
dependency-version: 2.3.8
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: actions-minor-patch
- dependency-name: sigstore/gh-action-sigstore-python
dependency-version: 3.3.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: actions-minor-patch
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(actions)(deps): bump docker/login-action from 3.7.0 to 4.1.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.7.0 to 4.1.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/c94ce9fb468520275223c153574b00df6fe4bcc9...4907a6ddec9925e35a0a9e82d7399ccc52663121)
---
updated-dependencies:
- dependency-name: docker/login-action
dependency-version: 4.1.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(actions)(deps): bump docker/build-push-action from 6.19.2 to 7.1.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.1.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...bcafcacb16a39f128d818304e6c9c0c18556b85f)
---
updated-dependencies:
- dependency-name: docker/build-push-action
dependency-version: 7.1.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(actions)(deps): bump actions/setup-python from 5.3.0 to 6.2.0
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.3.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5.3.0...a309ff8b426b58ec0e2a45f0f869d46889d02405)
---
updated-dependencies:
- dependency-name: actions/setup-python
dependency-version: 6.2.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
* fix(kanban): respawn guard defers blocker_auth instead of auto-blocking (#28683)
Follow-up to #28455. The respawn guard's blocker_auth rule (last error
matched a quota/auth/429 pattern) was auto-blocking the task on first
occurrence. That's too aggressive: transient rate limits typically
clear in seconds to minutes, but the auto-block puts the task in
'blocked' status which requires manual unblock.
Now treats blocker_auth the same as recent_success and active_pr:
defer the spawn this tick, leave the task in 'ready', let the next
tick try again. If the auth error genuinely persists, the existing
consecutive_failures counter trips the auto-block circuit breaker
after failure_limit failures via the normal path — so a persistent
401/403/quota-exhausted still ends up blocked, just not on first hit.
Also documents the respawn_guarded event in kanban.md's events table
with the three guard reasons.
Updated test_dispatch_respawn_guard_auto_blocks_auth_error → renamed
to test_dispatch_respawn_guard_defers_auth_error_without_auto_block;
asserts task stays in 'ready' and the guard reason is recorded.
* chore(actions)(deps): bump actions/checkout from 4.3.1 to 6.0.2
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: 6.0.2
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
* fix(dashboard): add scheduled kanban i18n strings (#28534)
Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): exit prompt_toolkit cleanly on SIGTERM/SIGHUP instead of raising KeyboardInterrupt (#28688)
The SIGTERM/SIGHUP handler raised KeyboardInterrupt() at the end of its
agent-interrupt + grace-window sequence. Python delivers signals between
bytecodes on the main thread, so when the signal hit mid-event-loop
(typically inside prompt_toolkit's '_poll_output_size' coroutine's
'await asyncio.sleep()'), the KeyboardInterrupt unwound INTO that
coroutine. prompt_toolkit's Task captured it as a BaseException;
prompt_toolkit's '_handle_exception' then printed 'Unhandled exception
in event loop' + the full asyncio traceback and parked the terminal on
'Press ENTER to continue...' before exiting.
Same root cause as #13710, different surface: there the failure was an
EIO cascade after a logging-cache KeyError escaped the handler; here
it's the KBI raise itself landing inside an asyncio Task. The fix is
the same shape — let the event loop unwind on its own terms.
Now: schedule 'app.exit()' via 'loop.call_soon_threadsafe()'. The
prompt_toolkit Application returns normally from 'app.run()' and the
existing '(EOFError, KeyboardInterrupt, BrokenPipeError)' handler in
the input loop catches everything else. Fallback to 'raise
KeyboardInterrupt()' preserved for contexts where prompt_toolkit isn't
the active app (e.g. -q one-shot mode).
The agent interrupt + 1.5 s grace window run unchanged before the new
exit path, so subprocess-group cleanup ('os.killpg' on Linux) still
gets its window.
Tested live: external SIGTERM to the CLI (with 'kill <pid>') now exits
cleanly with no traceback dump and no ENTER pause.
* chore(deps): bump dompurify from 3.3.3 to 3.4.2 in /website
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.3 to 3.4.2.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.3.3...3.4.2)
---
updated-dependencies:
- dependency-name: dompurify
dependency-version: 3.4.2
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump follow-redirects from 1.15.11 to 1.16.0 in /website
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)
---
updated-dependencies:
- dependency-name: follow-redirects
dependency-version: 1.16.0
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump lodash from 4.17.23 to 4.18.1 in /website
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)
---
updated-dependencies:
- dependency-name: lodash
dependency-version: 4.18.1
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump lodash-es and langium in /website
Bumps [lodash-es](https://github.com/lodash/lodash) and [langium](https://github.com/eclipse-langium/langium/tree/HEAD/packages/langium). These dependencies needed to be updated together.
Updates `lodash-es` from 4.17.23 to 4.18.1
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)
Updates `langium` from 4.2.1 to 4.2.3
- [Release notes](https://github.com/eclipse-langium/langium/releases)
- [Changelog](https://github.com/eclipse-langium/langium/blob/main/packages/langium/CHANGELOG.md)
- [Commits](https://github.com/eclipse-langium/langium/commits/HEAD/packages/langium)
---
updated-dependencies:
- dependency-name: lodash-es
dependency-version: 4.18.1
dependency-type: indirect
- dependency-name: langium
dependency-version: 4.2.3
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump python-multipart from 0.0.22 to 0.0.27
Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.22 to 0.0.27.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.22...0.0.27)
---
updated-dependencies:
- dependency-name: python-multipart
dependency-version: 0.0.27
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump python-dotenv from 1.2.1 to 1.2.2
Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/theskumar/python-dotenv/releases)
- [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2)
---
updated-dependencies:
- dependency-name: python-dotenv
dependency-version: 1.2.2
dependency-type: direct:production
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump fast-uri from 3.1.0 to 3.1.2 in /website
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)
---
updated-dependencies:
- dependency-name: fast-uri
dependency-version: 3.1.2
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* chore(deps): bump @babel/plugin-transform-modules-systemjs in /website
Bumps [@babel/plugin-transform-modules-systemjs](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-modules-systemjs) from 7.29.0 to 7.29.4.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.4/packages/babel-plugin-transform-modules-systemjs)
---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-modules-systemjs"
dependency-version: 7.29.4
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* fix(web): consume bundled design system assets (#26391)
* fix: update design system package, replace bg image, remove sync assets
* fix(web): update bundled asset metadata
* fix(web): normalize npm lockfile metadata
* fix(nix): refresh npm lockfile hashes
* chore(ci): trigger PR checks
* fix(web): declare motion peer dependency
* fix(nix): refresh npm lockfile hashes
* chore(ci): trigger PR checks after dependency update
* fix(web): restore cross-platform lockfile entries
* fix(nix): refresh npm lockfile hashes
* chore(ci): trigger PR checks after lockfile restore
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* chore(deps): bump webpack-dev-server from 5.2.3 to 5.2.4 in /website (#28104)
Bumps [webpack-dev-server](https://github.com/webpack/webpack-dev-server) from 5.2.3 to 5.2.4.
- [Release notes](https://github.com/webpack/webpack-dev-server/releases)
- [Changelog](https://github.com/webpack/webpack-dev-server/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-server/compare/v5.2.3...v5.2.4)
---
updated-dependencies:
- dependency-name: webpack-dev-server
dependency-version: 5.2.4
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump ws in /ui-tui/packages/hermes-ink (#28183)
Bumps [ws](https://github.com/websockets/ws) from 8.20.0 to 8.20.1.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.0...8.20.1)
---
updated-dependencies:
- dependency-name: ws
dependency-version: 8.20.1
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump mermaid from 11.13.0 to 11.15.0 in /website (#24011)
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.13.0 to 11.15.0.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.13.0...mermaid@11.15.0)
---
updated-dependencies:
- dependency-name: mermaid
dependency-version: 11.15.0
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix(kanban): migrate task session index after columns
* fix(kanban): also hoist idx_events_run + drop redundant inner create
Extends the previous commit to cover the remaining additive-column index
that sits on the same migration trap:
- ``task_events.run_id`` -> ``idx_events_run`` was still in SCHEMA_SQL.
A legacy ``task_events`` table predating #17805 (no ``run_id``) would
still abort ``executescript`` before ``_migrate_add_optional_columns``
could add the column. Hoisted out of SCHEMA_SQL and made unconditional
in the migration alongside the other three indexes.
- Removed the now-redundant ``CREATE INDEX idx_tasks_idempotency`` that
was nested inside the ``if "idempotency_key" not in cols`` branch.
The unconditional create lower in the function makes it idempotent
on both fresh and legacy DBs.
- Strengthened the regression test to cover all four indexes
(``idx_tasks_session_id``, ``idx_tasks_tenant``, ``idx_tasks_idempotency``,
``idx_events_run``) and to seed a pre-#17805 ``task_events`` shape that
exercises the ``run_id`` migration path.
The result: every ``CREATE INDEX`` that depends on an additive column now
runs after the migration ensures the column exists. Verified against a
realistic pre-#16081 board fixture (tasks + task_events both legacy
shape) — origin/main reproduces ``no such column: session_id``; this
branch migrates cleanly and creates all four indexes.
* fix(discord): define view classes after lazy discord.py install
When discord.py is not installed at import time, DISCORD_AVAILABLE=False
and the view class definitions at module bottom are skipped.
check_discord_requirements() performs a lazy install and sets
DISCORD_AVAILABLE=True but never re-ran the class definitions, causing
NameError on the first button interaction (exec approval, slash confirm, etc.).
Extract the five ui.View subclasses into _define_discord_view_classes() and
call it both at module load (when discord.py is pre-installed) and inside
check_discord_requirements() after a successful lazy install.
* Merge pull request #28829 from NousResearch/bb/tui-no-history-truncation
fix(tui): render full assistant text in scrollback (no history truncation)
* chore: add erikengervall to AUTHOR_MAP (#28855)
For PR #28774 (firecrawl integration tag).
Co-authored-by: alt-glitch <balyan.sid@gmail.com>
* feat(firecrawl): add integration tag for Hermes usage in browser and web providers
* fix(model-switch): mark bare custom provider as current
* Revert "feat(firecrawl): add integration tag for Hermes usage in browser and web providers" (#28862)
This reverts commit 273ff5c4a47af4499bbe5e3b1139efd313995554.
* fix(update): quarantine hermes.exe vs concurrent Windows instance (#26670) (#26677)
* fix(update): detect concurrent hermes.exe on Windows; retry + restart-defer quarantine
Closes #26670.
When 'hermes update' runs on Windows with another hermes.exe alive (most
commonly the Hermes Desktop Electron app's spawned backend) _quarantine_running_hermes_exe()
fails to rename the venv shim with [WinError 32]. uv pip install -e .
then exits 2, the git-pull fast path is silently abandoned, and the ZIP
fallback runs (and fails the same way) before eventually succeeding.
This change implements three of the five proposed fixes from the issue:
1. Concurrent-instance detection (preferred fix). _detect_concurrent_hermes_instances()
uses psutil to enumerate processes whose .exe is one of our venv shims
(hermes.exe / hermes-gateway.exe), excluding the caller's PID. When any
match exists, cmd_update prints an actionable message naming the
blocking PIDs and exits 2 BEFORE any destructive work. New --force flag
bypasses the gate.
2. Retry + restart-deferred fallback. _quarantine_running_hermes_exe()
now retries the rename up to 4 times with 100/250/500/1000 ms backoff
(covers the transient AV-scanner-handle case). If all retries fail,
it schedules the replacement via MoveFileExW with the OS deferred-rename
flag so the new shim can land at the original path and the update
completes; the old image is fully unloaded after the user's next
system restart.
3. Actionable warning text. The old 'Could not quarantine: [WinError 32]'
warning is replaced with one that names the likely culprits (Hermes
Desktop, REPLs, gateway, AV) and points to the new --force flag.
Tests:
- 13 new tests in tests/hermes_cli/test_update_concurrent_quarantine.py
covering: psutil-based enumeration, self-pid exclusion, case-insensitive
matching of .EXE, no-psutil graceful degradation, off-Windows no-op,
helpful warning formatting, retry-then-succeed, restart-deferred fallback,
cmd_update abort + exit code 2, and --force bypass.
- New autouse fixture in tests/hermes_cli/conftest.py defaults
_detect_concurrent_hermes_instances to [] so the rest of the suite
isn't tripped by the developer's own running hermes.exe. Opt-out marker
'real_concurrent_gate' registered in pyproject.toml.
- Updating docs page (website/docs/getting-started/updating.md) gains a
short section explaining the new Windows error and remediation.
* chore: refresh uv.lock to match pyproject.toml exact pins
aiohttp 3.13.4 -> 3.13.3 (matches pyproject pin: aiohttp==3.13.3)
anthropic 0.87.0 -> 0.86.0 (matches pyproject pin: anthropic==0.86.0)
hermes-agent 0.13.0 -> 0.14.0 (matches pyproject version)
CI's uv lock --check was failing on the merged state because main
drifted: pyproject.toml uses exact == pins for those two deps and the
hermes-agent version was bumped to 0.14.0 but the lockfile still had
0.13.0.
* fix(windows): hide cron script subprocess consoles
Apply CREATE_NO_WINDOW flags when the cron scheduler launches job scripts on Windows so gateway-managed no-agent cron jobs do not flash cmd or python console windows every tick.
* fix(windows): hide local subprocess consoles
Apply Windows CREATE_NO_WINDOW flags to foreground local terminal subprocesses and tracked background processes so Hermes operations do not flash or steal focus with extra console windows.
* fix(gateway): harden Windows gateway install lifecycle
Preserve Windows profile install decisions across UAC handoff, avoid visible console windows by launching via pythonw, make repeated install/start idempotent, recreate stale Scheduled Tasks, and separate start-now from login auto-start behavior. Add Windows gateway regression coverage and systemd setup tests for the shared install flow.
* test(gateway-windows): make ctypes.windll monkeypatch tolerant on non-Windows
Linux/macOS CI runners don't have ctypes.windll, so the elevated-gateway
test fails at module load. Adding raising=False lets monkeypatch install
the mock attribute without first requiring it to exist.
* fix(agent): set tool_name on tool-result messages at construction time
Introduces make_tool_result_message() in tool_dispatch_helpers.py as the
single place where tool-result message dicts are built. All six construction
sites in tool_executor.py, agent_runtime_helpers.py, and mini_swe_runner.py
now use it, so tool_name is set in memory from the moment a message is
created rather than relying on fallback logic in the flush paths.
Fixes blank tool_name in both state.db and JSON session logs.
Adds tests.
* fix(tui): termux-gate scrollback preservation, touch-friendly defaults
Adds a Termux runtime detection helper and gates three TUI defaults on it:
- Skip the startup scrollback clear on Termux so users can review/copy
earlier output after reopening the app. Desktop keeps the existing
\x1b[2J\x1b[H\x1b[3J slate (AlternateScreen takes over there anyway).
- Default INLINE_MODE on under Termux: primary-buffer rendering makes
long-thread review and copy/paste much less fragile when users
background/foreground the app. Override with HERMES_TUI_INLINE=0/1.
- Default mouse tracking off under Termux so touch selection isn't
intercepted by terminal mouse protocols. Explicit override via
HERMES_TUI_MOUSE_TRACKING=0/1; legacy HERMES_TUI_DISABLE_MOUSE still
works on desktop.
Detection is purely env-based (TERMUX_VERSION or PREFIX path) with an
explicit opt-out HERMES_TUI_TERMUX_MODE=0 for debugging. Non-Termux
platforms keep every existing default.
Co-authored-by: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com>
* feat: add BrowseShSource adapter for browse.sh skills catalog
Adds BrowseShSource — a new skill source adapter that integrates
Browserbase's browse.sh catalog (169+ site-specific SKILL.md files)
into the Hermes Skills Hub.
- BrowseShSource class in tools/skills_hub.py implementing SkillSource ABC
- Fetches browse.sh catalog API with 1h TTL cache
- Full-text search across name, title, description, hostname, category, tags
- fetch() downloads SKILL.md via sourceUrl (GitHub HTML -> raw URL conversion)
- Registered in create_source_router() after LobeHubSource
- Tests in tests/tools/test_skills_hub_browse_sh.py (7 tests, all passing)
* fix: register browse-sh in per-source limits and --source choices
- Add 'browse-sh' to _PER_SOURCE_LIMIT in both do_browse() and
browse_skills() with limit=500 (covers full 171-skill catalog)
- Add 'browse-sh' to --source argparse choices for both
'hermes skills browse' and 'hermes skills search'
Without these, browse-sh fell back to the default cap of 50 results
and was not filterable via --source.
* fix(browse-sh): fetch SKILL.md via /api/skills/{slug}+skillMdUrl
The catalog's sourceUrl points at github.com/browserbase/browse.sh,
whose underlying repository is not always public — most raw URLs derived
from it 404. Use the per-skill detail endpoint instead, which returns a
skillMdUrl CDN blob that reliably resolves to the SKILL.md text. Fall
back to a raw.githubusercontent.com sourceUrl if the detail call fails.
- tools/skills_hub.py: rewrite BrowseShSource.fetch() to resolve via
/api/skills/{slug} -> skillMdUrl; drop the unreachable _to_raw_url
helper; expose the resolved URL in bundle.metadata.skill_md_url.
- tests/tools/test_skills_hub_browse_sh.py: match the real catalog
shape (name = task name, slug = host/task-id), exercise the
detail-endpoint -> blob two-call flow, and add a fallback test.
- scripts/release.py: map kylejeong21@gmail.com -> Kylejeong2.
* docs(skills): document browse.sh source (#28939)
Add browse.sh (browse-sh) to the supported-sources table and
integrated-hubs section in user-guide/features/skills.md, and to the
--source notes in reference/cli-commands.md. Companion to the
BrowseShSource adapter merged in #28936.
* fix(cli): preserve setup config picker writes
Resync the setup wizard's in-memory config after the shared model picker writes to disk so the wizard's final save does not overwrite auxiliary choices or other provider updates.\n\nAdds a regression test for auxiliary task choices saved by the picker.
* fix(runtime): treat 'ollama'/'vllm'/'llamacpp' aliases like 'custom' for base_url trust (#27132)
When config.yaml has provider: ollama (or vllm/llamacpp/llama-cpp) with a
non-loopback base_url, auth.py's resolve_provider() correctly normalises
the alias to 'custom' at the top level, but two sites in runtime_provider.py
were still comparing the *original* string against the literal 'custom':
- _config_base_url_trustworthy_for_bare_custom() rejected non-loopback
URLs because cfg_provider_norm was 'ollama', not 'custom'.
- _resolve_openrouter_runtime() only entered the trust branch when
requested_norm == 'custom'.
Both sites now consult resolve_provider() and treat any alias that
resolves to 'custom' identically. Result: provider: ollama + LAN IP no
longer silently falls through to OpenRouter (HTTP 401), matching the
behaviour of provider: custom with the same base_url.
E2E verified across 6 cases (ollama/vllm/llamacpp/custom + LAN; ollama +
loopback; openrouter + cloud) — all route to the configured endpoint;
'frobnicate' + LAN still rejects with AuthError as before.
Also adds scripts/release.py AUTHOR_MAP entry for @stepanov1975
(PR #22074 — wizard config picker preservation, cherry-picked into the
preceding commit).
* perf(cli): defer openai._base_client import via sys.meta_path finder (#28864)
`cli.py` was eager-importing `openai._base_client` at module-load time
purely to monkeypatch `AsyncHttpxClientWrapper.__del__` (defense against
"Press ENTER to continue..." errors when AsyncOpenAI clients are GC'd
against dead event loops). That import cost ~166ms / ~30MB on every
cold CLI start because openai's type tree (responses/*, graders/*) is huge.
Replace with a `sys.meta_path` finder that intercepts the first import
of `openai._base_client` from anywhere in the codebase, lets the normal
load run, then applies the `__del__ = lambda self: None` patch before
control returns to the caller. Same correctness guarantee (patch
applies before any AsyncOpenAI instance can be constructed), zero cost
until the SDK is actually needed.
Hot path: every hermes chat / gateway boot / cron tick / subagent spawn.
A/B benchmark, 10 runs each, fresh subprocess:
BEFORE AFTER delta
import cli wall 0.86s 0.62s -28% (median)
import cli wall 0.85s 0.59s -31% (min)
import cli RSS 91.2MB 74.0MB -19% (median)
The `neuter_async_httpx_del` function in agent/auxiliary_client.py is
unchanged; its tests still pass and any future callers can still invoke
it directly.
Verified:
- import cli no longer pulls openai into sys.modules
- first 'from openai._base_client import AsyncHttpxClientWrapper'
triggers the patch; __del__.__name__ == '<lambda>'
- tests/run_agent/test_async_httpx_del_neuter.py: 9/9 pass
- tests/agent/test_auxiliary_client.py: 159/159 pass
- tests/cli/: 715/715 pass
* perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
* chore(deps): bump ws from 8.20.0 to 8.20.1 in /ui-tui
Bumps [ws](https://github.com/websockets/ws) from 8.20.0 to 8.20.1.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.0...8.20.1)
---
updated-dependencies:
- dependency-name: ws
dependency-version: 8.20.1
dependency-type: indirect
...
Signed-off-by: dependabot[bot] <support@github.com>
* fix(install.ps1): pin PortableGit instead of hitting rate-limited GitHub API (#28943)
The Windows installer fetched the latest git-for-windows release via
api.github.com/repos/git-for-windows/git/releases/latest, which is
rate-limited to 60 requests/hour/IP for unauthenticated callers. Users
behind CGNAT, corporate NAT, dorm WiFi, or shared ISP routinely hit the
limit, and the installer aborts asking them to install Git manually.
Switch to a pinned release tag (v2.54.0.windows.1) and a static
github.com/.../releases/download/<tag>/<asset> URL. Static download
URLs are served by GitHub's blob storage and are not subject to the
API rate limit.
Trade-offs:
- We have to bump the pin when we want a newer Git for Windows. The
installer doesn't depend on Git features beyond 'works', so this is
a once-a-year maintenance cost at most.
- Loses the (cosmetic) MB size display, since we no longer have asset
metadata. Replaced with the version string in the 'Downloading ...'
line instead.
* fix(model): match custom provider by active base url
* 🐛 fix(cli): handle no-remote worktree cleanup
* 🐛 fix(cli): handle missing remote tracking refs
* fix(xai-oauth): pin inference base_url to x.ai origin (#28952)
XAI_BASE_URL / HERMES_XAI_BASE_URL let users repoint the OAuth-authenticated
inference endpoint, but the env override was an unguarded credential-leak
vector: a tampered .env or hostile shell init setting
XAI_BASE_URL=https://attacker.example/v1 would silently ship the SuperGrok
OAuth bearer to a third party on every request.
Add _xai_validate_inference_base_url() that pins the host to x.ai or a
*.x.ai subdomain and rejects non-HTTPS. On rejection, fall back to the
default with a warning rather than raise — a bad env var should not
deadlock auth, but should never leak the bearer either.
Apply at all three sites that read the env override for xai-oauth:
- hermes_cli/auth.py resolve_xai_oauth_runtime_credentials (main path)
- hermes_cli/auth.py _xai_oauth_loopback_login (initial login)
- agent/auxiliary_client.py _resolve_xai_oauth_for_aux (aux client)
E2E validated against four scenarios: attacker.example, lookalike
api.x.ai.evil.com, http:// downgrade on api.x.ai, and legit custom.x.ai
subdomain (which still resolves correctly).
Discovered while comparing against the opencode-grok-auth plugin
(github.com/ysnock404/opencode-grok-auth), which highlighted the same
guard on the OpenCode side.
* fix(kanban): worker-initiated block must not be auto-promoted (#28712)
When a worker calls ``kanban_block(reason="review-required: ...")`` to
hand a task off for human review, the dispatcher's ``recompute_ready``
was treating the resulting ``blocked`` status as eligible for
auto-promotion — exactly the same as a circuit-breaker block. On the
next tick the task flipped back to ``ready``, a fresh worker spawned,
found nothing to do (work already applied, review-required comment
already posted), exited cleanly, got recorded as ``protocol_violation``
→ ``gave_up`` → ``blocked``, and the dispatcher promoted again.
Infinite loop until manual ``hermes kanban reclaim`` + ``kanban block``.
Add ``_has_sticky_block`` which distinguishes the two block sources
using the cheapest available signal: the most recent
``"blocked"``/``"unblocked"`` event in ``task_events``.
* Worker / operator ``kanban_block`` emits ``"blocked"`` →
``_has_sticky_block`` returns True → ``recompute_ready`` skips the
task entirely. ``unblock_task`` emits ``"unblocked"`` which flips
the predicate back, so the only legitimate exit is the documented
human-in-the-loop path.
* Circuit-breaker ``_record_task_failure`` emits ``"gave_up"`` (not
``"blocked"``) → predicate stays False → original
parent-completion-recovery semantics from #40c1decb3 are preserved.
* Tasks blocked purely by direct DB manipulation also recover, since
they have no ``"blocked"`` event row at all — matches the existing
``test_recompute_ready_promotes_blocked_with_done_parents`` fixture
behaviour.
* test(kanban): cover sticky blocks for worker-initiated kanban_block (#28712)
Six regression tests pinning the dispatcher contract that was broken
in #28712:
* test_worker_block_is_not_auto_promoted_by_recompute_ready —
kanban_block survives five back-to-back ticks (compressed dispatcher
loop).
* test_worker_block_on_child_with_done_parents_is_still_sticky —
the parent-completion code path was the worst false-positive; even
when every parent is done, an explicit worker block stays blocked.
* test_circuit_breaker_block_still_auto_promotes — preserves the
pre-#28712 recovery semantics for circuit-breaker blocks (direct
UPDATE + no "blocked" event).
* test_gave_up_event_alone_does_not_make_block_sticky — explicit
guard so the gave_up event is never accidentally treated as
sticky; covers the second leg of the protocol_violation loop.
* test_unblock_clears_sticky_state_and_lets_block_recover — only
unblock_task resolves the sticky state; subsequent circuit-breaker
blocks recover normally.
* test_protocol_violation_loop_is_broken — full bug-shaped
reproduction: block → tick → (would-be) crash + gave_up → next tick
still blocked. Without the fix this would loop indefinitely.
The seventh test from the original PR (legacy-DB init recovery) was
dropped during salvage — the schema-init half of #28712 is already
fixed on main by #28754 and #28781, and the contract is covered by
test_kanban_db.py::test_connect_migrates_legacy_db_before_optional_column_indexes.
* fix(discord): transcribe n…
Mucky010
pushed a commit
to Mucky010/hermes-agent
that referenced
this pull request
May 24, 2026
…earch#28497) Catch the website docs up to two weeks of merged work (May 4 – May 18, 2026, roughly 1,080 PRs). The audit found ~50 user-visible features that had landed in code with no docs footprint, plus a handful of stale pages. This PR closes every gap the scan turned up. New pages - user-guide/features/deliverable-mode.md — extension list, agent triggers, kanban_complete artifacts pattern, [[as_document]] override (PR NousResearch#27813). - developer-guide/web-search-provider-plugin.md — authoring guide modeled on image-gen-provider-plugin, covering brave_free / ddgs / etc. (PR NousResearch#25448). Providers / auth - Rename "Alibaba Cloud" → "Qwen Cloud (Alibaba DashScope)" everywhere the display label shows up; provider id stays `alibaba` (PR NousResearch#24835). - Document OAuth refresh-token quarantine for xAI / MiniMax / Codex (PRs NousResearch#28116 / NousResearch#28118 / NousResearch#28119). - Document Nous JWT minting from refresh token + invalid-refresh quarantine + cross-profile shared token store (PRs NousResearch#27663 / NousResearch#19712). - Add `## Microsoft Entra ID authentication (keyless)` section to azure-foundry guide — DefaultAzureCredential, RBAC, OpenAI + Anthropic routing details (PR NousResearch#28101 / #9df9816da). - Custom providers `api_mode` is now prompted-and-persisted, not just URL autodetected (PR NousResearch#25068). - Delegation honours `api_mode` + auto-detects anthropic_messages base URLs (PR NousResearch#26824). - `x_search` auto-enables when xAI credentials are present (PR NousResearch#27376). - Add `xAI Grok OAuth (SuperGrok)` row to providers headline table (PR NousResearch#26534). - NVIDIA NIM billing-origin header is set automatically (PR NousResearch#26585). Windows / installer - `install.ps1`: document `-Commit <sha>` and `-Tag <v>` pin params plus the BOM-strip / git-retry hardening (PR NousResearch#28169). - Document Hermes Desktop thin installer + first-launch bootstrap (PR NousResearch#27822). - Document `dep_ensure` Windows bootstrap (PR NousResearch#27845). - Document install-method auto-detection (pip / git / homebrew / nixos) and the matching update command (PR NousResearch#27843). Gateway / messaging - `/platform list|pause|resume` full description + circuit-breaker semantics (PR NousResearch#26600). - Slack / Matrix / Mattermost get parallel `allowed_channels` / `allowed_rooms` allowlist sections matching Telegram/Discord/DingTalk (PR NousResearch#21251). - Discord `allow_any_attachment` + `max_attachment_bytes` (config and env vars) (PR NousResearch#27245). - Discord clarify-choice button rendering (PR NousResearch#25485). - Telegram `guest_mode` @mention bypass for allowlisted groups (PR NousResearch#22759). - Telegram `notifications` mode (`important` vs `all`) (PR NousResearch#22793). - `[[as_document]]` skill / response directive for forcing document-style media delivery (PR NousResearch#21210). CLI / TUI - `/new [name]` argument (PR NousResearch#19637). - `/subgoal` user-supplied criteria appended to `/goal` (PR NousResearch#25449). - `/exit --delete` flag confirmation prompts for destructive slash commands (PR NousResearch#22687). - Status-bar additions: ▶ N background indicator (PR NousResearch#27175), context compression count (PR NousResearch#21218), YOLO mode banner+statusbar warning (PR NousResearch#26238). - `display.timestamps` + `docker_extra_args` config keys (PR NousResearch#23599). - TUI collapsible startup banner sections (PR NousResearch#20625). - `HERMES_SESSION_ID` exported to tool subprocesses (PR NousResearch#23847). i18n - Refresh display.language locale list from 8 → 16 (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu) — matches `agent/i18n.py:SUPPORTED_LANGUAGES`. Tools / features - `vision_analyze` native-pixel passthrough for vision-capable callers, with auxiliary text-describer fallback (PR NousResearch#22955). - `session_search` rewrite to the single-shape tool (discovery / scroll / browse modes) (PRs NousResearch#27590 / NousResearch#27840). - Clarify MCP transport scope: client supports stdio + SSE; embedded `hermes mcp serve` is stdio-only (PR NousResearch#21227). - Web search backends table: add Brave Search (free tier) and DDGS rows (PR NousResearch#21337). - ACP session-scoped edit auto-approval modes (PR NousResearch#27862). - Curator rename map in the user-visible per-run summary (PR NousResearch#22910). - Prompt caching feature page reference in features/overview.md — Claude cross-session 1-hour prefix cache on native Anthropic / OpenRouter / Nous Portal (PR NousResearch#23828). - Cron per-job profile parameter (PR NousResearch#28124). - `--no-skills` flag for `hermes profile create` (PR NousResearch#20986). Build - Verified with `npm run build` in `website/`; both `en` and `zh-Hans` locales compile. Remaining broken-link/anchor warnings are pre-existing (`rl-training.md` from learning-path / overview; the zh-Hans translation lag the docs skill already calls out).
Bryce-huang
pushed a commit
to wbkunlun/hermes-agent
that referenced
this pull request
May 29, 2026
…earch#28497) Catch the website docs up to two weeks of merged work (May 4 – May 18, 2026, roughly 1,080 PRs). The audit found ~50 user-visible features that had landed in code with no docs footprint, plus a handful of stale pages. This PR closes every gap the scan turned up. New pages - user-guide/features/deliverable-mode.md — extension list, agent triggers, kanban_complete artifacts pattern, [[as_document]] override (PR NousResearch#27813). - developer-guide/web-search-provider-plugin.md — authoring guide modeled on image-gen-provider-plugin, covering brave_free / ddgs / etc. (PR NousResearch#25448). Providers / auth - Rename "Alibaba Cloud" → "Qwen Cloud (Alibaba DashScope)" everywhere the display label shows up; provider id stays `alibaba` (PR NousResearch#24835). - Document OAuth refresh-token quarantine for xAI / MiniMax / Codex (PRs NousResearch#28116 / NousResearch#28118 / NousResearch#28119). - Document Nous JWT minting from refresh token + invalid-refresh quarantine + cross-profile shared token store (PRs NousResearch#27663 / NousResearch#19712). - Add `## Microsoft Entra ID authentication (keyless)` section to azure-foundry guide — DefaultAzureCredential, RBAC, OpenAI + Anthropic routing details (PR NousResearch#28101 / #9df9816da). - Custom providers `api_mode` is now prompted-and-persisted, not just URL autodetected (PR NousResearch#25068). - Delegation honours `api_mode` + auto-detects anthropic_messages base URLs (PR NousResearch#26824). - `x_search` auto-enables when xAI credentials are present (PR NousResearch#27376). - Add `xAI Grok OAuth (SuperGrok)` row to providers headline table (PR NousResearch#26534). - NVIDIA NIM billing-origin header is set automatically (PR NousResearch#26585). Windows / installer - `install.ps1`: document `-Commit <sha>` and `-Tag <v>` pin params plus the BOM-strip / git-retry hardening (PR NousResearch#28169). - Document Hermes Desktop thin installer + first-launch bootstrap (PR NousResearch#27822). - Document `dep_ensure` Windows bootstrap (PR NousResearch#27845). - Document install-method auto-detection (pip / git / homebrew / nixos) and the matching update command (PR NousResearch#27843). Gateway / messaging - `/platform list|pause|resume` full description + circuit-breaker semantics (PR NousResearch#26600). - Slack / Matrix / Mattermost get parallel `allowed_channels` / `allowed_rooms` allowlist sections matching Telegram/Discord/DingTalk (PR NousResearch#21251). - Discord `allow_any_attachment` + `max_attachment_bytes` (config and env vars) (PR NousResearch#27245). - Discord clarify-choice button rendering (PR NousResearch#25485). - Telegram `guest_mode` @mention bypass for allowlisted groups (PR NousResearch#22759). - Telegram `notifications` mode (`important` vs `all`) (PR NousResearch#22793). - `[[as_document]]` skill / response directive for forcing document-style media delivery (PR NousResearch#21210). CLI / TUI - `/new [name]` argument (PR NousResearch#19637). - `/subgoal` user-supplied criteria appended to `/goal` (PR NousResearch#25449). - `/exit --delete` flag confirmation prompts for destructive slash commands (PR NousResearch#22687). - Status-bar additions: ▶ N background indicator (PR NousResearch#27175), context compression count (PR NousResearch#21218), YOLO mode banner+statusbar warning (PR NousResearch#26238). - `display.timestamps` + `docker_extra_args` config keys (PR NousResearch#23599). - TUI collapsible startup banner sections (PR NousResearch#20625). - `HERMES_SESSION_ID` exported to tool subprocesses (PR NousResearch#23847). i18n - Refresh display.language locale list from 8 → 16 (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu) — matches `agent/i18n.py:SUPPORTED_LANGUAGES`. Tools / features - `vision_analyze` native-pixel passthrough for vision-capable callers, with auxiliary text-describer fallback (PR NousResearch#22955). - `session_search` rewrite to the single-shape tool (discovery / scroll / browse modes) (PRs NousResearch#27590 / NousResearch#27840). - Clarify MCP transport scope: client supports stdio + SSE; embedded `hermes mcp serve` is stdio-only (PR NousResearch#21227). - Web search backends table: add Brave Search (free tier) and DDGS rows (PR NousResearch#21337). - ACP session-scoped edit auto-approval modes (PR NousResearch#27862). - Curator rename map in the user-visible per-run summary (PR NousResearch#22910). - Prompt caching feature page reference in features/overview.md — Claude cross-session 1-hour prefix cache on native Anthropic / OpenRouter / Nous Portal (PR NousResearch#23828). - Cron per-job profile parameter (PR NousResearch#28124). - `--no-skills` flag for `hermes profile create` (PR NousResearch#20986). Build - Verified with `npm run build` in `website/`; both `en` and `zh-Hans` locales compile. Remaining broken-link/anchor warnings are pre-existing (`rl-training.md` from learning-path / overview; the zh-Hans translation lag the docs skill already calls out). #AI commit#
gweeteve
pushed a commit
to gweeteve/hermes-agent
that referenced
this pull request
Jun 2, 2026
…earch#28497) Catch the website docs up to two weeks of merged work (May 4 – May 18, 2026, roughly 1,080 PRs). The audit found ~50 user-visible features that had landed in code with no docs footprint, plus a handful of stale pages. This PR closes every gap the scan turned up. New pages - user-guide/features/deliverable-mode.md — extension list, agent triggers, kanban_complete artifacts pattern, [[as_document]] override (PR NousResearch#27813). - developer-guide/web-search-provider-plugin.md — authoring guide modeled on image-gen-provider-plugin, covering brave_free / ddgs / etc. (PR NousResearch#25448). Providers / auth - Rename "Alibaba Cloud" → "Qwen Cloud (Alibaba DashScope)" everywhere the display label shows up; provider id stays `alibaba` (PR NousResearch#24835). - Document OAuth refresh-token quarantine for xAI / MiniMax / Codex (PRs NousResearch#28116 / NousResearch#28118 / NousResearch#28119). - Document Nous JWT minting from refresh token + invalid-refresh quarantine + cross-profile shared token store (PRs NousResearch#27663 / NousResearch#19712). - Add `## Microsoft Entra ID authentication (keyless)` section to azure-foundry guide — DefaultAzureCredential, RBAC, OpenAI + Anthropic routing details (PR NousResearch#28101 / #9df9816da). - Custom providers `api_mode` is now prompted-and-persisted, not just URL autodetected (PR NousResearch#25068). - Delegation honours `api_mode` + auto-detects anthropic_messages base URLs (PR NousResearch#26824). - `x_search` auto-enables when xAI credentials are present (PR NousResearch#27376). - Add `xAI Grok OAuth (SuperGrok)` row to providers headline table (PR NousResearch#26534). - NVIDIA NIM billing-origin header is set automatically (PR NousResearch#26585). Windows / installer - `install.ps1`: document `-Commit <sha>` and `-Tag <v>` pin params plus the BOM-strip / git-retry hardening (PR NousResearch#28169). - Document Hermes Desktop thin installer + first-launch bootstrap (PR NousResearch#27822). - Document `dep_ensure` Windows bootstrap (PR NousResearch#27845). - Document install-method auto-detection (pip / git / homebrew / nixos) and the matching update command (PR NousResearch#27843). Gateway / messaging - `/platform list|pause|resume` full description + circuit-breaker semantics (PR NousResearch#26600). - Slack / Matrix / Mattermost get parallel `allowed_channels` / `allowed_rooms` allowlist sections matching Telegram/Discord/DingTalk (PR NousResearch#21251). - Discord `allow_any_attachment` + `max_attachment_bytes` (config and env vars) (PR NousResearch#27245). - Discord clarify-choice button rendering (PR NousResearch#25485). - Telegram `guest_mode` @mention bypass for allowlisted groups (PR NousResearch#22759). - Telegram `notifications` mode (`important` vs `all`) (PR NousResearch#22793). - `[[as_document]]` skill / response directive for forcing document-style media delivery (PR NousResearch#21210). CLI / TUI - `/new [name]` argument (PR NousResearch#19637). - `/subgoal` user-supplied criteria appended to `/goal` (PR NousResearch#25449). - `/exit --delete` flag confirmation prompts for destructive slash commands (PR NousResearch#22687). - Status-bar additions: ▶ N background indicator (PR NousResearch#27175), context compression count (PR NousResearch#21218), YOLO mode banner+statusbar warning (PR NousResearch#26238). - `display.timestamps` + `docker_extra_args` config keys (PR NousResearch#23599). - TUI collapsible startup banner sections (PR NousResearch#20625). - `HERMES_SESSION_ID` exported to tool subprocesses (PR NousResearch#23847). i18n - Refresh display.language locale list from 8 → 16 (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu) — matches `agent/i18n.py:SUPPORTED_LANGUAGES`. Tools / features - `vision_analyze` native-pixel passthrough for vision-capable callers, with auxiliary text-describer fallback (PR NousResearch#22955). - `session_search` rewrite to the single-shape tool (discovery / scroll / browse modes) (PRs NousResearch#27590 / NousResearch#27840). - Clarify MCP transport scope: client supports stdio + SSE; embedded `hermes mcp serve` is stdio-only (PR NousResearch#21227). - Web search backends table: add Brave Search (free tier) and DDGS rows (PR NousResearch#21337). - ACP session-scoped edit auto-approval modes (PR NousResearch#27862). - Curator rename map in the user-visible per-run summary (PR NousResearch#22910). - Prompt caching feature page reference in features/overview.md — Claude cross-session 1-hour prefix cache on native Anthropic / OpenRouter / Nous Portal (PR NousResearch#23828). - Cron per-job profile parameter (PR NousResearch#28124). - `--no-skills` flag for `hermes profile create` (PR NousResearch#20986). Build - Verified with `npm run build` in `website/`; both `en` and `zh-Hans` locales compile. Remaining broken-link/anchor warnings are pre-existing (`rl-training.md` from learning-path / overview; the zh-Hans translation lag the docs skill already calls out).
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.
Salvage of #25182 by @kshitijk4poor, cherry-picked onto current main.
What this PR makes true
All 7 web providers (brave-free, ddgs, searxng, exa, parallel, tavily, firecrawl) now live as bundled plugins under
plugins/web/<vendor>/and register throughagent.web_search_registryinstead of hardcodedif backend == ...chains. User-added web backends drop into~/.hermes/plugins/web/<name>/and appear inhermes toolsautomatically — same UX as image_gen and video_gen.Salvage notes
PR #25182 was branched before #25126 (the video_gen pluggable subsystem) landed, so direct merge would have wiped video_gen wiring from
hermes_cli/tools_config.pyandhermes_cli/plugins.py. Cherry-picked the 24 commits onto current main and resolved 3 conflicts to keep BOTH subsystems:hermes_cli/plugins.py—register_video_gen_provider(from main) +register_web_search_provider(from PR) both kept.hermes_cli/tools_config.py—_plugin_video_gen_providers()+_plugin_web_search_providers()both kept; the "Video Generation" and "Web Search + Extract" injection blocks in_visible_providerscoexist.Contributor authorship preserved on all 24 commits (rebase-merge required to keep it).
Validation
tests/plugins/web/+tests/tools/test_web*+tests/tools/test_website_policytests/plugins/image_gen/+tests/plugins/video_gen/+ video_gen surface matrixtests/hermes_cli/test_tools_config.pyE2E with isolated
HERMES_HOME:is_available()=Falsefor all; all 3 resolvers return None cleanly.TAVILY_API_KEYonly: all 3 capabilities resolve to tavily.tavilyconfigured with no key still returns tavily so the dispatcher emits a precise "TAVILY_API_KEY not set" error rather than silently switching).fal+xai— coexistence verified.Closes #25182.