Skip to content

feat(gateway): generic OIDC client-credentials relay provisioning (NAS-free) - #60730

Merged
benbarclay merged 2 commits into
mainfrom
feat/airgap-generic-cc-provision
Jul 8, 2026
Merged

feat(gateway): generic OIDC client-credentials relay provisioning (NAS-free)#60730
benbarclay merged 2 commits into
mainfrom
feat/airgap-generic-cc-provision

Conversation

@benbarclay

Copy link
Copy Markdown
Collaborator

What & why

Companion to the gateway-gateway OIDC tenant-identity PR. For air-gapped / self-hosted-IdP deployments with no Nous Portal, the gateway needs to present a caller-identity bearer the connector can introspect to a tenant — but today it can only resolve a Nous Portal token (resolve_nous_access_token()). This adds a generic OAuth2 client_credentials path so the gateway authenticates as a workload identity against the operator's own IdP (e.g. Microsoft Entra ID). The connector's OIDC tenant resolver reads a claim (default tid) off that token as the tenant.

Change

  • gateway/relay/__init__.py — new canonical _resolve_relay_identity_token(): does the client_credentials grant when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is configured, otherwise falls back to resolve_nous_access_token() (unchanged default). Wired into the runtime self_provision_relay() boot path.
  • hermes_cli/gateway_enroll.py_resolve_identity_token() now delegates to that canonical resolver, so the enroll CLI and the runtime self-provision path share one implementation.

Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml (env override GATEWAY_RELAY_IDP_*). No behaviour change when unset — existing Nous-hosted / self-hosted-with-Portal deployments are untouched.

Tests

  • tests/gateway/relay/test_identity_token_resolver.py (6): mode selection (Portal default vs IdP), client_credentials request shape, env-over-config precedence, and both fail-closed paths (missing client creds, no access_token in response).
  • Relay suite: 162 pass.

Live validation

Exercised via the cross-repo gateway↔connector live E2E (gateway_provision, gateway_managed_provision, gateway_inbound round-trip, gateway_link) against a connector running the OIDC tenant resolver with zero NAS config — all drivers pass.

Review notes / lane

Touches hermes_cli/gateway_enroll.py (relay/enroll) + gateway/relay/__init__.py (shared runtime). Flagging for a lane call on reviewer — happy to route to whoever owns the relay/enroll surface.

🚧 Do not merge without review — raising for review, not self-merging.

…S-free)

For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway
obtain its caller-identity bearer from a generic OAuth2 client_credentials grant
against the operator's own IdP (e.g. Microsoft Entra ID) instead of only
resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim
(default tid) off that token as the tenant.

- gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials
  when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal
  (unchanged default). Wired into self_provision_relay().
- hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical
  resolver so the enroll CLI and the runtime self-provision path share ONE impl.

Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml
(env override GATEWAY_RELAY_IDP_*). No behaviour change when unset.

Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection,
request shape, config/env precedence, fail-closed). Relay suite 162 pass.

Validated via the cross-repo gateway<->connector live E2E (provision, managed
self-provision, inbound round-trip, /link) against a connector running the OIDC
tenant resolver with zero NAS config.
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery comp/cli CLI entry point, hermes_cli/, setup wizard area/auth Authentication, OAuth, credential pools sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jul 8, 2026
@benbarclay
benbarclay merged commit f64e4f4 into main Jul 8, 2026
2 checks passed
@benbarclay
benbarclay deleted the feat/airgap-generic-cc-provision branch July 8, 2026 06:55
DaveVoyles added a commit to DaveVoyles/hermes-agent that referenced this pull request Jul 10, 2026
* feat(desktop): add UI scale setting to appearance settings

* chore(desktop): drop PR screenshot assets from tree

* fix(agent): add cross-turn stream-stale circuit breaker (#58962)

A session wedged against an unresponsive OpenAI-compatible provider can hit the stale-stream detector on every turn and loop forever, burning the full 180s x retries each turn with no response. Issue #58962 reports 494 consecutive failures over 3+ days on a single session.

The streaming retry path already caps retries WITHIN a turn (HERMES_STREAM_RETRIES, default 2) but has no cross-turn cap. Once a session's conversation state makes every turn stale, it retries indefinitely across turns and never notifies the user.

Add a per-session consecutive-stale-stream counter on the agent:
- incremented on every stale-stream kill in the outer poll loop;
- reset to 0 only when a stream actually completes;
- when it reaches HERMES_STREAM_STALE_GIVEUP (default 5), the next turn aborts immediately with a clear, actionable RuntimeError instead of spending 180s x retries again.

This is distinct from the existing stale-stream work (local-provider hard ceiling #44938, backoff/parse-error #60031): those bound a single hung stream, while this bounds repeated cross-turn staleness and surfaces a user-visible error.

Adds tests/run_agent/test_stream_stale_circuit_breaker.py covering the short-circuit, the success-reset, and the increment.

* fix: reset stream-stale breaker on model switch and fallback activation

Follow-up for the salvaged #60332 circuit breaker. The breaker latches:
once the streak trips, interruptible_streaming_api_call raises before any
stream is attempted, so the on-success reset can never run again. The
error text tells the user to switch models and retry — but neither
switch_model() nor try_activate_fallback() cleared the streak, so a
freshly selected healthy provider kept short-circuiting forever (only
/new recovered), and the automatic fallback chain was wedged the same way.

Reset the streak at both swap sites (after a successful rebuild only;
rollback/exhaustion paths keep the latch). 4 tests.

* docs: document HERMES_STREAM_STALE_GIVEUP alongside sibling stream knobs

* fix: widen stale circuit breaker to non-streaming path + all provider-swap resets

Review findings on the salvaged #60332 breaker, fixed as follow-ups:

- restore_primary_runtime() now resets the streak (third provider-swap
  path; without it a recovered primary was short-circuited before a
  single attempt and could never be re-proven healthy except via /model).
- interruptible_api_call (non-streaming) now carries the same breaker
  (guard at entry, bump on stale_call_kill, reset on success). Quiet-mode
  / subagent / headless sessions — the profile most like #58962's
  unattended 494-failure session — take this path and had the identical
  infinite stale-retry class.
- Partial-stream stub return now resets the streak (chunks were received,
  provider demonstrably responsive).
- Consolidated the triple-duplicated counter arithmetic into shared
  helpers (_stale_streak/_bump_stale_streak/_reset_stale_streak/
  _check_stale_giveup) with one canonical comment block; error message
  now says 'consecutive stale attempts' (the counter counts kills, not
  turns — a single turn can produce several).

4 new tests (restore resets / no-op restore keeps latch / non-streaming
short-circuit / non-streaming success reset).

* fix(bedrock): route non-Claude auxiliary models through Converse API

Auxiliary Bedrock resolution always used the Anthropic Bedrock SDK, which
only works for Claude foundation-model IDs. Non-Claude models such as
openai.gpt-oss-20b-1:0 now use a Bedrock Converse adapter, matching the
main agent's bedrock_converse transport.

* test(bedrock): cover auxiliary Converse routing for non-Claude models

Assert gpt-oss Bedrock IDs resolve to BedrockAuxiliaryClient while Claude
IDs keep the Anthropic SDK path, including async mode.

* fix: normalize string stop + surface dropped stream/tool_choice in Converse shim

Review findings on the salvaged shim: (a) OpenAI callers may pass stop as
a bare string but Converse's stopSequences requires a list — normalize;
(b) call_llm(stream=True) (MoA aggregator) can reach this client and the
shim silently returned a complete response — keep that behavior (the
streaming consumer's got-final-object path downgrades gracefully) but log
it, and log dropped tool_choice, instead of silently ignoring both.
+2 regression tests.

Follow-up to the salvage of #60217 by @xxxigm.

* feat(mem0): self-hosted dashboard backend + recall tuning (salvage #55614)

Salvage of #55614 by @kartik-mem0 (mem0 maintainer). Adds a SelfHostedBackend
that talks to a self-hosted Mem0 Docker server over httpx (X-API-Key auth,
/search + /memories routes), gated behind `host`. Also folds in the mem0
research-team recall tuning that rides with it: rerank defaults to false across
all modes, the mem0_list tool is removed (5->4 tools), search guidance is
de-shouted, and self-hosted get_all reports the true stored total (#52921).

Supersedes the self-hosted portion of #52487 (@liuhao1024, first-submitted).

Closes #52478
Fixes #52921

* fix(mem0): make prompt label + platform setup honor host routing precedence

Follow-up on the salvaged #55614. The PR added host-based routing to
_create_backend (precedence: oss > host > platform) but two sibling surfaces
didn't mirror it:

- system_prompt_block() checked host before oss, so an oss+host config ran
  OSS but told the model it was self-hosted HTTP. Reordered to match routing.
- Platform setup (hermes memory setup mem0 --mode platform) left a stale host
  in mem0.json; since host beats platform, the user kept routing to the
  self-hosted server. save_config merges (no delete), so clear host to ""
  rather than pop() so the merge actually overwrites it.

Adds regression tests for both (mutation-checked).

* fix(mem0): prune dead get_all, wire rerank config default, warn on MEM0_HOST env override

Review follow-ups on the salvage:

- get_all() pruned from the ABC and all three backends: mem0_list (its
  only caller) was removed by the recall-tuning commit, leaving new,
  tested, unreachable code — including SelfHostedBackend's _MAX_TOP_K
  over-fetch workaround. Tests for it dropped; fake-class stubs remain
  harmlessly. (The #52921 true-total fix lives on in the PR history if
  a lister ever returns.)
- The persisted rerank config key was write-only (setup prompted for it,
  nothing read it). initialize() now parses it into _rerank_default and
  mem0_search uses it when the model doesn't pass rerank explicitly;
  per-call args still win. Guard test added.
- Platform-mode setup now warns when MEM0_HOST is set in the environment:
  the json host-clear can't help there (_load_config seeds host from the
  env var, docs tell users to put it in .env) — the user would silently
  keep routing to the self-hosted server.
- SelfHostedBackend: connect-level retries (httpx.HTTPTransport(retries=2))
  so a single transient blip doesn't count toward the provider breaker;
  transport now injectable and the test helper uses the real __init__
  instead of mirroring it via __new__.
- plugin.yaml description no longer leads with reranking (off by default,
  platform-only); docs em-dash typo fixed.

* feat: add prompt-only session export

* feat(cli): add standalone HTML session export with sidebar navigation

Implements a professional, standalone HTML export feature for Hermes sessions.

Key changes:
- Adds 'hermes sessions export <file>.html' support to the CLI.
- Implements a dark-mode-first, responsive HTML generator in 'hermes_cli/session_export_html.py'.
- Single session export features a focused, centered 90% width layout.
- Multi-session export adds a fixed sidebar with session switching and real-time search filtering.
- ZERO external dependencies; all styles and JS are embedded for offline portability.

* feat(cli): include system prompts in HTML export

* feat(cli): redesign system prompt display as dedicated header section

* feat(cli): expand system prompt by default in HTML export

* fix(cli): fix layout width bug and ensure system prompt header is used

* style(export): restore width: 0 for multi-session flex layout

* feat(cli): filter internal session_meta messages from HTML export

* feat(sessions): wire html + prompt-only formats into 'sessions export'

Salvage follow-up integrating PR #30481 (@simplast) and PR #57683
(@catbearlove1-lang) into the unified export surface:

- --format html: standalone self-contained HTML transcript (single
  session or multi-session with sidebar), works with all shared filters
  and --redact; requires a file output path.
- --only user-prompts: prompt-only export (jsonl records or md sections)
  via the shared session_export renderer; the separate export-prompts
  subcommand from the original PR is subsumed by this flag.
- AUTHOR_MAP entries for both contributors; docs EN + zh-Hans.

* feat(discord): optionally mention approval owners on exec prompts

Opt-in discord.approval_mentions (config.yaml, bridged to
DISCORD_APPROVAL_MENTIONS) prepends <@id> mentions for numeric
allowlist entries to exec-approval prompts, with a scoped
AllowedMentions override (users only). Default off - no surprise
pings. Reapplied onto the content-mirror layout from #60245: mentions
prepend to the visible content block and its truncation budget.

Original implementation from PR #39719; commits arrived bot-authored,
re-attributed to the contributor.

* chore: add alex107ivanov to AUTHOR_MAP

* fix: restore cli-config.yaml.example from main (stale-branch version leaked into salvage)

* fix(web-server): close OAuth token TOCTOU by writing 0o600 atomically

`_save_anthropic_oauth_creds` wrote the Anthropic OAuth token file with
`os.replace(tmp, path)` followed by a post-hoc `chmod(0o600)`. Between the
rename and the chmod the token file existed at the default umask (0o644 on most
hosts) — a window in which another local user could read the access/refresh
tokens.

Write via `utils.atomic_json_write(..., mode=0o600)`, which creates the temp
with mode 0o600 *before* any content is written, fsyncs, atomically replaces,
preserves the existing file's owner, and cleans up its temp on failure. This
matches the `atomic_json_write(mode=0o600)` call already used elsewhere in this
module for the credential-pool write, and #56644's owner preservation.

Tests updated for the new mechanism, plus a check that the write goes through
`atomic_json_write(mode=0o600)` (mutation-verified).

* feat(mem0): add self-hosted mode to the setup wizard

The salvaged SelfHostedBackend made self-hosted servers reachable via
mem0.json / MEM0_HOST, but the setup wizard still offered only Platform
and OSS — exactly the gap users hit (Discord report: 'At memory setup
there's only 2 options'). Adds a third wizard mode:

- interactive picker: Platform / Self-hosted server / Open Source
- non-interactive: hermes memory setup mem0 --mode selfhosted
  --host http://... [--api-key ...] [--dry-run]
- host -> mem0.json (behavioral), API key -> .env as MEM0_API_KEY
  (secret), optional key for AUTH_DISABLED servers
- best-effort reachability check against the server, non-fatal
- README + memory-providers docs updated with the wizard path

* feat(sessions): trace export + HF upload via 'sessions export --format trace' (#60507)

* feat(trace): upload sessions to HF Agent Trace Viewer

Salvage trace upload as a smaller CLI-first feature: deterministic Claude Code JSONL export, fail-closed redaction, lazy Hugging Face dependency, and no gateway slash-command wiring.

* chore(trace): drop external porting references from docstrings

Describe the trace-upload design in Hermes' own terms.

* feat(sessions): fold trace upload into 'sessions export --format trace'

Integrates the HF Agent Trace Viewer exporter (PR #36145) onto the
unified export surface instead of a separate 'hermes trace' subcommand:

- --format trace: Claude Code JSONL to stdout/file, or one
  <id>.trace.jsonl per session for filtered bulk export; defaults to
  the most recent session when no --session-id/filters given.
- --upload pushes to the user's private HF traces dataset (--public to
  opt out of private); reads HF_TOKEN with guided setup when missing.
- traces are secret-redacted by default (force mode); --no-redact opts
  out after review; redaction failure blocks export (fail closed).
- hermes_cli/trace.py + subcommands/trace.py removed; agent/trace_upload.py
  is the single engine. Docs EN + zh-Hans; 4 new CLI tests.

* fix: limit desktop model pickers to explicit providers

* chore: map Ronald contributor email

* fix: harden explicit-provider gate for stale env-seeded pool entries + non-desktop picker opt-ins

Follow-up on the #56966 salvage:

- is_provider_explicitly_configured(): an env-seeded credential-pool entry
  only counts as explicit while its env var still resolves to a usable
  secret. A stale auth.json entry left behind after the user deletes the
  var no longer keeps the provider in the picker forever (#55790).
- TUI modelPicker + dashboard ModelPickerDialog/api.getModelOptions pass
  include_unconfigured=true explicitly, preserving their full-universe
  setup-affordance behavior now that the backend defaults to the
  configured subset.
- desktop lib/model-options.ts routes explicit_only through the shared
  requestModelOptions() helper (added on main after the PR branched).
- regression tests for ambient (gh_cli) pool sources, explicit manual/
  device-code sources, and stale vs live env-seeded entries.

* feat(plugins): pass approve rule keys to approval gate

* fix(approval): wire gateway notify round-trip into the plugin escalation gate

_run_approval_gate's gateway branch only queued via submit_pending, so
plugin-escalated approvals never sent the interactive embed+buttons on
Discord/Telegram/Slack (#59413) - the user was never notified and the
action stayed silently blocked. Mirror check_dangerous_command's path:
when a session notify callback is registered, run the blocking
_await_gateway_decision round-trip (redacted payload, once/session/
always persistence, deny/timeout produce definitive BLOCKED outcomes);
fall back to submit_pending only when no callback exists.

Fixes #59413.

* chore: add doncazper to AUTHOR_MAP

* feat(pty): RingBuffer for keep-alive output capture

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(pty): PtySession drain/attach/detach with EOF close 4410

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(pty): PtySessionRegistry with reap + capacity

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(chat): reattach /api/pty sessions via ?attach= token

Keep-alive path when ?attach=<token> is present: PTY outlives the socket
via PTY_REGISTRY, reattaches on reconnect. No token = unchanged legacy
pump (_legacy_pump). detach (not close) on disconnect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(pty): periodic reaper wired into dashboard lifespan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(chat): persist attach token, reconnect on transient close

ChatPage sends ?attach=<localStorage token> so /chat reattaches to its
live PTY across refresh. onclose: 4410=process-exit (session ended),
4409=superseded (quiet), else transient -> auto-reconnect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(mcp): reap orphaned subprocesses before spawning new ones on retry

When an MCP stdio subprocess fails to connect (token expiry, port
contention, timeout), the run() reconnect loop retries with backoff.
Each retry calls _run_stdio() which spawns a new process pair, but the
previous failed pair was only detected as orphaned (added to
_orphan_stdio_pids) — never actually killed.  This caused rapid zombie
accumulation: 5 failed attempts × 2 procs each = 10 orphans competing
for the same port.

Add a _kill_orphaned_mcp_children() call at the top of _run_stdio(),
before the _snapshot_child_pids() baseline, so any orphans from prior
failed attempts are reaped before a new subprocess is spawned.

Fixes #57355

* fix(mcp): reap stdio orphans before reconnect

* fix(mcp): unify reconnect orphan reaping + move off the event loop

Merge the two cherry-picked reap call sites into one unscoped sweep at
the top of _run_stdio (the unscoped sweep is a superset of the
per-server one), and run it via asyncio.to_thread so the 2s
SIGTERM->SIGKILL escalation cannot stall the shared MCP event loop.

* fix(mcp): bound stdio initialize handshake to stop subprocess/FD leak

A stdio MCP server that never completes `initialize` (e.g. emits a
non-JSON-RPC frame and then blocks on stdin) leaks a child process plus its
stdio pipes/pidfd on every discovery-retry cycle — unbounded, until the
gateway hits EMFILE and every new open()/spawn fails (#59349).

Root cause (confirmed by instrumenting the live repro, and different from the
issue's own hypothesis): the spawned child IS captured in `new_pids`, so the
report's "new_pids empty at finally" guess is not it. The real cause is that
`session.initialize()` hangs forever on the garbage stream. `connect_timeout`
only bounds the caller's `.result()` wait on the foreground thread — it does
NOT cancel the `_run_stdio` coroutine on the background MCP loop. So the
coroutine is stuck at `await session.initialize()` permanently, its cleanup
`finally` never runs, the child is never reaped, and it stays invisible to the
orphan-reaper (whose `_orphan_stdio_pids` set never gets populated).

Fix: wrap `session.initialize()` in `asyncio.wait_for(..., connect_timeout)`
so a stalled handshake fails instead of hanging. The TimeoutError unwinds
through the SDK context managers (closing the child's stdin -> EOF -> exit)
and lets the existing `finally` reap any straggler. Cross-platform — no
signals/pgid/proc.

Scope: stdio only. The HTTP path has the same `await session.initialize()`
shape but spawns no subprocess (so it can't cause this leak) and already has
httpx transport timeouts.

Verified: the reporter's repro goes from unbounded growth to draining to zero;
added a hermetic regression test (fake transport whose `initialize()` hangs,
asserts the connect is bounded by connect_timeout) that fails on the pre-fix
code and passes on the fix; 566 existing MCP tests pass; ruff clean.

Repro confirmed on macOS (pipe FDs); the Linux-specific pidfd growth in the
report should be equivalent — the reporter offered to validate on Linux.

Closes #59349

* fix(mcp): widen #59349 handshake bound to HTTP transports + cancel abandoned start() task

Sibling sites of the same bug class as the salvaged stdio fix:
- SSE, streamable-HTTP (new + deprecated API) initialize() calls are now
  bounded by the same connect_timeout, so an endpoint that accepts the
  connection but never answers the handshake cannot park the run() task
  forever.
- start() now cancels its ensure_future'd run() task when the caller's
  connect timeout cancels start() itself — the orphaned-task leak was
  the root mechanism behind #59349, and this closes the class for any
  future pre-ready hang.

* fix(mcp): reap orphaned stdio MCP children on ungraceful parent death

A stdio MCP server (e.g. `npx -y mcp-remote <url>`) is spawned as a direct
child of the Hermes process. Existing teardown (MCPServerTask.shutdown() /
_kill_orphaned_mcp_children()) reaps it correctly on a clean exit, but a
kill -9 / crash / force-quit of the Hermes process skips that path entirely
-- the child (and its own descendants, e.g. mcp-remote's spawned node
process) is orphaned and keeps running. Repeated ungraceful restarts pile up
N orphaned processes racing to hold the same upstream SSE session, producing
errors like 'Invalid request parameters' on legitimate reconnects.

macOS/Linux have no portable equivalent of prctl(PR_SET_PDEATHSIG) at the
Python subprocess level, so this adds a thin supervisor
(tools/mcp_stdio_watchdog.py) that:
  - execs the real command as its own child in its own process group
  - passes stdin/stdout/stderr through untouched (MCP stdio protocol
    talks directly over those streams)
  - polls the original spawning PID with the same orphan-detection
    algorithm already proven in tui_gateway/slash_worker.py (ppid
    comparison + psutil creation-time guard against PID reuse)
  - SIGTERM-then-SIGKILL's the child's process group the moment the
    original parent is gone

Wired into _run_stdio via a new _wrap_command_with_watchdog() helper,
POSIX-only (matches the existing killpg-based cleanup's platform scope),
fails open (any error resolving pid/create-time falls back to the
unwrapped command) so this can never be the reason a working MCP server
stops starting.

Verified: reproduced the exact orphan scenario standalone (fake parent
process spawns watchdog + fake long-running MCP child, kill -9 the fake
parent, confirm the watchdog reaps the child within its poll window with
zero leaked processes). Updated test_mcp_tool_issue_948.py's resolved-path
assertion to check the watchdog-wrapped command instead of the raw
resolved binary. Full test_mcp_tool.py + test_mcp_stability.py +
test_mcp_tool_issue_948.py suite: 232 passed. Full -k mcp sweep across the
whole test tree: 1003 passed, 2 skipped, 0 failed.

* fix(mcp): watchdog wrap after OSV preflight + forward SIGTERM to child group

Two fixes on top of the salvaged parent-death watchdog:
- Apply the watchdog wrap AFTER the OSV malware preflight so the check
  inspects the real npx/uvx package instead of the python wrapper
  (the wrap previously made the preflight a silent no-op for every
  stdio server).
- The real server runs in its own process group under the watchdog, so
  the graceful-shutdown killpg no longer reached it; the watchdog now
  forwards SIGTERM/SIGINT to the child's group, keeping wedged servers
  killable on clean shutdown.

* Recycle idle MCP stdio servers

* Handle minimal MCP server fakes

* chore(release): map rainbowgore + thestudionorth in AUTHOR_MAP for MCP leak salvages

* docs(mcp): document idle_timeout_seconds / max_lifetime_seconds recycle keys + handshake-bound note

* test(mcp): unblock recycle-reconnect test from the parked self-probe wait

The salvaged test predates the parked-server self-probe
(_PARKED_RETRY_INTERVAL, landed on main after the PR branched): after the
final failed retry, run() parks in a real asyncio.wait that the patched
asyncio.sleep doesn't cover, stalling the test 300s. Signal shutdown once
the retry budget is exhausted so the park exits immediately.

* fix(mcp): guard POSIX-only kill primitives in stdio watchdog for the Windows footgun linter

signal.SIGKILL / os.killpg don't exist on Windows. The watchdog is only
spawned on POSIX (wrap site gates on os.name), but guard via getattr with
a plain terminate/kill fallback so an accidental Windows import can't
AttributeError.

* feat(dashboard): report profile + gateway topology in /api/status (#60537)

/api/status (loopback/insecure binds only) now includes:
- profiles: every profile on the host (default + named)
- gateway_mode: none | single | multiple | multiplex
- gateways: one entry per live gateway with the host ports its
  port-binding platforms listen on, plus served_profiles when the
  default gateway is multiplexing

Ports resolve from each profile's config.yaml (top-level platforms:
wins over gateway.platforms:, matching load_gateway_config precedence)
with adapter defaults as fallback. Topology enumeration runs in an
executor so the profile scan + process-table probes stay off the event
loop, and the whole block is gated behind the same loopback-only split
as hermes_home/gateway_pid so gated binds leak nothing new.

* fix(tools): enable platform-native toolsets when their composite is explicitly configured (#35527)

When a user explicitly configures a platform with its native composite
(e.g. platform_toolsets.discord: [hermes-discord]), the discord and
discord_admin toolsets were silently stripped by _DEFAULT_OFF_TOOLSETS
even though the composite contains those tools. The strip could not tell
an explicit composite opt-in apart from the unconfigured default.

Track whether the platform was explicitly configured and, when it was,
exempt toolsets that are both default-off and platform-restricted to the
current platform from the strip. Only discord/discord_admin are affected
(the sole entries in both _DEFAULT_OFF_TOOLSETS and
_TOOLSET_PLATFORM_RESTRICTIONS). Unconfigured and empty-list platforms
keep the security default-off behaviour.

* docs(sessions): unify export docs under one overview section (#60554)

Restructures the five parallel export sections into a single 'Export
Sessions' section: a format table (jsonl/md/qmd/html/trace + --only
user-prompts), one shared-filters paragraph covering all formats, and
per-format subsections nested beneath. EN + zh-Hans.

* fix(discord): honor pairing grants for message auth

* fix(discord): explain fail-closed allowlist default

Log a one-shot structured warning when Discord denies traffic because
no allowlist/policy is configured, and correct the setup wizard's
inverted warning text. The fail-closed default itself is unchanged.

Fixes #58682.

* docs(discord): troubleshoot silent fail-closed denials

Docs portion of PR #57067: 'bot connects but never replies' section
pointing at the gateway.log warning and the allowlist/policy knobs.

Co-authored-by: ooovenenoso <120500656+ooovenenoso@users.noreply.github.com>

* fix(gateway): only session-discover channel targets for connected platforms (#60574)

Session-based channel discovery resurrected historical origins for
platforms with no connected adapter, exposing stale send_message
targets that can no longer deliver. Gate both the enum loop and the
plugin-registry loop on the live adapter set.

Surgical reapply of the channel-directory portion of PR #25959 (branch
was 6.5k commits stale; the text-batching delay changes bundled there
were dropped - separate concern, defaults have since been retuned on
main).

Co-authored-by: Marco-Olivier Lavoie <marcolivier@gmail.com>

* Fix delegation config precedence

* Use read-only config loader and honor HERMES_IGNORE_USER_CONFIG in delegation config

* fix(dashboard): advertise truecolor to the embedded chat TUI (#60576)

Headless/hosted deploys run the dashboard server without COLORTERM in
the process environment, so chalk inside the PTY-spawned TUI child
downgraded every skin hex color to the xterm 256 palette — the default
skin's bronze banner border (#CD7F32) snapped to palette 173 (#D7875F,
salmon red) and the gold caduceus rendered red/yellow on fresh cloud
instances. Local launches never reproduced it because the operator's
interactive terminal leaks COLORTERM=truecolor into the server env.

xterm.js always renders 24-bit RGB, so the dashboard PTY child should
always advertise truecolor: backfill COLORTERM=truecolor in
_resolve_chat_argv via setdefault (an explicit operator value wins).

Verified with a clean-env PTY probe of the real TUI binary:
no COLORTERM -> 0 truecolor SGRs / 165 palette-256 (salmon 38;5;173);
with the backfill -> 166 truecolor SGRs, exact bronze 38;2;205;127;50.

* feat(dashboard): expose profile names + gateway_mode on gated /api/status (#60585)

The profile+gateway topology added in #60537 sits entirely behind the
loopback/--insecure auth gate. But a hosted agent (Hermes Cloud) binds
non-loopback with OAuth, so should_require_auth is True, and NAS reads
/api/status over the network (fly-provider.ts getInstanceRuntimeStatus)
with no session token. On that gated path the whole topology block was
omitted, so the Portal could never render the profile list.

Split the topology readout by sensitivity:
- profile NAMES (profiles) + gateway_mode are low-sensitivity product
  surface and now ride the always-public status body, surviving the auth
  gate so NAS/the Portal can enumerate profiles.
- the per-gateway detail (gateways[], carrying host ports) is deployment
  recon and stays gated alongside hermes_home / config_path / env_path /
  gateway_pid / gateway_health_url.

The collector now runs unconditionally (still in the executor, off the
event loop). No new fields; only the gate placement changes.

* feat(relay): carry routed profile from the connector wire source (#60586)

The multiplex machinery already routes an inbound message to a profile via
SessionSource.profile (build_session_key namespacing + the per-turn
config/credential scope in SessionStore._resolve_profile_for_key). But the
relay path never populated it: _event_from_wire rebuilt the SessionSource
field-by-field and dropped any 'profile' the connector sent, so a
Team-Gateway (connector + relay) message could not be routed to a specific
profile the way the /p/<profile>/ HTTP prefix and per-credential polling
adapters already can.

Stamp source.profile from the wire payload in _event_from_wire. This is the
last missing link for NAS-driven per-profile routing over the relay in
multiplex mode; the connector populating the field ships separately
(gateway-gateway contract adds the optional wire field).

Back-compat: absent 'profile' → None → legacy agent:main namespace,
byte-identical to today for every single-profile gateway.

* Add dashboard memory provider switching

* fix: validate memory provider names before filesystem lookup and setup commands

Strict charset allowlist (alnum + - _, max 64) on the {name} path param of
the memory-provider config/setup endpoints. Prevents traversal-shaped names
from reaching find_provider_dir(), and setup now 404s when neither a
loadable provider nor a plugin manifest exists, so the command-running path
is only reachable for discoverable plugins. Adds regression tests.

* feat(gateway): GATEWAY_MULTIPLEX_PROFILES env override for multiplex flag (#60589)

The connector now depends on the single multiplexed gateway for per-profile
relay routing, so hosted deployments need to FORCE multiplexing on regardless
of the image's config.yaml. gateway.multiplex_profiles was config.yaml-only,
which a user could leave unset or flip off.

Add GATEWAY_MULTIPLEX_PROFILES as a standard operator override on top of the
existing config key — the same 'config.yaml is canonical, env is the operator
override' pattern the Telegram/Signal require_mention bridges use:

  env (recognized token) > config.yaml (top-level or nested gateway.*) > False

- gateway/config.py: _env_multiplex_profiles_override() resolves the env var
  tri-state — recognized truthy/falsy token → bool; unset/blank/unrecognized
  → None (fall through to config). Blank is deliberately None, not False, so a
  provisioned-but-unpopulated Fly secret ('') can't shadow a config.yaml opt-in
  (the empty-secret trap). Wired into GatewayConfig.from_dict so every consumer
  (run.py, session.py via self.config) sees the resolved value.
- hermes_cli/gateway.py: the named-profile-start guard
  (_guard_named_profile_under_multiplexer) reads config.yaml directly, so it
  gets the SAME env precedence — otherwise env-forced multiplex would leave the
  guard blind and someone could start a conflicting per-profile gateway that
  double-binds a bot token. Env-forced-on trips the guard even with no
  config.yaml key; env-forced-off disables it over a config opt-in.

Tests: full 3-tier precedence in test_config.py (incl. the discriminating
env-overrides-config cases + the empty/whitespace/unrecognized fall-through
trap + resolver tri-state), mutation-verified (flipping precedence fails
exactly the two env-wins tests); guard env cases in test_multiplex_lifecycle.py.

Force-on is safe on a single-profile instance: session keys stay byte-identical
(agent:main) and the _run_agent wrapper installs the per-turn secret scope, so
the fail-closed get_secret() path is satisfied.

* Fix dashboard chat model profile scoping

* fix: pass profile-scoped SessionDB to _session_latest_descendant in dashboard chat PTY resume

The chat PTY launch path landed on main after PR #50558 and still called
_session_latest_descendant() with the old one-arg signature. Open the
requested profile's state DB (matching the REST endpoint) so profile-scoped
resume resolves descendants in the right database.

* Add WhatsApp dashboard pairing flow

* chore: release v0.18.1 (2026.7.7) (#60595)

* fix(whatsapp): unpin Baileys from git commit, use published 7.0.0-rc13 (#60643)

The April 2026 pin to WhiskeySockets/Baileys#01047deb existed only to
pick up the abprops bad-request fix (Baileys PR #2473) before it was
released. That fix shipped in v7.0.0-rc11 (May 2026); our pinned commit
is now 48 commits behind rc13.

The git pin forced npm to clone the repo and compile Baileys from
TypeScript source on every fresh install (~3 min), which blew past the
dashboard pairing flow's timeout. Registry install takes ~3s.

Validation: all 9 bridge.js imports present in rc13, bridge.native.test.mjs
passes (13/13), live bridge boot renders pairing QR against real WA servers.

* chore: release v0.18.2 (2026.7.7.2) (#60651)

* feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop) (#57225)

* feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop)

pip and Homebrew are now Unsupported install methods per
website/docs/getting-started/platform-support.md. Surface a
warn-don't-block deprecation notice everywhere the install method is
already shown, pointing at the platform-support docs and noting these
installs will not receive further updates. NixOS (Tier 2) is untouched.

- hermes_cli/config.py: shared is_unsupported_install_method() /
  format_unsupported_install_warning() helpers so the wording and docs
  link stay consistent across every surface.
- hermes_cli/banner.py: generalize the existing pip-only banner
  warning to also cover Homebrew.
- hermes_cli/main.py: hermes update and hermes update --check print
  the warning before proceeding (still update; warn, don't block).
- tui_gateway/server.py: session.info gains install_warning.
- ui-tui: SessionPanel renders install_warning alongside the existing
  'N commits behind' notice.
- apps/desktop: SessionRuntimeInfo/GatewayEventPayload gain
  install_warning; applyRuntimeInfo + the live session.info event fire
  a snoozable warning toast via a new reportInstallMethodWarning(),
  mirroring the existing backend-contract-skew toast pattern. i18n
  strings added for en/zh/zh-hant/ja.
- Tests: updated pip banner assertions for the new wording, added a
  Homebrew banner test, and two tui_gateway session_info tests
  (install_warning present for pip, absent for git).

* fix(nix): make `hermes` in developement environment actually work

install modules as editable overlay with uv

* feat: print install method when running --version

* fix: correct detect install method when running from a subtree

* Fail closed on invalid JSON/YAML/TOML writes instead of writing then reporting

write_file() previously called _atomic_write() first and only ran the
JSON/YAML/TOML/Python syntax check afterward as an informational lint
delta -- a parse failure never set the top-level `error` key, so a
corrupt structured-data write still landed on disk (and file_tools.py's
files_modified gating, which keys off `error`, silently reported it as
a successful modification).

Move the in-process syntax check for JSON/YAML/TOML ahead of
_atomic_write() and refuse the write outright on a parse failure: no
temp file, no rename, nothing touches disk, and the result carries a
top-level `error` so callers correctly see it as unmodified.

Deliberately scoped to _FAIL_CLOSED_INPROC_EXTS (JSON/YAML/TOML), not
all of LINTERS_INPROC -- .py is excluded because this codebase's own
test fixtures (TestPatchReplacePostWriteVerification et al.) write
arbitrary non-Python text through *.py paths purely to exercise
write-mechanics; a hard block there broke 3 previously-passing tests
during development. Python keeps its pre-existing non-blocking
lint-delta report.

Adds tests/tools/test_write_file_syntax_gate.py: invalid JSON/YAML/YML/
TOML refused with nothing written (new file) and nothing modified
(existing file); valid JSON/YAML still written byte-for-byte; a
non-linted extension with garbage content is unaffected; invalid Python
is confirmed NOT hard-refused (still just reported).

* fix(tools): make the YAML write gate syntax-only so multi-doc/tagged YAML isn't refused

safe_load() raises ComposerError on multi-document streams (k8s manifests)
and ConstructorError on application-defined tags (CloudFormation !Sub,
Ansible !vault) — both valid YAML syntax. Now that the linter's verdict is
a fail-closed write gate, those false positives would refuse legitimate
writes outright. Switch to yaml.parse() (scanner+parser only), which still
catches real syntax failures.

* chore: add AUTHOR_MAP entry for neoguyverx (PR #60526 salvage)

* fix(gateway): drain in-flight cron jobs before shutdown tool kill

/update and other shutdown paths only waited on gateway session agents,
so active cron tool work was killed immediately in final-cleanup while
the scheduler could still mark the job successful (#60432).

* test(gateway): cover cron drain during gateway shutdown (#60432)

* fix(gateway,cron): make shutdown drain visible to in-flight cron work

Cron jobs run through cron/scheduler.py's own ThreadPoolExecutor via a
standalone AIAgent (run_job/run_one_job), entirely outside
GatewayRunner._running_agents -- the dict _drain_active_agents() and
every other active-work check on that class reads. A gateway shutdown
(/update, /restart, and SIGUSR1 all funnel through the same stop())
could log active_at_start=0 and immediately kill tool subprocesses
while a cron job's terminal command was still running, with no wait
and no indication anything was interrupted.

Real-world impact (from the issue): a scheduled daily briefing cron
job was in flight during /update, its tool subprocess got killed
by the unconditional shutdown cleanup, and the job was never marked
failed -- it simply never completed or delivered, with no error
surfaced anywhere. A repro with a 30-minute `sleep` cron job in flight
during /update reproduced the same pattern: subprocess killed at
+0.22s of drain (active_at_start=0), the job's agent thread continued
in-process and produced a plausible-looking final response from the
truncated tool output, and the scheduler marked the run successful.

Root cause is layered, not a single line:

1. GatewayRunner._drain_active_agents() only waits on _running_agents.
   Cron work was invisible to it, so drain returned instantly whenever
   the only active work was a cron job.
2. Even with visibility, the shutdown's final tool-subprocess kill
   (process_registry.kill_all()) is a global, unconditional sweep with
   no per-job targeting -- a long-running cron job that outlives the
   drain timeout still gets its subprocess killed.
3. cron/scheduler.py had no way to detect that a job's tool subprocess
   was killed out from under it mid-run; the agent thread kept going
   and its eventual (often degraded but plausible-looking) response
   got reported as a normal successful completion.

Fix, three parts:

- cron/scheduler.py: expose get_running_job_ids() (thread-safe
  snapshot of the existing _running_job_ids set, already used to
  prevent double-dispatch) so the gateway can read cron's in-flight
  state without reaching into private module internals.

- gateway/run.py: GatewayRunner._active_cron_job_count() reads that
  snapshot. _drain_active_agents() now waits on
  (_running_agents OR active cron jobs), so a cron-only workload gets
  the same bounded wait chat sessions already get instead of an
  instant active_at_start=0. Shutdown drain logging gains
  cron_active_at_start/cron_active_now fields alongside the existing
  ones (unchanged, for compat).

- cron/scheduler.py: mark_running_jobs_interrupted(reason), called by
  gateway/run.py's _kill_tool_subprocesses() right after
  process_registry.kill_all(), marks every job still in
  _running_job_ids at that instant as failed/interrupted via the
  existing mark_job_run() -- and records the job IDs in
  _interrupted_job_ids BEFORE writing, so run_one_job()'s own
  eventual completion for the same run (racing in its own thread)
  checks that flag and skips its normal write instead of clobbering
  the interrupted status with a false "ok" produced from the
  now-truncated tool output. This does not attempt to correlate a
  killed PID to a specific job ID (process_registry tracks PIDs, not
  job IDs) -- any job still dispatched at the moment of a forced kill
  is treated as interrupted, matching the existing coarser precedent
  set by _interrupt_running_agents(), which interrupts every entry in
  _running_agents on a drain timeout without per-agent correlation
  either.

Deliberately out of scope (flagged in the issue as a separate,
lower-priority concern): startup-time reconciliation of cron runs that
started but never reached a terminal status.

Testing:

- tests/cron/test_shutdown_interrupt.py (12 tests): get_running_job_ids
  snapshot semantics, mark_running_jobs_interrupted marking/no-op/
  partial-failure behavior, and -- the core race guard -- run_one_job
  skipping its own last_status write (both the success path and the
  exception path) when the shutdown path already marked the run
  interrupted, with a control test proving ordinary un-interrupted
  completions are unaffected.

- tests/gateway/test_cron_active_work_drain.py (9 tests):
  _active_cron_job_count reading cron state and failing closed (0) if
  the cron module is unavailable; _drain_active_agents waiting for an
  in-flight cron job the same way it waits for chat sessions, timing
  out if the job outruns the window, and leaving existing chat-session
  drain behavior unchanged; a full runner.stop() integration test
  (drain-timeout path) proving mark_running_jobs_interrupted actually
  fires with the right job ID when a tool subprocess is force-killed,
  plus a no-op control when nothing cron-related is in flight.

- tests/gateway/test_shutdown_cache_cleanup.py: added
  _active_cron_job_count() to that file's hand-rolled _FakeGateway test
  double, which stop() now calls -- without it those 8 pre-existing
  tests AttributeError (caught by fail-then-pass below, not a
  production bug).

Fail-then-pass: reverted gateway/run.py + cron/scheduler.py, all 21
new tests fail (fixture/attribute errors -- the feature doesn't exist
yet); restored, all 21 pass.

Regression check: ran the full plausibly-affected surface --
tests/gateway/{test_gateway_shutdown,test_restart_drain,
test_restart_notification,test_restart_redelivery_dedup,
test_restart_resume_pending,test_restart_service_detection,
test_shutdown_cache_cleanup,test_stuck_loop,test_clean_shutdown_marker,
test_external_drain_control,test_session_state_cleanup,
test_update_command,test_update_streaming}.py plus tests/cron/ (944
tests) -- against a clean upstream/main checkout and against this
branch. Diffed the two FAILED lists: identical, 20 pre-existing
failures on both sides (Windows-locale/cp1252 file-encoding issues and
Unix-permission-bit assertions that don't apply on this Windows dev
box), zero new failures, zero fixed-by-accident. The 8
test_shutdown_cache_cleanup.py failures found mid-development were
from the _FakeGateway gap above, fixed in the same commit and
confirmed clean on the final rerun (diff against baseline: exit 0).

Fixes #60432

* fix(cron): stop interrupted jobs from delivering their pre-kill output

Follow-up to the previous commit on #60432. The status-write guard
(_consume_interrupted_flag, checked right before mark_job_run) closes
the false-success bookkeeping gap, but run_one_job delivers its result
BEFORE that check: delivery happens right after run_job() returns,
mark_job_run happens at the very end. A job whose tool subprocess was
killed mid-flight can still produce a plausible-looking final_response
from the truncated output, and that response would reach the user via
_deliver_result before the interrupted flag was ever consulted --
correct status in jobs.json, wrong message already sent.

Adds _is_interrupted(), a non-destructive peek at the same
_interrupted_job_ids set (_consume_interrupted_flag stays as the
consuming, authoritative check right before the status write -- this
needed a peek instead since the flag has to still be visible there).
Checked right after save_job_output, before the deliver_content
decision: if the run looked successful but was flagged interrupted,
force success=False with an explicit interruption message. This
routes delivery through the existing _summarize_cron_failure_for_delivery
path (the same one a real failure already uses) instead of the raw
final_response, so the user gets an honest "this run was interrupted"
instead of a truncated/misleading result.

Testing: 4 new tests in tests/cron/test_shutdown_interrupt.py --
_is_interrupted peek semantics (false/true/does-not-clear, as opposed
to the consuming _consume_interrupted_flag), and the delivery-gate
test itself, which mocks run_job to return a normal-looking success
with a "plausible final response" while the job is pre-marked
interrupted, and asserts _deliver_result receives the failure summary
("This run was interrupted.") instead, with the summarizer's error
argument confirmed to mention the interruption.

Fail-then-pass: reverted cron/scheduler.py only, the 4 new tests fail
(3 on the missing _is_interrupted attribute, 1 -- the delivery-gate
test -- on _summarize_cron_failure_for_delivery never being called,
i.e. the raw response would have gone out); restored, all 16 tests in
the file pass.

Regression: tests/cron/ (683 tests) + test_cron_active_work_drain.py +
test_gateway_shutdown.py + test_shutdown_cache_cleanup.py -- 11
pre-existing failures (Unix file-permission-bit and path-tilde
assertions that don't apply on this Windows dev box), matching the
same set already established as pre-existing in the prior commit's
regression check. Zero new failures.

Continues #60432

* fix(gateway,cron): reconcile #60612 + #60631 onto one drain surface

Keep #60631's get_running_job_ids() snapshot + _active_cron_job_count()
(import-guarded for minimal test doubles) as the single read path, and
retarget #60612's drain tests at it. Drops the redundant
cron_jobs_in_flight() helper so there is one surface, not two.

* fix(tui): prevent ws_orphan_reap from ending gateway-originated sessions

Guard _finalize_session's db.end_session() call against gateway-owned
sessions (telegram, bluebubbles, discord, etc.).  The TUI is a viewer
for these sessions, not the lifecycle owner.  Unconditionally ending
them in state.db creates a Groundhog Day routing loop: the gateway's
#54878 self-heal detects the stale entry, recovers to the parent
session, context compression splits back to the reaped child, and the
cycle repeats on every inbound message — causing complete conversational
context amnesia.

Fixes #60609

* fix(tui): derive gateway-owned sources from the Platform enum, not a hardcoded list

The salvaged guard used a hand-maintained frozenset of 14 platform names —
several of which (line, wechat, facebook, imessage, googlechat) aren't
actual Hermes Platform values, while real ones (whatsapp_cloud, feishu,
wecom, dingtalk, qqbot, yuanbao, plugin platforms like irc) were missing.
Resolve the source through gateway.config.Platform instead (built-ins +
registered plugin platforms via _missing_), with an explicit exclusion set
for self-owned/local sources. Adds tests for the guard and both reap paths.

* feat(gateway): generic OIDC client-credentials relay provisioning (NAS-free) (#60730)

For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway
obtain its caller-identity bearer from a generic OAuth2 client_credentials grant
against the operator's own IdP (e.g. Microsoft Entra ID) instead of only
resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim
(default tid) off that token as the tenant.

- gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials
  when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal
  (unchanged default). Wired into self_provision_relay().
- hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical
  resolver so the enroll CLI and the runtime self-provision path share ONE impl.

Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml
(env override GATEWAY_RELAY_IDP_*). No behaviour change when unset.

Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection,
request shape, config/env precedence, fail-closed). Relay suite 162 pass.

Validated via the cross-repo gateway<->connector live E2E (provision, managed
self-provision, inbound round-trip, /link) against a connector running the OIDC
tenant resolver with zero NAS config.

* Fix slow Z.AI startup by caching auto-detected endpoint to disk

(cherry picked from commit 6ed884933a178d5540f02d80e3fe9e678ca844eb)

* chore: add veradim to AUTHOR_MAP for PR #41201 salvage

* fix: don't flip active_provider when caching Z.AI probe result

_save_provider_state() sets auth_store['active_provider'] as a side effect.
The Z.AI endpoint probe runs from credential-pool env seeding for any user
with a Z.AI key in env — persisting the probe cache must not silently make
zai the active provider. Use _store_provider_state(set_active=False).

Follow-up to PR #41201 salvage.

* fix: Z.AI endpoint persist failure must not break URL resolution

Review findings (hermes-pr-review Phase 2, 3-angle):
- _save_auth_store() does real filesystem I/O (mkdir, O_EXCL create, fsync,
  atomic replace) and can raise on disk-full/permissions/lock-timeout. The
  persist ran bare in the success path, so a persist failure aborted
  _resolve_zai_base_url() after detection had already succeeded. Wrap the
  persist in try/except: log a warning and still return the detected URL
  (worst case: next start re-probes).
- Readability: stage the payload in a local detected_endpoint instead of
  writing through the stale pre-lock 'state' dict, which is no longer what
  gets persisted.

* fix(cron): stop the ticker from stalling forever on a wedged jobs lock (#60703) (#60855)

Three fixes for the silent post-restart ticker stall:

1. _jobs_lock() bounds its cross-process flock: LOCK_NB polled against a
   30s deadline instead of an unbounded LOCK_EX taken while holding the
   process-wide RLock. On timeout it logs at ERROR and degrades to
   in-process-only locking (the existing fallback path), so a sibling
   process wedged while holding .jobs.lock can no longer freeze every
   cron function - including the ticker's get_due_jobs() and thus the
   heartbeat - forever with zero logging.

2. fire_claim/run_claim freshness checks are bounded on both sides
   (0 <= age < ttl): a claim stamped in the future (clock/TZ skew across
   a restart) was previously fresh forever, making the job permanently
   unfireable and every manual run report 'already being fired'.

3. _execute_job_now distinguishes paused/disabled/missing jobs from a
   genuinely held claim instead of mislabeling them all as 'already
   being fired'.

* fix(tui_gateway): back off notification poller when session is busy

The busy-session branch of _notification_poller_loop re-queued the
completion event and immediately re-polled it with no sleep, spinning
at full speed (100% CPU, ~1100 futex/s of GIL churn) for as long as
the session stayed running. This starved the dashboard asyncio loop:
/api/status went from 0.14s to 3-6s with 10s timeouts.

Sleep 0.25s outside history_lock before re-polling, mirroring the
0.1s back-off already used for foreign-session events.

* chore: add SiteupAgencia to AUTHOR_MAP for #57435 salvage

* test(tools): add unit tests for skill_gist

* fix(agent): tag desktop chat sessions as desktop

The desktop app's chat panel reuses tui_gateway as its backend, so every chat session was stamped platform="tui". That made the agent read terminal-specific platform guidance while running in the graphical desktop chat surface.

Resolve the misclassification at its source: tui_gateway now picks platform="desktop" when HERMES_DESKTOP=1 and HERMES_DESKTOP_TERMINAL is unset, and keeps platform="tui" for the embedded terminal pane and standalone TUI. Add a PLATFORM_HINTS["desktop"] entry describing the actual chat surface (full GFM markdown, MEDIA: intercept, inline images). Move the embedded-pane clarifier to the platform-hint resolution site so it appends only to the tui hint under HERMES_DESKTOP_TERMINAL=1. Delete the now-dead desktop-hint block from build_environment_hints() that competed with the platform hint.

Standalone TUI sessions produce byte-identical prompts as before; the new desktop hint and clarifier are assembled once per session in the stable tier, so prompt caching is preserved.

* fix(delegation): route async results to origin session

Carry the live TUI session id with async delegation completion events and prefer the commissioning UI session when desktop pollers share the completion queue. Resolve compressed session keys to their continuation before treating events as orphaned, and capture the live parent agent session id for TUI/ACP dispatch.

* fix(delegation): fail-closed orphan handling + session-scoped delegation lifecycle

Two invariants layered on the origin-routing commit (#55578):

1. Fail closed on orphaned async-delegation payloads. The poller's
   belongs-elsewhere check handles events owned by another LIVE session,
   but an event whose owner is gone previously fell through and was
   adopted by whichever poller saw it - injecting one chat's delegation
   output into another chat. Delegation completions are now injected
   only into a session that PROVABLY owns them (origin UI id, or
   session-key/lineage match via the compression chain); unowned
   payloads are dropped from injection with a WARNING (the subagent's
   output is already persisted in the delegation records, so nothing is
   lost). The shutdown drain applies the same rule. Non-delegation
   events keep the historical adopt-orphans behavior.

2. A session's in-flight async delegations end with the session.
   _finalize_session now calls interrupt_for_session(): delegations
   commissioned by the closing UI session are interrupted always;
   key-matched delegations only when the TUI owns the session lifecycle,
   so closing a viewer tab on a live gateway session never kills the
   gateway's own background work.

* feat(models): swap curated Tencent Hy3 Preview for GA tencent/hy3, drop owl-alpha (#60943)

- OPENROUTER_MODELS: remove openrouter/owl-alpha (free) and
  tencent/hy3-preview{,:free}; add tencent/hy3 and tencent/hy3:free
- _PROVIDER_MODELS[nous]: tencent/hy3-preview -> tencent/hy3
- run_agent.py reasoning-prefix list: tencent/hy3-preview -> tencent/hy3
  (prefix match still covers -preview if pinned)
- model_metadata: register hy3 context length (262144) alongside hy3-preview
- regenerate website/static/api/model-catalog.json
- update tokenhub curated-list tests to the new IDs

The tencent-tokenhub direct provider still serves hy3-preview and is
intentionally unchanged.

* docs(i18n): align translated CONTRIBUTING files with pyproject Python range (3.11-3.13)

* docs(i18n): align translated CONTRIBUTING files with pyproject Python range (3.11-3.13)

* fix(delegation): route async delegate_task results back to originating session

The completion event already carries the dispatching session's session_key
(captured at dispatch time in delegate_tool.py:2798), but the delivery
router ignored it — results landed in whatever session was active at
completion time instead of the session that dispatched the subagent.

Changes:
- drain_notifications() in process_registry.py: optional session_key
  filter. Non-matching async_delegation events are re-queued instead of
  consumed, so they remain available for the correct session's drain.
- cli.py process_loop: passes active session_key to drain_notifications()
- tui_gateway/server.py post-turn drain: passes session_key from the
  TUI session dict
- gateway/run.py _build_process_event_source: logs warning when routing
  metadata is unresolvable (previously silent drop)
- Regression tests verifying session-scoped drain filtering

Fixes #58684

* fix(delegation): positive-proof ownership for the post-turn drain

Extends the salvaged session_key filter with the same fail-closed,
compression-chain-aware ownership gate the poller uses (#55578):

- drain_notifications() accepts an owns_event callback; when provided,
  an async-delegation event is consumed ONLY on positive proof of
  ownership, and a broken callback re-queues (never leaks). Bare key
  equality remains for single-session callers (CLI); no filter remains
  legacy behavior.
- The TUI post-turn drain passes _session_owns_notification_event, so
  it can't adopt another session's (or an orphan's) delegation payload,
  while a post-compression session still claims its own pre-compression
  dispatches - the gap bare key equality left open.

* fix(desktop): register /compress command in TUI gateway dispatch so Desktop can invoke it

* fix(tui): route /compress and /compact past the slash worker to command.dispatch

Ported from #60834 (same author) — pending-input routing so clients that
fail the slash.exec->dispatch fallback still reach the new compress handler.

* fix(whatsapp): use windows_detach_popen_kwargs to prevent console window flash on Windows

* fix(cli): preserve chat -q answer by gating exit-summary screen clear (#53009)

In single-query (-q) mode, the assistant's final answer was printed and
then immediately erased by _print_exit_summary() — which unconditionally
called _clear_terminal_on_exit() (ESC[3J ESC[2J ESC[H]). The answer was
present in the session store but invisible in the terminal.

The clear is only needed for interactive TUI teardown (#38928) where
prompt_toolkit chrome must be cleaned up. Add a clear_screen parameter
to _print_exit_summary() (default True, preserving interactive behavior)
and pass False from the single-query call site so the answer stays
visible above the exit summary.

Regression tests cover:
- clear_screen=True (default) calls _clear_terminal_on_exit()
- clear_screen=False skips the clear
- Single-query -q path passes False end-to-end
- Interactive path still clears (preserving #38928)

* test(cli): update FakeCLI._print_exit_summary for new clear_screen kwarg

* perf(yuanbao): bounded-concurrency inbound media resolve

* feat(Yuanbao) optimizes media resource processing speed: parallel download

* fix(delegate): pin async completion to spawning parent session (#57498)

Background delegate_task completions only carried session_key. When multiple
active sessions shared a routing peer, get_or_create_session could recover the
latest ended_at IS NULL row and inject the subagent result into the wrong
session.

Capture parent_agent.session_id at dispatch time, include it on async-delegation
completion events, and pin gateway routing via switch_session when the
synthetic completion message is handled.

Fixes #57498

* fix(gateway): never resurrect ended sessions for delegation completions; /new severs in-flight delegations

Completes the session-binding class on the gateway surface (#55578),
matching the TUI rules:

1. Fail-closed pinning: switch_session() re-opens ended sessions, so
   pinning a completion to a spawning session that has since ENDED
   (user /new, closed rotation) would resurrect a conversation the user
   explicitly ended and inject into it. The injection path now checks
   the pinned row's ended_at first and drops the injection with a
   WARNING when the spawning session is dead or unknown - the result
   stays in the delegation records.

2. /new ends the old conversation's delegations: _handle_reset_command
   calls interrupt_for_session() with the expiring durable session id
   (matching the parent_session_id pin stamped at dispatch) plus the
   routing key as fallback, so a reset can't leave dangling subagents
   whose completions have no live owner.

interrupt_for_session() gains the parent_session_id selector because a
gateway chat's session_key (the platform conversation key) survives a
reset while the session id rotates - key-based matching alone could
never sever a gateway conversation's delegations.

* feat(gateway): add webhook payload filters

* fix(gateway): run webhook route scripts off the event loop + AUTHOR_MAP entry

- run_route_script shells out with subprocess.run (up to 30s timeout); wrap
  the call in asyncio.to_thread so a slow script can't stall every other
  webhook and gateway task on the loop.
- scripts/release.py: map grace@weeb.onl -> evelynburger for the salvaged
  contributor commit.

* fix(desktop): continue the selected stored session instead of minting a new one (#55578) (#60874)

Two client-side halves of the #55578 session split:

1. Submit with a null activeSessionId but a SELECTED stored session now
   resumes that stored session instead of falling straight through to
   createBackendSessionForSend - which silently forked the user's
   conversation into a brand-new session that then got orphan-reaped.
   New-chat drafts (no stored selection) still create sessions as before.

2. prompt.submit recovery now also fires on gateway request timeouts,
   not only 'session not found'. A starved backend loop (the async-
   delegation poller spin) rejects the submit with 'request timed out'
   even though the stored session is fine; previously that surfaced an
   error, left the binding cleared, and set up the split on the next
   send.

Fail-then-pass: 2 new tests fail with production code reverted.

* fix(compression): stop compaction thrash — 75% trigger floor under 512K, no summary output cap, reasoning-trace exclusion (#60989)

Sessions on sub-512K-context models were spending most of their wall-clock
re-summarizing: the 50% trigger left too little post-compaction headroom
(the incompressible floor — system prompt, tool schemas, protected tail,
rolling summary — ate most of the reclaimed space), so compaction re-fired
every 1-2 turns. Three compounding defects fixed:

- Threshold floor: models with context windows below 512K now trigger at
  >=75% of the window (raise-only — a higher configured value or per-model
  autoraise like Codex gpt-5.5's 85% always wins). Re-derived on
  update_model() in both directions.
- No max_tokens on the summary call: the summary budget is prompt guidance
  only ("Target ~N tokens"). The wire cap truncated summaries mid-section
  on the Anthropic Messages / NVIDIA NIM paths (thinking models burn the
  cap on reasoning first), yielding truncated or thinking-only summaries
  and compaction loops. Summary token ceiling lowered 12K -> 10K to keep
  the guidance within the intended 1K-10K envelope.
- Reasoning traces excluded end-to-end: inline <think>/<reasoning> blocks
  are now stripped from assistant content before serialization to the
  summarizer, and from the summarizer's own output before the summary is
  stored (previously a thinking summarizer model's trace was persisted in
  _previous_summary and re-fed into every iterative update, compounding
  bloat). Native reasoning fields were already excluded.

Verified E2E with real imports against a temp HERMES_HOME: threshold table
across 64K-1M windows, override interactions (user 0.85 wins, spark 0.70
raised, gpt-5.5 0.85 kept), full compress() round-trip with a thinking
summarizer, and wire-kwargs capture proving no max_tokens is sent.

* docs(webhook): complete filters + route-scripts coverage across doc surfaces (#60983)

Follow-up to #60944 (webhook payload filters and route scripts):
- reference/cli-commands.md (en+zh): document the new --script option on
  'hermes webhook subscribe'
- zh-Hans user-guide webhooks.md: mirror the Payload Filters and Script
  Filters/Transforms sections plus the filters/script route properties
  (the salvage shipped English-only docs)
- hermes-agent skill webhooks reference: teach the agent the filters/
  script surface so agent-driven subscriptions can use them

* feat(xai): add grok-4.5 (GA) to model catalog, context lengths, and reasoning-effort allowlist (#60887)

* feat(xai): add grok-4.5 (early access) to catalog, context lengths, and reasoning-effort allowlist

- hermes_cli/models.py: grok-4.5 in _XAI_CURATED_EXTRAS (callable but absent
  from models.dev) and _XAI_STATIC_FALLBACK, so the /model picker and
  validation surface it on both xai and xai-oauth.
- agent/model_metadata.py: context lengths grok-4.5 -> 500K (per model card)
  and grok-build-latest -> 500K (alias); grok-4.5 added to
  _GROK_EFFORT_CAPABLE_PREFIXES.

Verified live against api.x.ai /v1/responses (2026-07-08): effort
low/medium/high accepted (server default: high), "none" rejected,
function calling works, full agent turn with terminal tool succeeded.

* feat(xai): grok-4.5 GA — add aggregator catalog entries, refresh comments

grok-4.5 is now GA: models.dev lists it (500K context, effort
low/medium/high) and both OpenRouter and Nous serve x-ai/grok-4.5.
Add it to the OpenRouter fallback snapshot and the Nous static list,
and update the early-access comments.

* chore: regenerate model-catalog.json for x-ai/grok-4.5

* f…
DaveVoyles added a commit to DaveVoyles/hermes-agent that referenced this pull request Jul 10, 2026
* fix(discord): widen expired-defer handling to /thread slash command

Same 10062 degrade-gracefully pattern as _run_simple_slash: create the
thread anyway, skip the ephemeral followups that need a live
interaction token. Non-expiry defer errors still raise.

* feat(desktop): add UI scale setting to appearance settings

* chore(desktop): drop PR screenshot assets from tree

* fix(agent): add cross-turn stream-stale circuit breaker (#58962)

A session wedged against an unresponsive OpenAI-compatible provider can hit the stale-stream detector on every turn and loop forever, burning the full 180s x retries each turn with no response. Issue #58962 reports 494 consecutive failures over 3+ days on a single session.

The streaming retry path already caps retries WITHIN a turn (HERMES_STREAM_RETRIES, default 2) but has no cross-turn cap. Once a session's conversation state makes every turn stale, it retries indefinitely across turns and never notifies the user.

Add a per-session consecutive-stale-stream counter on the agent:
- incremented on every stale-stream kill in the outer poll loop;
- reset to 0 only when a stream actually completes;
- when it reaches HERMES_STREAM_STALE_GIVEUP (default 5), the next turn aborts immediately with a clear, actionable RuntimeError instead of spending 180s x retries again.

This is distinct from the existing stale-stream work (local-provider hard ceiling #44938, backoff/parse-error #60031): those bound a single hung stream, while this bounds repeated cross-turn staleness and surfaces a user-visible error.

Adds tests/run_agent/test_stream_stale_circuit_breaker.py covering the short-circuit, the success-reset, and the increment.

* fix: reset stream-stale breaker on model switch and fallback activation

Follow-up for the salvaged #60332 circuit breaker. The breaker latches:
once the streak trips, interruptible_streaming_api_call raises before any
stream is attempted, so the on-success reset can never run again. The
error text tells the user to switch models and retry — but neither
switch_model() nor try_activate_fallback() cleared the streak, so a
freshly selected healthy provider kept short-circuiting forever (only
/new recovered), and the automatic fallback chain was wedged the same way.

Reset the streak at both swap sites (after a successful rebuild only;
rollback/exhaustion paths keep the latch). 4 tests.

* docs: document HERMES_STREAM_STALE_GIVEUP alongside sibling stream knobs

* fix: widen stale circuit breaker to non-streaming path + all provider-swap resets

Review findings on the salvaged #60332 breaker, fixed as follow-ups:

- restore_primary_runtime() now resets the streak (third provider-swap
  path; without it a recovered primary was short-circuited before a
  single attempt and could never be re-proven healthy except via /model).
- interruptible_api_call (non-streaming) now carries the same breaker
  (guard at entry, bump on stale_call_kill, reset on success). Quiet-mode
  / subagent / headless sessions — the profile most like #58962's
  unattended 494-failure session — take this path and had the identical
  infinite stale-retry class.
- Partial-stream stub return now resets the streak (chunks were received,
  provider demonstrably responsive).
- Consolidated the triple-duplicated counter arithmetic into shared
  helpers (_stale_streak/_bump_stale_streak/_reset_stale_streak/
  _check_stale_giveup) with one canonical comment block; error message
  now says 'consecutive stale attempts' (the counter counts kills, not
  turns — a single turn can produce several).

4 new tests (restore resets / no-op restore keeps latch / non-streaming
short-circuit / non-streaming success reset).

* fix(bedrock): route non-Claude auxiliary models through Converse API

Auxiliary Bedrock resolution always used the Anthropic Bedrock SDK, which
only works for Claude foundation-model IDs. Non-Claude models such as
openai.gpt-oss-20b-1:0 now use a Bedrock Converse adapter, matching the
main agent's bedrock_converse transport.

* test(bedrock): cover auxiliary Converse routing for non-Claude models

Assert gpt-oss Bedrock IDs resolve to BedrockAuxiliaryClient while Claude
IDs keep the Anthropic SDK path, including async mode.

* fix: normalize string stop + surface dropped stream/tool_choice in Converse shim

Review findings on the salvaged shim: (a) OpenAI callers may pass stop as
a bare string but Converse's stopSequences requires a list — normalize;
(b) call_llm(stream=True) (MoA aggregator) can reach this client and the
shim silently returned a complete response — keep that behavior (the
streaming consumer's got-final-object path downgrades gracefully) but log
it, and log dropped tool_choice, instead of silently ignoring both.
+2 regression tests.

Follow-up to the salvage of #60217 by @xxxigm.

* feat(mem0): self-hosted dashboard backend + recall tuning (salvage #55614)

Salvage of #55614 by @kartik-mem0 (mem0 maintainer). Adds a SelfHostedBackend
that talks to a self-hosted Mem0 Docker server over httpx (X-API-Key auth,
/search + /memories routes), gated behind `host`. Also folds in the mem0
research-team recall tuning that rides with it: rerank defaults to false across
all modes, the mem0_list tool is removed (5->4 tools), search guidance is
de-shouted, and self-hosted get_all reports the true stored total (#52921).

Supersedes the self-hosted portion of #52487 (@liuhao1024, first-submitted).

Closes #52478
Fixes #52921

* fix(mem0): make prompt label + platform setup honor host routing precedence

Follow-up on the salvaged #55614. The PR added host-based routing to
_create_backend (precedence: oss > host > platform) but two sibling surfaces
didn't mirror it:

- system_prompt_block() checked host before oss, so an oss+host config ran
  OSS but told the model it was self-hosted HTTP. Reordered to match routing.
- Platform setup (hermes memory setup mem0 --mode platform) left a stale host
  in mem0.json; since host beats platform, the user kept routing to the
  self-hosted server. save_config merges (no delete), so clear host to ""
  rather than pop() so the merge actually overwrites it.

Adds regression tests for both (mutation-checked).

* fix(mem0): prune dead get_all, wire rerank config default, warn on MEM0_HOST env override

Review follow-ups on the salvage:

- get_all() pruned from the ABC and all three backends: mem0_list (its
  only caller) was removed by the recall-tuning commit, leaving new,
  tested, unreachable code — including SelfHostedBackend's _MAX_TOP_K
  over-fetch workaround. Tests for it dropped; fake-class stubs remain
  harmlessly. (The #52921 true-total fix lives on in the PR history if
  a lister ever returns.)
- The persisted rerank config key was write-only (setup prompted for it,
  nothing read it). initialize() now parses it into _rerank_default and
  mem0_search uses it when the model doesn't pass rerank explicitly;
  per-call args still win. Guard test added.
- Platform-mode setup now warns when MEM0_HOST is set in the environment:
  the json host-clear can't help there (_load_config seeds host from the
  env var, docs tell users to put it in .env) — the user would silently
  keep routing to the self-hosted server.
- SelfHostedBackend: connect-level retries (httpx.HTTPTransport(retries=2))
  so a single transient blip doesn't count toward the provider breaker;
  transport now injectable and the test helper uses the real __init__
  instead of mirroring it via __new__.
- plugin.yaml description no longer leads with reranking (off by default,
  platform-only); docs em-dash typo fixed.

* feat: add prompt-only session export

* feat(cli): add standalone HTML session export with sidebar navigation

Implements a professional, standalone HTML export feature for Hermes sessions.

Key changes:
- Adds 'hermes sessions export <file>.html' support to the CLI.
- Implements a dark-mode-first, responsive HTML generator in 'hermes_cli/session_export_html.py'.
- Single session export features a focused, centered 90% width layout.
- Multi-session export adds a fixed sidebar with session switching and real-time search filtering.
- ZERO external dependencies; all styles and JS are embedded for offline portability.

* feat(cli): include system prompts in HTML export

* feat(cli): redesign system prompt display as dedicated header section

* feat(cli): expand system prompt by default in HTML export

* fix(cli): fix layout width bug and ensure system prompt header is used

* style(export): restore width: 0 for multi-session flex layout

* feat(cli): filter internal session_meta messages from HTML export

* feat(sessions): wire html + prompt-only formats into 'sessions export'

Salvage follow-up integrating PR #30481 (@simplast) and PR #57683
(@catbearlove1-lang) into the unified export surface:

- --format html: standalone self-contained HTML transcript (single
  session or multi-session with sidebar), works with all shared filters
  and --redact; requires a file output path.
- --only user-prompts: prompt-only export (jsonl records or md sections)
  via the shared session_export renderer; the separate export-prompts
  subcommand from the original PR is subsumed by this flag.
- AUTHOR_MAP entries for both contributors; docs EN + zh-Hans.

* feat(discord): optionally mention approval owners on exec prompts

Opt-in discord.approval_mentions (config.yaml, bridged to
DISCORD_APPROVAL_MENTIONS) prepends <@id> mentions for numeric
allowlist entries to exec-approval prompts, with a scoped
AllowedMentions override (users only). Default off - no surprise
pings. Reapplied onto the content-mirror layout from #60245: mentions
prepend to the visible content block and its truncation budget.

Original implementation from PR #39719; commits arrived bot-authored,
re-attributed to the contributor.

* chore: add alex107ivanov to AUTHOR_MAP

* fix: restore cli-config.yaml.example from main (stale-branch version leaked into salvage)

* fix(web-server): close OAuth token TOCTOU by writing 0o600 atomically

`_save_anthropic_oauth_creds` wrote the Anthropic OAuth token file with
`os.replace(tmp, path)` followed by a post-hoc `chmod(0o600)`. Between the
rename and the chmod the token file existed at the default umask (0o644 on most
hosts) — a window in which another local user could read the access/refresh
tokens.

Write via `utils.atomic_json_write(..., mode=0o600)`, which creates the temp
with mode 0o600 *before* any content is written, fsyncs, atomically replaces,
preserves the existing file's owner, and cleans up its temp on failure. This
matches the `atomic_json_write(mode=0o600)` call already used elsewhere in this
module for the credential-pool write, and #56644's owner preservation.

Tests updated for the new mechanism, plus a check that the write goes through
`atomic_json_write(mode=0o600)` (mutation-verified).

* feat(mem0): add self-hosted mode to the setup wizard

The salvaged SelfHostedBackend made self-hosted servers reachable via
mem0.json / MEM0_HOST, but the setup wizard still offered only Platform
and OSS — exactly the gap users hit (Discord report: 'At memory setup
there's only 2 options'). Adds a third wizard mode:

- interactive picker: Platform / Self-hosted server / Open Source
- non-interactive: hermes memory setup mem0 --mode selfhosted
  --host http://... [--api-key ...] [--dry-run]
- host -> mem0.json (behavioral), API key -> .env as MEM0_API_KEY
  (secret), optional key for AUTH_DISABLED servers
- best-effort reachability check against the server, non-fatal
- README + memory-providers docs updated with the wizard path

* feat(sessions): trace export + HF upload via 'sessions export --format trace' (#60507)

* feat(trace): upload sessions to HF Agent Trace Viewer

Salvage trace upload as a smaller CLI-first feature: deterministic Claude Code JSONL export, fail-closed redaction, lazy Hugging Face dependency, and no gateway slash-command wiring.

* chore(trace): drop external porting references from docstrings

Describe the trace-upload design in Hermes' own terms.

* feat(sessions): fold trace upload into 'sessions export --format trace'

Integrates the HF Agent Trace Viewer exporter (PR #36145) onto the
unified export surface instead of a separate 'hermes trace' subcommand:

- --format trace: Claude Code JSONL to stdout/file, or one
  <id>.trace.jsonl per session for filtered bulk export; defaults to
  the most recent session when no --session-id/filters given.
- --upload pushes to the user's private HF traces dataset (--public to
  opt out of private); reads HF_TOKEN with guided setup when missing.
- traces are secret-redacted by default (force mode); --no-redact opts
  out after review; redaction failure blocks export (fail closed).
- hermes_cli/trace.py + subcommands/trace.py removed; agent/trace_upload.py
  is the single engine. Docs EN + zh-Hans; 4 new CLI tests.

* fix: limit desktop model pickers to explicit providers

* chore: map Ronald contributor email

* fix: harden explicit-provider gate for stale env-seeded pool entries + non-desktop picker opt-ins

Follow-up on the #56966 salvage:

- is_provider_explicitly_configured(): an env-seeded credential-pool entry
  only counts as explicit while its env var still resolves to a usable
  secret. A stale auth.json entry left behind after the user deletes the
  var no longer keeps the provider in the picker forever (#55790).
- TUI modelPicker + dashboard ModelPickerDialog/api.getModelOptions pass
  include_unconfigured=true explicitly, preserving their full-universe
  setup-affordance behavior now that the backend defaults to the
  configured subset.
- desktop lib/model-options.ts routes explicit_only through the shared
  requestModelOptions() helper (added on main after the PR branched).
- regression tests for ambient (gh_cli) pool sources, explicit manual/
  device-code sources, and stale vs live env-seeded entries.

* feat(plugins): pass approve rule keys to approval gate

* fix(approval): wire gateway notify round-trip into the plugin escalation gate

_run_approval_gate's gateway branch only queued via submit_pending, so
plugin-escalated approvals never sent the interactive embed+buttons on
Discord/Telegram/Slack (#59413) - the user was never notified and the
action stayed silently blocked. Mirror check_dangerous_command's path:
when a session notify callback is registered, run the blocking
_await_gateway_decision round-trip (redacted payload, once/session/
always persistence, deny/timeout produce definitive BLOCKED outcomes);
fall back to submit_pending only when no callback exists.

Fixes #59413.

* chore: add doncazper to AUTHOR_MAP

* feat(pty): RingBuffer for keep-alive output capture

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(pty): PtySession drain/attach/detach with EOF close 4410

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(pty): PtySessionRegistry with reap + capacity

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(chat): reattach /api/pty sessions via ?attach= token

Keep-alive path when ?attach=<token> is present: PTY outlives the socket
via PTY_REGISTRY, reattaches on reconnect. No token = unchanged legacy
pump (_legacy_pump). detach (not close) on disconnect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(pty): periodic reaper wired into dashboard lifespan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(chat): persist attach token, reconnect on transient close

ChatPage sends ?attach=<localStorage token> so /chat reattaches to its
live PTY across refresh. onclose: 4410=process-exit (session ended),
4409=superseded (quiet), else transient -> auto-reconnect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(mcp): reap orphaned subprocesses before spawning new ones on retry

When an MCP stdio subprocess fails to connect (token expiry, port
contention, timeout), the run() reconnect loop retries with backoff.
Each retry calls _run_stdio() which spawns a new process pair, but the
previous failed pair was only detected as orphaned (added to
_orphan_stdio_pids) — never actually killed.  This caused rapid zombie
accumulation: 5 failed attempts × 2 procs each = 10 orphans competing
for the same port.

Add a _kill_orphaned_mcp_children() call at the top of _run_stdio(),
before the _snapshot_child_pids() baseline, so any orphans from prior
failed attempts are reaped before a new subprocess is spawned.

Fixes #57355

* fix(mcp): reap stdio orphans before reconnect

* fix(mcp): unify reconnect orphan reaping + move off the event loop

Merge the two cherry-picked reap call sites into one unscoped sweep at
the top of _run_stdio (the unscoped sweep is a superset of the
per-server one), and run it via asyncio.to_thread so the 2s
SIGTERM->SIGKILL escalation cannot stall the shared MCP event loop.

* fix(mcp): bound stdio initialize handshake to stop subprocess/FD leak

A stdio MCP server that never completes `initialize` (e.g. emits a
non-JSON-RPC frame and then blocks on stdin) leaks a child process plus its
stdio pipes/pidfd on every discovery-retry cycle — unbounded, until the
gateway hits EMFILE and every new open()/spawn fails (#59349).

Root cause (confirmed by instrumenting the live repro, and different from the
issue's own hypothesis): the spawned child IS captured in `new_pids`, so the
report's "new_pids empty at finally" guess is not it. The real cause is that
`session.initialize()` hangs forever on the garbage stream. `connect_timeout`
only bounds the caller's `.result()` wait on the foreground thread — it does
NOT cancel the `_run_stdio` coroutine on the background MCP loop. So the
coroutine is stuck at `await session.initialize()` permanently, its cleanup
`finally` never runs, the child is never reaped, and it stays invisible to the
orphan-reaper (whose `_orphan_stdio_pids` set never gets populated).

Fix: wrap `session.initialize()` in `asyncio.wait_for(..., connect_timeout)`
so a stalled handshake fails instead of hanging. The TimeoutError unwinds
through the SDK context managers (closing the child's stdin -> EOF -> exit)
and lets the existing `finally` reap any straggler. Cross-platform — no
signals/pgid/proc.

Scope: stdio only. The HTTP path has the same `await session.initialize()`
shape but spawns no subprocess (so it can't cause this leak) and already has
httpx transport timeouts.

Verified: the reporter's repro goes from unbounded growth to draining to zero;
added a hermetic regression test (fake transport whose `initialize()` hangs,
asserts the connect is bounded by connect_timeout) that fails on the pre-fix
code and passes on the fix; 566 existing MCP tests pass; ruff clean.

Repro confirmed on macOS (pipe FDs); the Linux-specific pidfd growth in the
report should be equivalent — the reporter offered to validate on Linux.

Closes #59349

* fix(mcp): widen #59349 handshake bound to HTTP transports + cancel abandoned start() task

Sibling sites of the same bug class as the salvaged stdio fix:
- SSE, streamable-HTTP (new + deprecated API) initialize() calls are now
  bounded by the same connect_timeout, so an endpoint that accepts the
  connection but never answers the handshake cannot park the run() task
  forever.
- start() now cancels its ensure_future'd run() task when the caller's
  connect timeout cancels start() itself — the orphaned-task leak was
  the root mechanism behind #59349, and this closes the class for any
  future pre-ready hang.

* fix(mcp): reap orphaned stdio MCP children on ungraceful parent death

A stdio MCP server (e.g. `npx -y mcp-remote <url>`) is spawned as a direct
child of the Hermes process. Existing teardown (MCPServerTask.shutdown() /
_kill_orphaned_mcp_children()) reaps it correctly on a clean exit, but a
kill -9 / crash / force-quit of the Hermes process skips that path entirely
-- the child (and its own descendants, e.g. mcp-remote's spawned node
process) is orphaned and keeps running. Repeated ungraceful restarts pile up
N orphaned processes racing to hold the same upstream SSE session, producing
errors like 'Invalid request parameters' on legitimate reconnects.

macOS/Linux have no portable equivalent of prctl(PR_SET_PDEATHSIG) at the
Python subprocess level, so this adds a thin supervisor
(tools/mcp_stdio_watchdog.py) that:
  - execs the real command as its own child in its own process group
  - passes stdin/stdout/stderr through untouched (MCP stdio protocol
    talks directly over those streams)
  - polls the original spawning PID with the same orphan-detection
    algorithm already proven in tui_gateway/slash_worker.py (ppid
    comparison + psutil creation-time guard against PID reuse)
  - SIGTERM-then-SIGKILL's the child's process group the moment the
    original parent is gone

Wired into _run_stdio via a new _wrap_command_with_watchdog() helper,
POSIX-only (matches the existing killpg-based cleanup's platform scope),
fails open (any error resolving pid/create-time falls back to the
unwrapped command) so this can never be the reason a working MCP server
stops starting.

Verified: reproduced the exact orphan scenario standalone (fake parent
process spawns watchdog + fake long-running MCP child, kill -9 the fake
parent, confirm the watchdog reaps the child within its poll window with
zero leaked processes). Updated test_mcp_tool_issue_948.py's resolved-path
assertion to check the watchdog-wrapped command instead of the raw
resolved binary. Full test_mcp_tool.py + test_mcp_stability.py +
test_mcp_tool_issue_948.py suite: 232 passed. Full -k mcp sweep across the
whole test tree: 1003 passed, 2 skipped, 0 failed.

* fix(mcp): watchdog wrap after OSV preflight + forward SIGTERM to child group

Two fixes on top of the salvaged parent-death watchdog:
- Apply the watchdog wrap AFTER the OSV malware preflight so the check
  inspects the real npx/uvx package instead of the python wrapper
  (the wrap previously made the preflight a silent no-op for every
  stdio server).
- The real server runs in its own process group under the watchdog, so
  the graceful-shutdown killpg no longer reached it; the watchdog now
  forwards SIGTERM/SIGINT to the child's group, keeping wedged servers
  killable on clean shutdown.

* Recycle idle MCP stdio servers

* Handle minimal MCP server fakes

* chore(release): map rainbowgore + thestudionorth in AUTHOR_MAP for MCP leak salvages

* docs(mcp): document idle_timeout_seconds / max_lifetime_seconds recycle keys + handshake-bound note

* test(mcp): unblock recycle-reconnect test from the parked self-probe wait

The salvaged test predates the parked-server self-probe
(_PARKED_RETRY_INTERVAL, landed on main after the PR branched): after the
final failed retry, run() parks in a real asyncio.wait that the patched
asyncio.sleep doesn't cover, stalling the test 300s. Signal shutdown once
the retry budget is exhausted so the park exits immediately.

* fix(mcp): guard POSIX-only kill primitives in stdio watchdog for the Windows footgun linter

signal.SIGKILL / os.killpg don't exist on Windows. The watchdog is only
spawned on POSIX (wrap site gates on os.name), but guard via getattr with
a plain terminate/kill fallback so an accidental Windows import can't
AttributeError.

* feat(dashboard): report profile + gateway topology in /api/status (#60537)

/api/status (loopback/insecure binds only) now includes:
- profiles: every profile on the host (default + named)
- gateway_mode: none | single | multiple | multiplex
- gateways: one entry per live gateway with the host ports its
  port-binding platforms listen on, plus served_profiles when the
  default gateway is multiplexing

Ports resolve from each profile's config.yaml (top-level platforms:
wins over gateway.platforms:, matching load_gateway_config precedence)
with adapter defaults as fallback. Topology enumeration runs in an
executor so the profile scan + process-table probes stay off the event
loop, and the whole block is gated behind the same loopback-only split
as hermes_home/gateway_pid so gated binds leak nothing new.

* fix(tools): enable platform-native toolsets when their composite is explicitly configured (#35527)

When a user explicitly configures a platform with its native composite
(e.g. platform_toolsets.discord: [hermes-discord]), the discord and
discord_admin toolsets were silently stripped by _DEFAULT_OFF_TOOLSETS
even though the composite contains those tools. The strip could not tell
an explicit composite opt-in apart from the unconfigured default.

Track whether the platform was explicitly configured and, when it was,
exempt toolsets that are both default-off and platform-restricted to the
current platform from the strip. Only discord/discord_admin are affected
(the sole entries in both _DEFAULT_OFF_TOOLSETS and
_TOOLSET_PLATFORM_RESTRICTIONS). Unconfigured and empty-list platforms
keep the security default-off behaviour.

* docs(sessions): unify export docs under one overview section (#60554)

Restructures the five parallel export sections into a single 'Export
Sessions' section: a format table (jsonl/md/qmd/html/trace + --only
user-prompts), one shared-filters paragraph covering all formats, and
per-format subsections nested beneath. EN + zh-Hans.

* fix(discord): honor pairing grants for message auth

* fix(discord): explain fail-closed allowlist default

Log a one-shot structured warning when Discord denies traffic because
no allowlist/policy is configured, and correct the setup wizard's
inverted warning text. The fail-closed default itself is unchanged.

Fixes #58682.

* docs(discord): troubleshoot silent fail-closed denials

Docs portion of PR #57067: 'bot connects but never replies' section
pointing at the gateway.log warning and the allowlist/policy knobs.

Co-authored-by: ooovenenoso <120500656+ooovenenoso@users.noreply.github.com>

* fix(gateway): only session-discover channel targets for connected platforms (#60574)

Session-based channel discovery resurrected historical origins for
platforms with no connected adapter, exposing stale send_message
targets that can no longer deliver. Gate both the enum loop and the
plugin-registry loop on the live adapter set.

Surgical reapply of the channel-directory portion of PR #25959 (branch
was 6.5k commits stale; the text-batching delay changes bundled there
were dropped - separate concern, defaults have since been retuned on
main).

Co-authored-by: Marco-Olivier Lavoie <marcolivier@gmail.com>

* Fix delegation config precedence

* Use read-only config loader and honor HERMES_IGNORE_USER_CONFIG in delegation config

* fix(dashboard): advertise truecolor to the embedded chat TUI (#60576)

Headless/hosted deploys run the dashboard server without COLORTERM in
the process environment, so chalk inside the PTY-spawned TUI child
downgraded every skin hex color to the xterm 256 palette — the default
skin's bronze banner border (#CD7F32) snapped to palette 173 (#D7875F,
salmon red) and the gold caduceus rendered red/yellow on fresh cloud
instances. Local launches never reproduced it because the operator's
interactive terminal leaks COLORTERM=truecolor into the server env.

xterm.js always renders 24-bit RGB, so the dashboard PTY child should
always advertise truecolor: backfill COLORTERM=truecolor in
_resolve_chat_argv via setdefault (an explicit operator value wins).

Verified with a clean-env PTY probe of the real TUI binary:
no COLORTERM -> 0 truecolor SGRs / 165 palette-256 (salmon 38;5;173);
with the backfill -> 166 truecolor SGRs, exact bronze 38;2;205;127;50.

* feat(dashboard): expose profile names + gateway_mode on gated /api/status (#60585)

The profile+gateway topology added in #60537 sits entirely behind the
loopback/--insecure auth gate. But a hosted agent (Hermes Cloud) binds
non-loopback with OAuth, so should_require_auth is True, and NAS reads
/api/status over the network (fly-provider.ts getInstanceRuntimeStatus)
with no session token. On that gated path the whole topology block was
omitted, so the Portal could never render the profile list.

Split the topology readout by sensitivity:
- profile NAMES (profiles) + gateway_mode are low-sensitivity product
  surface and now ride the always-public status body, surviving the auth
  gate so NAS/the Portal can enumerate profiles.
- the per-gateway detail (gateways[], carrying host ports) is deployment
  recon and stays gated alongside hermes_home / config_path / env_path /
  gateway_pid / gateway_health_url.

The collector now runs unconditionally (still in the executor, off the
event loop). No new fields; only the gate placement changes.

* feat(relay): carry routed profile from the connector wire source (#60586)

The multiplex machinery already routes an inbound message to a profile via
SessionSource.profile (build_session_key namespacing + the per-turn
config/credential scope in SessionStore._resolve_profile_for_key). But the
relay path never populated it: _event_from_wire rebuilt the SessionSource
field-by-field and dropped any 'profile' the connector sent, so a
Team-Gateway (connector + relay) message could not be routed to a specific
profile the way the /p/<profile>/ HTTP prefix and per-credential polling
adapters already can.

Stamp source.profile from the wire payload in _event_from_wire. This is the
last missing link for NAS-driven per-profile routing over the relay in
multiplex mode; the connector populating the field ships separately
(gateway-gateway contract adds the optional wire field).

Back-compat: absent 'profile' → None → legacy agent:main namespace,
byte-identical to today for every single-profile gateway.

* Add dashboard memory provider switching

* fix: validate memory provider names before filesystem lookup and setup commands

Strict charset allowlist (alnum + - _, max 64) on the {name} path param of
the memory-provider config/setup endpoints. Prevents traversal-shaped names
from reaching find_provider_dir(), and setup now 404s when neither a
loadable provider nor a plugin manifest exists, so the command-running path
is only reachable for discoverable plugins. Adds regression tests.

* feat(gateway): GATEWAY_MULTIPLEX_PROFILES env override for multiplex flag (#60589)

The connector now depends on the single multiplexed gateway for per-profile
relay routing, so hosted deployments need to FORCE multiplexing on regardless
of the image's config.yaml. gateway.multiplex_profiles was config.yaml-only,
which a user could leave unset or flip off.

Add GATEWAY_MULTIPLEX_PROFILES as a standard operator override on top of the
existing config key — the same 'config.yaml is canonical, env is the operator
override' pattern the Telegram/Signal require_mention bridges use:

  env (recognized token) > config.yaml (top-level or nested gateway.*) > False

- gateway/config.py: _env_multiplex_profiles_override() resolves the env var
  tri-state — recognized truthy/falsy token → bool; unset/blank/unrecognized
  → None (fall through to config). Blank is deliberately None, not False, so a
  provisioned-but-unpopulated Fly secret ('') can't shadow a config.yaml opt-in
  (the empty-secret trap). Wired into GatewayConfig.from_dict so every consumer
  (run.py, session.py via self.config) sees the resolved value.
- hermes_cli/gateway.py: the named-profile-start guard
  (_guard_named_profile_under_multiplexer) reads config.yaml directly, so it
  gets the SAME env precedence — otherwise env-forced multiplex would leave the
  guard blind and someone could start a conflicting per-profile gateway that
  double-binds a bot token. Env-forced-on trips the guard even with no
  config.yaml key; env-forced-off disables it over a config opt-in.

Tests: full 3-tier precedence in test_config.py (incl. the discriminating
env-overrides-config cases + the empty/whitespace/unrecognized fall-through
trap + resolver tri-state), mutation-verified (flipping precedence fails
exactly the two env-wins tests); guard env cases in test_multiplex_lifecycle.py.

Force-on is safe on a single-profile instance: session keys stay byte-identical
(agent:main) and the _run_agent wrapper installs the per-turn secret scope, so
the fail-closed get_secret() path is satisfied.

* Fix dashboard chat model profile scoping

* fix: pass profile-scoped SessionDB to _session_latest_descendant in dashboard chat PTY resume

The chat PTY launch path landed on main after PR #50558 and still called
_session_latest_descendant() with the old one-arg signature. Open the
requested profile's state DB (matching the REST endpoint) so profile-scoped
resume resolves descendants in the right database.

* Add WhatsApp dashboard pairing flow

* chore: release v0.18.1 (2026.7.7) (#60595)

* fix(whatsapp): unpin Baileys from git commit, use published 7.0.0-rc13 (#60643)

The April 2026 pin to WhiskeySockets/Baileys#01047deb existed only to
pick up the abprops bad-request fix (Baileys PR #2473) before it was
released. That fix shipped in v7.0.0-rc11 (May 2026); our pinned commit
is now 48 commits behind rc13.

The git pin forced npm to clone the repo and compile Baileys from
TypeScript source on every fresh install (~3 min), which blew past the
dashboard pairing flow's timeout. Registry install takes ~3s.

Validation: all 9 bridge.js imports present in rc13, bridge.native.test.mjs
passes (13/13), live bridge boot renders pairing QR against real WA servers.

* chore: release v0.18.2 (2026.7.7.2) (#60651)

* feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop) (#57225)

* feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop)

pip and Homebrew are now Unsupported install methods per
website/docs/getting-started/platform-support.md. Surface a
warn-don't-block deprecation notice everywhere the install method is
already shown, pointing at the platform-support docs and noting these
installs will not receive further updates. NixOS (Tier 2) is untouched.

- hermes_cli/config.py: shared is_unsupported_install_method() /
  format_unsupported_install_warning() helpers so the wording and docs
  link stay consistent across every surface.
- hermes_cli/banner.py: generalize the existing pip-only banner
  warning to also cover Homebrew.
- hermes_cli/main.py: hermes update and hermes update --check print
  the warning before proceeding (still update; warn, don't block).
- tui_gateway/server.py: session.info gains install_warning.
- ui-tui: SessionPanel renders install_warning alongside the existing
  'N commits behind' notice.
- apps/desktop: SessionRuntimeInfo/GatewayEventPayload gain
  install_warning; applyRuntimeInfo + the live session.info event fire
  a snoozable warning toast via a new reportInstallMethodWarning(),
  mirroring the existing backend-contract-skew toast pattern. i18n
  strings added for en/zh/zh-hant/ja.
- Tests: updated pip banner assertions for the new wording, added a
  Homebrew banner test, and two tui_gateway session_info tests
  (install_warning present for pip, absent for git).

* fix(nix): make `hermes` in developement environment actually work

install modules as editable overlay with uv

* feat: print install method when running --version

* fix: correct detect install method when running from a subtree

* Fail closed on invalid JSON/YAML/TOML writes instead of writing then reporting

write_file() previously called _atomic_write() first and only ran the
JSON/YAML/TOML/Python syntax check afterward as an informational lint
delta -- a parse failure never set the top-level `error` key, so a
corrupt structured-data write still landed on disk (and file_tools.py's
files_modified gating, which keys off `error`, silently reported it as
a successful modification).

Move the in-process syntax check for JSON/YAML/TOML ahead of
_atomic_write() and refuse the write outright on a parse failure: no
temp file, no rename, nothing touches disk, and the result carries a
top-level `error` so callers correctly see it as unmodified.

Deliberately scoped to _FAIL_CLOSED_INPROC_EXTS (JSON/YAML/TOML), not
all of LINTERS_INPROC -- .py is excluded because this codebase's own
test fixtures (TestPatchReplacePostWriteVerification et al.) write
arbitrary non-Python text through *.py paths purely to exercise
write-mechanics; a hard block there broke 3 previously-passing tests
during development. Python keeps its pre-existing non-blocking
lint-delta report.

Adds tests/tools/test_write_file_syntax_gate.py: invalid JSON/YAML/YML/
TOML refused with nothing written (new file) and nothing modified
(existing file); valid JSON/YAML still written byte-for-byte; a
non-linted extension with garbage content is unaffected; invalid Python
is confirmed NOT hard-refused (still just reported).

* fix(tools): make the YAML write gate syntax-only so multi-doc/tagged YAML isn't refused

safe_load() raises ComposerError on multi-document streams (k8s manifests)
and ConstructorError on application-defined tags (CloudFormation !Sub,
Ansible !vault) — both valid YAML syntax. Now that the linter's verdict is
a fail-closed write gate, those false positives would refuse legitimate
writes outright. Switch to yaml.parse() (scanner+parser only), which still
catches real syntax failures.

* chore: add AUTHOR_MAP entry for neoguyverx (PR #60526 salvage)

* fix(gateway): drain in-flight cron jobs before shutdown tool kill

/update and other shutdown paths only waited on gateway session agents,
so active cron tool work was killed immediately in final-cleanup while
the scheduler could still mark the job successful (#60432).

* test(gateway): cover cron drain during gateway shutdown (#60432)

* fix(gateway,cron): make shutdown drain visible to in-flight cron work

Cron jobs run through cron/scheduler.py's own ThreadPoolExecutor via a
standalone AIAgent (run_job/run_one_job), entirely outside
GatewayRunner._running_agents -- the dict _drain_active_agents() and
every other active-work check on that class reads. A gateway shutdown
(/update, /restart, and SIGUSR1 all funnel through the same stop())
could log active_at_start=0 and immediately kill tool subprocesses
while a cron job's terminal command was still running, with no wait
and no indication anything was interrupted.

Real-world impact (from the issue): a scheduled daily briefing cron
job was in flight during /update, its tool subprocess got killed
by the unconditional shutdown cleanup, and the job was never marked
failed -- it simply never completed or delivered, with no error
surfaced anywhere. A repro with a 30-minute `sleep` cron job in flight
during /update reproduced the same pattern: subprocess killed at
+0.22s of drain (active_at_start=0), the job's agent thread continued
in-process and produced a plausible-looking final response from the
truncated tool output, and the scheduler marked the run successful.

Root cause is layered, not a single line:

1. GatewayRunner._drain_active_agents() only waits on _running_agents.
   Cron work was invisible to it, so drain returned instantly whenever
   the only active work was a cron job.
2. Even with visibility, the shutdown's final tool-subprocess kill
   (process_registry.kill_all()) is a global, unconditional sweep with
   no per-job targeting -- a long-running cron job that outlives the
   drain timeout still gets its subprocess killed.
3. cron/scheduler.py had no way to detect that a job's tool subprocess
   was killed out from under it mid-run; the agent thread kept going
   and its eventual (often degraded but plausible-looking) response
   got reported as a normal successful completion.

Fix, three parts:

- cron/scheduler.py: expose get_running_job_ids() (thread-safe
  snapshot of the existing _running_job_ids set, already used to
  prevent double-dispatch) so the gateway can read cron's in-flight
  state without reaching into private module internals.

- gateway/run.py: GatewayRunner._active_cron_job_count() reads that
  snapshot. _drain_active_agents() now waits on
  (_running_agents OR active cron jobs), so a cron-only workload gets
  the same bounded wait chat sessions already get instead of an
  instant active_at_start=0. Shutdown drain logging gains
  cron_active_at_start/cron_active_now fields alongside the existing
  ones (unchanged, for compat).

- cron/scheduler.py: mark_running_jobs_interrupted(reason), called by
  gateway/run.py's _kill_tool_subprocesses() right after
  process_registry.kill_all(), marks every job still in
  _running_job_ids at that instant as failed/interrupted via the
  existing mark_job_run() -- and records the job IDs in
  _interrupted_job_ids BEFORE writing, so run_one_job()'s own
  eventual completion for the same run (racing in its own thread)
  checks that flag and skips its normal write instead of clobbering
  the interrupted status with a false "ok" produced from the
  now-truncated tool output. This does not attempt to correlate a
  killed PID to a specific job ID (process_registry tracks PIDs, not
  job IDs) -- any job still dispatched at the moment of a forced kill
  is treated as interrupted, matching the existing coarser precedent
  set by _interrupt_running_agents(), which interrupts every entry in
  _running_agents on a drain timeout without per-agent correlation
  either.

Deliberately out of scope (flagged in the issue as a separate,
lower-priority concern): startup-time reconciliation of cron runs that
started but never reached a terminal status.

Testing:

- tests/cron/test_shutdown_interrupt.py (12 tests): get_running_job_ids
  snapshot semantics, mark_running_jobs_interrupted marking/no-op/
  partial-failure behavior, and -- the core race guard -- run_one_job
  skipping its own last_status write (both the success path and the
  exception path) when the shutdown path already marked the run
  interrupted, with a control test proving ordinary un-interrupted
  completions are unaffected.

- tests/gateway/test_cron_active_work_drain.py (9 tests):
  _active_cron_job_count reading cron state and failing closed (0) if
  the cron module is unavailable; _drain_active_agents waiting for an
  in-flight cron job the same way it waits for chat sessions, timing
  out if the job outruns the window, and leaving existing chat-session
  drain behavior unchanged; a full runner.stop() integration test
  (drain-timeout path) proving mark_running_jobs_interrupted actually
  fires with the right job ID when a tool subprocess is force-killed,
  plus a no-op control when nothing cron-related is in flight.

- tests/gateway/test_shutdown_cache_cleanup.py: added
  _active_cron_job_count() to that file's hand-rolled _FakeGateway test
  double, which stop() now calls -- without it those 8 pre-existing
  tests AttributeError (caught by fail-then-pass below, not a
  production bug).

Fail-then-pass: reverted gateway/run.py + cron/scheduler.py, all 21
new tests fail (fixture/attribute errors -- the feature doesn't exist
yet); restored, all 21 pass.

Regression check: ran the full plausibly-affected surface --
tests/gateway/{test_gateway_shutdown,test_restart_drain,
test_restart_notification,test_restart_redelivery_dedup,
test_restart_resume_pending,test_restart_service_detection,
test_shutdown_cache_cleanup,test_stuck_loop,test_clean_shutdown_marker,
test_external_drain_control,test_session_state_cleanup,
test_update_command,test_update_streaming}.py plus tests/cron/ (944
tests) -- against a clean upstream/main checkout and against this
branch. Diffed the two FAILED lists: identical, 20 pre-existing
failures on both sides (Windows-locale/cp1252 file-encoding issues and
Unix-permission-bit assertions that don't apply on this Windows dev
box), zero new failures, zero fixed-by-accident. The 8
test_shutdown_cache_cleanup.py failures found mid-development were
from the _FakeGateway gap above, fixed in the same commit and
confirmed clean on the final rerun (diff against baseline: exit 0).

Fixes #60432

* fix(cron): stop interrupted jobs from delivering their pre-kill output

Follow-up to the previous commit on #60432. The status-write guard
(_consume_interrupted_flag, checked right before mark_job_run) closes
the false-success bookkeeping gap, but run_one_job delivers its result
BEFORE that check: delivery happens right after run_job() returns,
mark_job_run happens at the very end. A job whose tool subprocess was
killed mid-flight can still produce a plausible-looking final_response
from the truncated output, and that response would reach the user via
_deliver_result before the interrupted flag was ever consulted --
correct status in jobs.json, wrong message already sent.

Adds _is_interrupted(), a non-destructive peek at the same
_interrupted_job_ids set (_consume_interrupted_flag stays as the
consuming, authoritative check right before the status write -- this
needed a peek instead since the flag has to still be visible there).
Checked right after save_job_output, before the deliver_content
decision: if the run looked successful but was flagged interrupted,
force success=False with an explicit interruption message. This
routes delivery through the existing _summarize_cron_failure_for_delivery
path (the same one a real failure already uses) instead of the raw
final_response, so the user gets an honest "this run was interrupted"
instead of a truncated/misleading result.

Testing: 4 new tests in tests/cron/test_shutdown_interrupt.py --
_is_interrupted peek semantics (false/true/does-not-clear, as opposed
to the consuming _consume_interrupted_flag), and the delivery-gate
test itself, which mocks run_job to return a normal-looking success
with a "plausible final response" while the job is pre-marked
interrupted, and asserts _deliver_result receives the failure summary
("This run was interrupted.") instead, with the summarizer's error
argument confirmed to mention the interruption.

Fail-then-pass: reverted cron/scheduler.py only, the 4 new tests fail
(3 on the missing _is_interrupted attribute, 1 -- the delivery-gate
test -- on _summarize_cron_failure_for_delivery never being called,
i.e. the raw response would have gone out); restored, all 16 tests in
the file pass.

Regression: tests/cron/ (683 tests) + test_cron_active_work_drain.py +
test_gateway_shutdown.py + test_shutdown_cache_cleanup.py -- 11
pre-existing failures (Unix file-permission-bit and path-tilde
assertions that don't apply on this Windows dev box), matching the
same set already established as pre-existing in the prior commit's
regression check. Zero new failures.

Continues #60432

* fix(gateway,cron): reconcile #60612 + #60631 onto one drain surface

Keep #60631's get_running_job_ids() snapshot + _active_cron_job_count()
(import-guarded for minimal test doubles) as the single read path, and
retarget #60612's drain tests at it. Drops the redundant
cron_jobs_in_flight() helper so there is one surface, not two.

* fix(tui): prevent ws_orphan_reap from ending gateway-originated sessions

Guard _finalize_session's db.end_session() call against gateway-owned
sessions (telegram, bluebubbles, discord, etc.).  The TUI is a viewer
for these sessions, not the lifecycle owner.  Unconditionally ending
them in state.db creates a Groundhog Day routing loop: the gateway's
#54878 self-heal detects the stale entry, recovers to the parent
session, context compression splits back to the reaped child, and the
cycle repeats on every inbound message — causing complete conversational
context amnesia.

Fixes #60609

* fix(tui): derive gateway-owned sources from the Platform enum, not a hardcoded list

The salvaged guard used a hand-maintained frozenset of 14 platform names —
several of which (line, wechat, facebook, imessage, googlechat) aren't
actual Hermes Platform values, while real ones (whatsapp_cloud, feishu,
wecom, dingtalk, qqbot, yuanbao, plugin platforms like irc) were missing.
Resolve the source through gateway.config.Platform instead (built-ins +
registered plugin platforms via _missing_), with an explicit exclusion set
for self-owned/local sources. Adds tests for the guard and both reap paths.

* feat(gateway): generic OIDC client-credentials relay provisioning (NAS-free) (#60730)

For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway
obtain its caller-identity bearer from a generic OAuth2 client_credentials grant
against the operator's own IdP (e.g. Microsoft Entra ID) instead of only
resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim
(default tid) off that token as the tenant.

- gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials
  when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal
  (unchanged default). Wired into self_provision_relay().
- hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical
  resolver so the enroll CLI and the runtime self-provision path share ONE impl.

Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml
(env override GATEWAY_RELAY_IDP_*). No behaviour change when unset.

Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection,
request shape, config/env precedence, fail-closed). Relay suite 162 pass.

Validated via the cross-repo gateway<->connector live E2E (provision, managed
self-provision, inbound round-trip, /link) against a connector running the OIDC
tenant resolver with zero NAS config.

* Fix slow Z.AI startup by caching auto-detected endpoint to disk

(cherry picked from commit 6ed884933a178d5540f02d80e3fe9e678ca844eb)

* chore: add veradim to AUTHOR_MAP for PR #41201 salvage

* fix: don't flip active_provider when caching Z.AI probe result

_save_provider_state() sets auth_store['active_provider'] as a side effect.
The Z.AI endpoint probe runs from credential-pool env seeding for any user
with a Z.AI key in env — persisting the probe cache must not silently make
zai the active provider. Use _store_provider_state(set_active=False).

Follow-up to PR #41201 salvage.

* fix: Z.AI endpoint persist failure must not break URL resolution

Review findings (hermes-pr-review Phase 2, 3-angle):
- _save_auth_store() does real filesystem I/O (mkdir, O_EXCL create, fsync,
  atomic replace) and can raise on disk-full/permissions/lock-timeout. The
  persist ran bare in the success path, so a persist failure aborted
  _resolve_zai_base_url() after detection had already succeeded. Wrap the
  persist in try/except: log a warning and still return the detected URL
  (worst case: next start re-probes).
- Readability: stage the payload in a local detected_endpoint instead of
  writing through the stale pre-lock 'state' dict, which is no longer what
  gets persisted.

* fix(cron): stop the ticker from stalling forever on a wedged jobs lock (#60703) (#60855)

Three fixes for the silent post-restart ticker stall:

1. _jobs_lock() bounds its cross-process flock: LOCK_NB polled against a
   30s deadline instead of an unbounded LOCK_EX taken while holding the
   process-wide RLock. On timeout it logs at ERROR and degrades to
   in-process-only locking (the existing fallback path), so a sibling
   process wedged while holding .jobs.lock can no longer freeze every
   cron function - including the ticker's get_due_jobs() and thus the
   heartbeat - forever with zero logging.

2. fire_claim/run_claim freshness checks are bounded on both sides
   (0 <= age < ttl): a claim stamped in the future (clock/TZ skew across
   a restart) was previously fresh forever, making the job permanently
   unfireable and every manual run report 'already being fired'.

3. _execute_job_now distinguishes paused/disabled/missing jobs from a
   genuinely held claim instead of mislabeling them all as 'already
   being fired'.

* fix(tui_gateway): back off notification poller when session is busy

The busy-session branch of _notification_poller_loop re-queued the
completion event and immediately re-polled it with no sleep, spinning
at full speed (100% CPU, ~1100 futex/s of GIL churn) for as long as
the session stayed running. This starved the dashboard asyncio loop:
/api/status went from 0.14s to 3-6s with 10s timeouts.

Sleep 0.25s outside history_lock before re-polling, mirroring the
0.1s back-off already used for foreign-session events.

* chore: add SiteupAgencia to AUTHOR_MAP for #57435 salvage

* test(tools): add unit tests for skill_gist

* fix(agent): tag desktop chat sessions as desktop

The desktop app's chat panel reuses tui_gateway as its backend, so every chat session was stamped platform="tui". That made the agent read terminal-specific platform guidance while running in the graphical desktop chat surface.

Resolve the misclassification at its source: tui_gateway now picks platform="desktop" when HERMES_DESKTOP=1 and HERMES_DESKTOP_TERMINAL is unset, and keeps platform="tui" for the embedded terminal pane and standalone TUI. Add a PLATFORM_HINTS["desktop"] entry describing the actual chat surface (full GFM markdown, MEDIA: intercept, inline images). Move the embedded-pane clarifier to the platform-hint resolution site so it appends only to the tui hint under HERMES_DESKTOP_TERMINAL=1. Delete the now-dead desktop-hint block from build_environment_hints() that competed with the platform hint.

Standalone TUI sessions produce byte-identical prompts as before; the new desktop hint and clarifier are assembled once per session in the stable tier, so prompt caching is preserved.

* fix(delegation): route async results to origin session

Carry the live TUI session id with async delegation completion events and prefer the commissioning UI session when desktop pollers share the completion queue. Resolve compressed session keys to their continuation before treating events as orphaned, and capture the live parent agent session id for TUI/ACP dispatch.

* fix(delegation): fail-closed orphan handling + session-scoped delegation lifecycle

Two invariants layered on the origin-routing commit (#55578):

1. Fail closed on orphaned async-delegation payloads. The poller's
   belongs-elsewhere check handles events owned by another LIVE session,
   but an event whose owner is gone previously fell through and was
   adopted by whichever poller saw it - injecting one chat's delegation
   output into another chat. Delegation completions are now injected
   only into a session that PROVABLY owns them (origin UI id, or
   session-key/lineage match via the compression chain); unowned
   payloads are dropped from injection with a WARNING (the subagent's
   output is already persisted in the delegation records, so nothing is
   lost). The shutdown drain applies the same rule. Non-delegation
   events keep the historical adopt-orphans behavior.

2. A session's in-flight async delegations end with the session.
   _finalize_session now calls interrupt_for_session(): delegations
   commissioned by the closing UI session are interrupted always;
   key-matched delegations only when the TUI owns the session lifecycle,
   so closing a viewer tab on a live gateway session never kills the
   gateway's own background work.

* feat(models): swap curated Tencent Hy3 Preview for GA tencent/hy3, drop owl-alpha (#60943)

- OPENROUTER_MODELS: remove openrouter/owl-alpha (free) and
  tencent/hy3-preview{,:free}; add tencent/hy3 and tencent/hy3:free
- _PROVIDER_MODELS[nous]: tencent/hy3-preview -> tencent/hy3
- run_agent.py reasoning-prefix list: tencent/hy3-preview -> tencent/hy3
  (prefix match still covers -preview if pinned)
- model_metadata: register hy3 context length (262144) alongside hy3-preview
- regenerate website/static/api/model-catalog.json
- update tokenhub curated-list tests to the new IDs

The tencent-tokenhub direct provider still serves hy3-preview and is
intentionally unchanged.

* docs(i18n): align translated CONTRIBUTING files with pyproject Python range (3.11-3.13)

* docs(i18n): align translated CONTRIBUTING files with pyproject Python range (3.11-3.13)

* fix(delegation): route async delegate_task results back to originating session

The completion event already carries the dispatching session's session_key
(captured at dispatch time in delegate_tool.py:2798), but the delivery
router ignored it — results landed in whatever session was active at
completion time instead of the session that dispatched the subagent.

Changes:
- drain_notifications() in process_registry.py: optional session_key
  filter. Non-matching async_delegation events are re-queued instead of
  consumed, so they remain available for the correct session's drain.
- cli.py process_loop: passes active session_key to drain_notifications()
- tui_gateway/server.py post-turn drain: passes session_key from the
  TUI session dict
- gateway/run.py _build_process_event_source: logs warning when routing
  metadata is unresolvable (previously silent drop)
- Regression tests verifying session-scoped drain filtering

Fixes #58684

* fix(delegation): positive-proof ownership for the post-turn drain

Extends the salvaged session_key filter with the same fail-closed,
compression-chain-aware ownership gate the poller uses (#55578):

- drain_notifications() accepts an owns_event callback; when provided,
  an async-delegation event is consumed ONLY on positive proof of
  ownership, and a broken callback re-queues (never leaks). Bare key
  equality remains for single-session callers (CLI); no filter remains
  legacy behavior.
- The TUI post-turn drain passes _session_owns_notification_event, so
  it can't adopt another session's (or an orphan's) delegation payload,
  while a post-compression session still claims its own pre-compression
  dispatches - the gap bare key equality left open.

* fix(desktop): register /compress command in TUI gateway dispatch so Desktop can invoke it

* fix(tui): route /compress and /compact past the slash worker to command.dispatch

Ported from #60834 (same author) — pending-input routing so clients that
fail the slash.exec->dispatch fallback still reach the new compress handler.

* fix(whatsapp): use windows_detach_popen_kwargs to prevent console window flash on Windows

* fix(cli): preserve chat -q answer by gating exit-summary screen clear (#53009)

In single-query (-q) mode, the assistant's final answer was printed and
then immediately erased by _print_exit_summary() — which unconditionally
called _clear_terminal_on_exit() (ESC[3J ESC[2J ESC[H]). The answer was
present in the session store but invisible in the terminal.

The clear is only needed for interactive TUI teardown (#38928) where
prompt_toolkit chrome must be cleaned up. Add a clear_screen parameter
to _print_exit_summary() (default True, preserving interactive behavior)
and pass False from the single-query call site so the answer stays
visible above the exit summary.

Regression tests cover:
- clear_screen=True (default) calls _clear_terminal_on_exit()
- clear_screen=False skips the clear
- Single-query -q path passes False end-to-end
- Interactive path still clears (preserving #38928)

* test(cli): update FakeCLI._print_exit_summary for new clear_screen kwarg

* perf(yuanbao): bounded-concurrency inbound media resolve

* feat(Yuanbao) optimizes media resource processing speed: parallel download

* fix(delegate): pin async completion to spawning parent session (#57498)

Background delegate_task completions only carried session_key. When multiple
active sessions shared a routing peer, get_or_create_session could recover the
latest ended_at IS NULL row and inject the subagent result into the wrong
session.

Capture parent_agent.session_id at dispatch time, include it on async-delegation
completion events, and pin gateway routing via switch_session when the
synthetic completion message is handled.

Fixes #57498

* fix(gateway): never resurrect ended sessions for delegation completions; /new severs in-flight delegations

Completes the session-binding class on the gateway surface (#55578),
matching the TUI rules:

1. Fail-closed pinning: switch_session() re-opens ended sessions, so
   pinning a completion to a spawning session that has since ENDED
   (user /new, closed rotation) would resurrect a conversation the user
   explicitly ended and inject into it. The injection path now checks
   the pinned row's ended_at first and drops the injection with a
   WARNING when the spawning session is dead or unknown - the result
   stays in the delegation records.

2. /new ends the old conversation's delegations: _handle_reset_command
   calls interrupt_for_session() with the expiring durable session id
   (matching the parent_session_id pin stamped at dispatch) plus the
   routing key as fallback, so a reset can't leave dangling subagents
   whose completions have no live owner.

interrupt_for_session() gains the parent_session_id selector because a
gateway chat's session_key (the platform conversation key) survives a
reset while the session id rotates - key-based matching alone could
never sever a gateway conversation's delegations.

* feat(gateway): add webhook payload filters

* fix(gateway): run webhook route scripts off the event loop + AUTHOR_MAP entry

- run_route_script shells out with subprocess.run (up to 30s timeout); wrap
  the call in asyncio.to_thread so a slow script can't stall every other
  webhook and gateway task on the loop.
- scripts/release.py: map grace@weeb.onl -> evelynburger for the salvaged
  contributor commit.

* fix(desktop): continue the selected stored session instead of minting a new one (#55578) (#60874)

Two client-side halves of the #55578 session split:

1. Submit with a null activeSessionId but a SELECTED stored session now
   resumes that stored session instead of falling straight through to
   createBackendSessionForSend - which silently forked the user's
   conversation into a brand-new session that then got orphan-reaped.
   New-chat drafts (no stored selection) still create sessions as before.

2. prompt.submit recovery now also fires on gateway request timeouts,
   not only 'session not found'. A starved backend loop (the async-
   delegation poller spin) rejects the submit with 'request timed out'
   even though the stored session is fine; previously that surfaced an
   error, left the binding cleared, and set up the split on the next
   send.

Fail-then-pass: 2 new tests fail with production code reverted.

* fix(compression): stop compaction thrash — 75% trigger floor under 512K, no summary output cap, reasoning-trace exclusion (#60989)

Sessions on sub-512K-context models were spending most of their wall-clock
re-summarizing: the 50% trigger left too little post-compaction headroom
(the incompressible floor — system prompt, tool schemas, protected tail,
rolling summary — ate most of the reclaimed space), so compaction re-fired
every 1-2 turns. Three compounding defects fixed:

- Threshold floor: models with context windows below 512K now trigger at
  >=75% of the window (raise-only — a higher configured value or per-model
  autoraise like Codex gpt-5.5's 85% always wins). Re-derived on
  update_model() in both directions.
- No max_tokens on the summary call: the summary budget is prompt guidance
  only ("Target ~N tokens"). The wire cap truncated summaries mid-section
  on the Anthropic Messages / NVIDIA NIM paths (thinking models burn the
  cap on reasoning first), yielding truncated or thinking-only summaries
  and compaction loops. Summary token ceiling lowered 12K -> 10K to keep
  the guidance within the intended 1K-10K envelope.
- Reasoning traces excluded end-to-end: inline <think>/<reasoning> blocks
  are now stripped from assistant content before serialization to the
  summarizer, and from the summarizer's own output before the summary is
  stored (previously a thinking summarizer model's trace was persisted in
  _previous_summary and re-fed into every iterative update, compounding
  bloat). Native reasoning fields were already excluded.

Verified E2E with real imports against a temp HERMES_HOME: threshold table
across 64K-1M windows, override interactions (user 0.85 wins, spark 0.70
raised, gpt-5.5 0.85 kept), full compress() round-trip with a thinking
summarizer, and wire-kwargs capture proving no max_tokens is sent.

* docs(webhook): complete filters + route-scripts coverage across doc surfaces (#60983)

Follow-up to #60944 (webhook payload filters and route scripts):
- reference/cli-commands.md (en+zh): document the new --script option on
  'hermes webhook subscribe'
- zh-Hans user-guide webhooks.md: mirror the Payload Filters and Script
  Filters/Transforms sections plus the filters/script route properties
  (the salvage shipped English-only docs)
- hermes-agent skill webhooks reference: teach the agent the filters/
  script surface so agent-driven subscriptions can use them

* feat(xai): add grok-4.5 (GA) to model catalog, context lengths, and reasoning-effort allowlist (#60887)

* feat(xai): add grok-4.5 (early access) to catalog, context lengths, and reasoning-effort allowlist

- hermes_cli/models.py: grok-4.5 in _XAI_CURATED_EXTRAS (callable but absent
  from models.dev) and _XAI_STATIC_FALLBACK, so the /model picker and
  validation surface it on both xai and xai-oauth.
- agent/model_metadata.py: context lengths grok-4.5 -> 500K (per model card)
  and grok-build-latest -> 500K (alias); grok-4.5 added to
  _GROK_EFFORT_CAPABLE_PREFIXES.

Verified live against api.x.ai /v1/responses (2026-07-08): effort
low/medium/high accepted (server default: high), "none" rejected,
function calling works, full agent turn with terminal tool succeeded.

* feat(xai): grok-4.5 GA — add aggregator catalog entries, refresh comments

grok-4.5 is now GA: models.dev list…
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
…S-free) (NousResearch#60730)

For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway
obtain its caller-identity bearer from a generic OAuth2 client_credentials grant
against the operator's own IdP (e.g. Microsoft Entra ID) instead of only
resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim
(default tid) off that token as the tenant.

- gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials
  when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal
  (unchanged default). Wired into self_provision_relay().
- hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical
  resolver so the enroll CLI and the runtime self-provision path share ONE impl.

Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml
(env override GATEWAY_RELAY_IDP_*). No behaviour change when unset.

Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection,
request shape, config/env precedence, fail-closed). Relay suite 162 pass.

Validated via the cross-repo gateway<->connector live E2E (provision, managed
self-provision, inbound round-trip, /link) against a connector running the OIDC
tenant resolver with zero NAS config.
justemu pushed a commit to justemu/hermes-agent that referenced this pull request Jul 18, 2026
…S-free) (NousResearch#60730)

For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway
obtain its caller-identity bearer from a generic OAuth2 client_credentials grant
against the operator's own IdP (e.g. Microsoft Entra ID) instead of only
resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim
(default tid) off that token as the tenant.

- gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials
  when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal
  (unchanged default). Wired into self_provision_relay().
- hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical
  resolver so the enroll CLI and the runtime self-provision path share ONE impl.

Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml
(env override GATEWAY_RELAY_IDP_*). No behaviour change when unset.

Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection,
request shape, config/env precedence, fail-closed). Relay suite 162 pass.

Validated via the cross-repo gateway<->connector live E2E (provision, managed
self-provision, inbound round-trip, /link) against a connector running the OIDC
tenant resolver with zero NAS config.
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
…S-free) (NousResearch#60730)

For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway
obtain its caller-identity bearer from a generic OAuth2 client_credentials grant
against the operator's own IdP (e.g. Microsoft Entra ID) instead of only
resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim
(default tid) off that token as the tenant.

- gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials
  when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal
  (unchanged default). Wired into self_provision_relay().
- hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical
  resolver so the enroll CLI and the runtime self-provision path share ONE impl.

Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml
(env override GATEWAY_RELAY_IDP_*). No behaviour change when unset.

Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection,
request shape, config/env precedence, fail-closed). Relay suite 162 pass.

Validated via the cross-repo gateway<->connector live E2E (provision, managed
self-provision, inbound round-trip, /link) against a connector running the OIDC
tenant resolver with zero NAS config.
gabrielcosi pushed a commit to gabrielcosi/home-ops that referenced this pull request Jul 21, 2026
…7.7 ➔ v2026.7.20) (#10)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/gabrielcosi/hermes-agent](https://github.com/NousResearch/hermes-agent) | patch | `v2026.7.7` → `v2026.7.20` |

---

### Release Notes

<details>
<summary>NousResearch/hermes-agent (ghcr.io/gabrielcosi/hermes-agent)</summary>

### [`v2026.7.20`](https://github.com/NousResearch/hermes-agent/releases/tag/v2026.7.20): Hermes Agent v0.19.0 (2026.7.20) — The Quicksilver Release

[Compare Source](https://github.com/NousResearch/hermes-agent/compare/v2026.7.7...v2026.7.20)

##### Hermes Agent v0.19.0 (v2026.7.20)

**Release Date:** July 20, 2026
**Since v0.18.0:** \~2,245 commits · \~1,065 merged PRs · \~2,465 files changed · \~300,000 insertions · \~36,000 deletions · **\~3,300 issues closed** · **450+ community contributors**

> **The Quicksilver Release.** Hermes is the messenger god, and this window we made him move like it. First-turn time-to-first-token dropped **\~80% on every platform**, reasoning streams live by default, the desktop app got a \~20-PR speed overhaul (14× faster streaming markdown, virtualized diffs, snappy session switching), and the TUI renders markdown incrementally. Around that speed spine: you can now **manage your Nous subscription without leaving the terminal**, plug **Bitwarden and 1Password** straight into Hermes, let **smart approvals** judge flagged commands for you by default, **watch your subagents work live**, and trust that a finished response **survives a gateway crash** thanks to a durable delivery ledger. This release also rolls up everything from the v0.18.1 and v0.18.2 infrastructure patch tags — those windows are fully documented here.

***

##### ✨ Highlights

- **Hermes got dramatically faster — first token in a fraction of the time** — Cold-start "Initializing agent..." used to eat \~4.3 seconds before your first turn even reached the model; it's now \~0.9s, an \~80% cut that applies to the CLI, gateway, TUI, desktop, and cron alike. Round 2 attacked what you *see* while waiting: reasoning models now stream their thinking live by default (no more staring at a spinner for 30 seconds), and the response box paints per token instead of per line. If Hermes ever felt like it took a deep breath before answering, that breath is gone. ([#&#8203;59332](https://github.com/NousResearch/hermes-agent/pull/59332), [#&#8203;59389](https://github.com/NousResearch/hermes-agent/pull/59389) — [@&#8203;teknium1](https://github.com/teknium1))

- **The desktop app speed wave — 20+ targeted perf PRs** — Long replies used to cost 14× more CPU in the markdown splitter than they do now; giant diffs froze the review pane until we virtualized it; switching sessions thrashes layout no more. Streaming no longer re-renders the sidebar and every tool row per token, profile backends pre-warm on hover intent, and boot-hidden panes mount at idle instead of on the cold-start critical path. The net effect: the desktop app feels like a native app under load, even with huge transcripts and busy agents. ([#&#8203;67154](https://github.com/NousResearch/hermes-agent/pull/67154), [#&#8203;67818](https://github.com/NousResearch/hermes-agent/pull/67818), [#&#8203;65898](https://github.com/NousResearch/hermes-agent/pull/65898), [#&#8203;66033](https://github.com/NousResearch/hermes-agent/pull/66033), [#&#8203;66747](https://github.com/NousResearch/hermes-agent/pull/66747), [#&#8203;67742](https://github.com/NousResearch/hermes-agent/pull/67742) and more — [@&#8203;OutThisLife](https://github.com/OutThisLife))

- **Manage your Nous plan from the terminal — `/subscription` and `/topup`** — Changing your subscription used to mean a trip to the billing website. Now `/subscription` opens a full flow right in the TUI or classic CLI: see your plan and remaining allowance, preview exactly what an upgrade costs ("Pay $46.30 & upgrade now") or when a downgrade takes effect, and apply it — with scheduled-change banners and undo. The desktop app got a matching billing settings tab. Your wallet never has to leave the keyboard. ([#&#8203;51639](https://github.com/NousResearch/hermes-agent/pull/51639), [#&#8203;61054](https://github.com/NousResearch/hermes-agent/pull/61054), [#&#8203;61067](https://github.com/NousResearch/hermes-agent/pull/61067) — [@&#8203;alt-glitch](https://github.com/alt-glitch))

- **Smart approvals are now the default** — When Hermes wants to run a flagged command, an LLM reviewer now assesses it independently instead of asking you to approve every single one — and each verdict covers only that exact command, so a later command matching the same pattern gets its own review. Combined with the new **user-defined deny rules** (which block commands even under yolo mode) and `/deny <reason>` (which tells the agent *why* you refused so it course-corrects), day-to-day approval fatigue drops sharply without giving up control. ([#&#8203;62661](https://github.com/NousResearch/hermes-agent/pull/62661), [#&#8203;59164](https://github.com/NousResearch/hermes-agent/pull/59164), [#&#8203;54518](https://github.com/NousResearch/hermes-agent/pull/54518) — [@&#8203;teknium1](https://github.com/teknium1))

- **Plug your password manager into Hermes — Bitwarden & 1Password secret sources** — API keys no longer have to live in a plaintext `.env`. A new pluggable `SecretSource` interface lets Hermes fetch secrets from Bitwarden and 1Password (`op://` references) at load time, with multiple vaults enabled simultaneously, deterministic precedence, conflict warnings, and per-variable provenance. This consolidated eleven competing community PRs into one orchestrated interface — future vault providers drop in as plugins. ([#&#8203;59498](https://github.com/NousResearch/hermes-agent/pull/59498) — [@&#8203;teknium1](https://github.com/teknium1), 1Password provider salvaged from [@&#8203;hwrdprkns](https://github.com/hwrdprkns))

- **Watch your subagents work — live transcripts + durable background delegation** — `delegate_task` dispatches now return live transcript files you can `tail -f` the moment the subagents launch: every tool call, result, and streamed reply, one human-readable log per child. And background delegation completions are now **durable** — if the process restarts mid-run, results are restored and delivered through an ownership-checked ledger instead of vanishing. Fan out a fleet, watch any worker live, and never lose the results. ([#&#8203;67479](https://github.com/NousResearch/hermes-agent/pull/67479), [#&#8203;63494](https://github.com/NousResearch/hermes-agent/pull/63494) — [@&#8203;teknium1](https://github.com/teknium1))

- **A finished answer can no longer be lost — the delivery-obligation ledger** — If the gateway died between generating your response and confirming the platform actually delivered it, that answer used to be silently gone (and you'd paid for the turn). Final responses are now recorded in a durable ledger in `state.db` around the platform send and **redelivered on the next boot** — closing a P1 silent-loss window for Telegram, Discord, Slack, and every other channel. ([#&#8203;67181](https://github.com/NousResearch/hermes-agent/pull/67181) — [@&#8203;teknium1](https://github.com/teknium1))

- **One gateway, many profiles — profile-based message routing** — A single multiplexed gateway sharing one bot token can now route specific guilds, channels, or threads to different profiles — each with fully isolated config, skills, memory, and secrets. Point your work Discord server at the `work` profile and your hobby server at `personal`, from one bot. A second multiplex hardening wave means one misconfigured profile can no longer take down the whole gateway. ([#&#8203;64835](https://github.com/NousResearch/hermes-agent/pull/64835) salvaging [@&#8203;Burgunthy](https://github.com/Burgunthy), [#&#8203;65700](https://github.com/NousResearch/hermes-agent/pull/65700), [#&#8203;60589](https://github.com/NousResearch/hermes-agent/pull/60589) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;benbarclay](https://github.com/benbarclay) + six salvaged contributors)

- **New providers and the newest frontier models** — Fireworks AI and DeepInfra land as first-class providers (Fireworks with cost estimation and a [#&#8203;2](https://github.com/NousResearch/hermes-agent/issues/2) slot in the provider picker), Upstage Solar joins via salvage, and the model catalogs picked up **GPT-5.6 (Sol/Terra/Luna + Pro variants, wired end-to-end across every route)**, **grok-4.5 (GA)**, **moonshotai/kimi-k3**, **claude-fable-5 / claude-sonnet-5**, and GA **tencent/hy3** — plus LM Studio JIT model loading for local setups. ([#&#8203;62593](https://github.com/NousResearch/hermes-agent/pull/62593), [#&#8203;63969](https://github.com/NousResearch/hermes-agent/pull/63969), [#&#8203;61616](https://github.com/NousResearch/hermes-agent/pull/61616) — [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor) completing [@&#8203;rob-maron](https://github.com/rob-maron)'s [#&#8203;61578](https://github.com/NousResearch/hermes-agent/issues/61578), [#&#8203;60887](https://github.com/NousResearch/hermes-agent/pull/60887), [#&#8203;65913](https://github.com/NousResearch/hermes-agent/pull/65913), [#&#8203;64541](https://github.com/NousResearch/hermes-agent/pull/64541), [#&#8203;65472](https://github.com/NousResearch/hermes-agent/pull/65472))

- **Crank the thinking to max — new reasoning effort tiers and per-model control** — Reasoning effort gained `max` and `ultra` levels (GPT-5.6 and Codex's top tiers), selectable everywhere from the CLI to the desktop, with sane clamping on providers with smaller scales. You can now also pin **per-model reasoning-effort overrides** in config, set **per-slot effort in MoA presets** (your advisors think hard, your synthesizer stays fast), and per-task effort for auxiliary models. Thinking depth is now a dial, not a global switch. ([#&#8203;62650](https://github.com/NousResearch/hermes-agent/pull/62650), [#&#8203;64458](https://github.com/NousResearch/hermes-agent/pull/64458), [#&#8203;64631](https://github.com/NousResearch/hermes-agent/pull/64631), [#&#8203;64597](https://github.com/NousResearch/hermes-agent/pull/64597) — [@&#8203;teknium1](https://github.com/teknium1))

- **Your sessions, your data — export everything** — `hermes sessions export` now writes Markdown, Quarto, HTML, prompt-only, and even Hugging Face-ready trace formats, with the full filter surface (age, workspace, platform), an opt-in `--redact` secret-scrubbing pass, and compacted-session lineage stitched into one logical export. Pair with the new prune filters and bulk archive to keep your session store tidy. Your conversation history is a real dataset now, not a black box. ([#&#8203;60186](https://github.com/NousResearch/hermes-agent/pull/60186) salvaging [@&#8203;web3blind](https://github.com/web3blind), [#&#8203;60492](https://github.com/NousResearch/hermes-agent/pull/60492), [#&#8203;60507](https://github.com/NousResearch/hermes-agent/pull/60507), [#&#8203;59327](https://github.com/NousResearch/hermes-agent/pull/59327) — [@&#8203;teknium1](https://github.com/teknium1))

- **Security hardening round** — This window closed a long list of credential-surface gaps: Vertex credentials scoped away from subprocess env and through profile secret scopes, media/vision/image-gen local-file reads routed through one shared credential-read guard, a webhook body-size-cap sweep across every aiohttp server, bot-token redaction in Telegram transport errors, Fireworks token prefixes added to the redactor, six P1 browser/MEDIA/.env hardening PRs salvaged in one pass, and CI hardened against untrusted-ref interpolation. ([#&#8203;57660](https://github.com/NousResearch/hermes-agent/pull/57660), [#&#8203;58709](https://github.com/NousResearch/hermes-agent/pull/58709), [#&#8203;59215](https://github.com/NousResearch/hermes-agent/pull/59215), [#&#8203;56582](https://github.com/NousResearch/hermes-agent/pull/56582), [#&#8203;57842](https://github.com/NousResearch/hermes-agent/pull/57842) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;srojk34](https://github.com/srojk34), [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor), [@&#8203;jquesnelle](https://github.com/jquesnelle))

***

##### ⚡ Performance — the speed spine

##### First-turn latency (all platforms)

- **\~80% TTFT cut** — Discord capability detection off the critical path (token-keyed 24h disk cache + background refresh), Ollama probe skipped for known non-Ollama providers, agent-init blocking work removed; cold submit→dispatch \~4.3s → \~0.9s ([#&#8203;59332](https://github.com/NousResearch/hermes-agent/pull/59332) — [@&#8203;teknium1](https://github.com/teknium1))
- **Perceived-latency round 2** — `display.show_reasoning` default ON (watch the model think instead of a spinner), per-token response-box painting with width-aware force-flush, prompt-build caching, mtime-cached timezone resolution ([#&#8203;59389](https://github.com/NousResearch/hermes-agent/pull/59389) — [@&#8203;teknium1](https://github.com/teknium1))
- Segment mixed tool batches to recover lost concurrency; drop per-call base64 re-serialization from request-size estimates ([#&#8203;64460](https://github.com/NousResearch/hermes-agent/pull/64460), [#&#8203;67788](https://github.com/NousResearch/hermes-agent/pull/67788) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;OutThisLife](https://github.com/OutThisLife))

##### Desktop speed wave

- 14× less splitter CPU via incremental block lexing for streaming markdown; virtualized review-pane diffs (no more full-Shiki freeze); snappy session switching on large transcripts; killed the layout-thrash cascade on session switch ([#&#8203;67154](https://github.com/NousResearch/hermes-agent/pull/67154), [#&#8203;67818](https://github.com/NousResearch/hermes-agent/pull/67818), [#&#8203;65898](https://github.com/NousResearch/hermes-agent/pull/65898), [#&#8203;66033](https://github.com/NousResearch/hermes-agent/pull/66033) — [@&#8203;OutThisLife](https://github.com/OutThisLife))
- Cut startup serialization + per-turn REST amplification; pre-warm profile backends and gateway sockets on hover intent; idle-mount boot-hidden panes; fast model picker + dialogs ([#&#8203;66747](https://github.com/NousResearch/hermes-agent/pull/66747), [#&#8203;66347](https://github.com/NousResearch/hermes-agent/pull/66347), [#&#8203;67857](https://github.com/NousResearch/hermes-agent/pull/67857), [#&#8203;66470](https://github.com/NousResearch/hermes-agent/pull/66470) — [@&#8203;OutThisLife](https://github.com/OutThisLife))
- Stop per-token sidebar + tool-row re-renders during streaming; stop eager JSON.stringify of every tool's args/result; scope tool-diff subscriptions; batch sidebar session slices into one profile-DB pass; targeted file-tree revalidation; rAF-coalesced sash resizes ([#&#8203;67742](https://github.com/NousResearch/hermes-agent/pull/67742), [#&#8203;67842](https://github.com/NousResearch/hermes-agent/pull/67842), [#&#8203;67195](https://github.com/NousResearch/hermes-agent/pull/67195), [#&#8203;67245](https://github.com/NousResearch/hermes-agent/pull/67245), [#&#8203;67824](https://github.com/NousResearch/hermes-agent/pull/67824), [#&#8203;67838](https://github.com/NousResearch/hermes-agent/pull/67838), [#&#8203;67844](https://github.com/NousResearch/hermes-agent/pull/67844) — [@&#8203;OutThisLife](https://github.com/OutThisLife))
- Systematized perf benchmark harness with trustworthy cold-start + first-token measurement, replacing 12 one-off scripts ([#&#8203;67466](https://github.com/NousResearch/hermes-agent/pull/67466), [#&#8203;67697](https://github.com/NousResearch/hermes-agent/pull/67697) — [@&#8203;OutThisLife](https://github.com/OutThisLife))

##### Everywhere else

- TUI renders streamed markdown incrementally per block ([#&#8203;67236](https://github.com/NousResearch/hermes-agent/pull/67236) — [@&#8203;OutThisLife](https://github.com/OutThisLife))
- Skill discovery cached by scan signature; snapshot manifest builds \~5× faster; text prefilter before AST parse in tool discovery ([#&#8203;61414](https://github.com/NousResearch/hermes-agent/pull/61414), [#&#8203;61131](https://github.com/NousResearch/hermes-agent/pull/61131), [#&#8203;63941](https://github.com/NousResearch/hermes-agent/pull/63941) — [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor), [@&#8203;ethernet8023](https://github.com/ethernet8023))
- Copy-on-write message prep instead of full deepcopy; model-metadata probe-cache cluster; gateway `session.resume` model + display history from one SELECT ([#&#8203;61133](https://github.com/NousResearch/hermes-agent/pull/61133), [#&#8203;61368](https://github.com/NousResearch/hermes-agent/pull/61368), [#&#8203;67247](https://github.com/NousResearch/hermes-agent/pull/67247) — [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor), [@&#8203;OutThisLife](https://github.com/OutThisLife))
- `hermes update` skips npm install when Node manifests are unchanged; dashboard session-list payloads trimmed + messages paginated ([#&#8203;61580](https://github.com/NousResearch/hermes-agent/pull/61580), [#&#8203;60883](https://github.com/NousResearch/hermes-agent/pull/60883) — [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor))
- Byte-stable gateway system prompts — pinned session-context render keeps the prompt cache alive across turns ([#&#8203;67403](https://github.com/NousResearch/hermes-agent/pull/67403) — [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor))

##### 🏗️ Core Agent & Architecture

##### Providers & models

- **Fireworks AI provider** with cost estimation + cached picker price columns, promoted to [#&#8203;2](https://github.com/NousResearch/hermes-agent/issues/2) in provider pickers ([#&#8203;62593](https://github.com/NousResearch/hermes-agent/pull/62593), [#&#8203;65476](https://github.com/NousResearch/hermes-agent/pull/65476), [#&#8203;65214](https://github.com/NousResearch/hermes-agent/pull/65214) — [@&#8203;teknium1](https://github.com/teknium1))
- **DeepInfra** hardened integration; **Upstage Solar** provider ([#&#8203;42231](https://github.com/NousResearch/hermes-agent/issues/42231) salvage) ([#&#8203;63969](https://github.com/NousResearch/hermes-agent/pull/63969), [#&#8203;64541](https://github.com/NousResearch/hermes-agent/pull/64541) — [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor))
- **GPT-5.6 (Sol/Terra/Luna + Pro) end-to-end** — context lengths, native/Codex catalogs, pricing, compaction caps across every route ([#&#8203;61616](https://github.com/NousResearch/hermes-agent/pull/61616) — [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor), building on [@&#8203;rob-maron](https://github.com/rob-maron))
- grok-4.5 (GA) catalog + reasoning allowlist; kimi-k3 on Nous Portal + OpenRouter (kimi-k2.x retired) + K3 discovery on the Kimi Coding endpoint; claude-fable-5 / claude-sonnet-5 / fugu-ultra curated; GA tencent/hy3 ([#&#8203;60887](https://github.com/NousResearch/hermes-agent/pull/60887), [#&#8203;65913](https://github.com/NousResearch/hermes-agent/pull/65913), [#&#8203;65922](https://github.com/NousResearch/hermes-agent/pull/65922), [#&#8203;56617](https://github.com/NousResearch/hermes-agent/pull/56617), [#&#8203;60943](https://github.com/NousResearch/hermes-agent/pull/60943) — [@&#8203;teknium1](https://github.com/teknium1))
- Catalog-labeled silent default (GLM-5.2) + bare-provider `/model` cost-safe routing; LM Studio JIT load mode; adaptive thinking for Kimi-family Anthropic endpoints ([#&#8203;64771](https://github.com/NousResearch/hermes-agent/pull/64771), [#&#8203;65472](https://github.com/NousResearch/hermes-agent/pull/65472), [#&#8203;67606](https://github.com/NousResearch/hermes-agent/pull/67606) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor))
- GLM-5.2 native reasoning\_effort controls; Gemini request-context improvements; extra HTTP headers for LLM API calls; per-client model routing on the API server ([#&#8203;58884](https://github.com/NousResearch/hermes-agent/pull/58884), [#&#8203;61873](https://github.com/NousResearch/hermes-agent/pull/61873) — [@&#8203;vishal-dharm](https://github.com/vishal-dharm), [#&#8203;57038](https://github.com/NousResearch/hermes-agent/pull/57038), [#&#8203;57028](https://github.com/NousResearch/hermes-agent/pull/57028) — [@&#8203;teknium1](https://github.com/teknium1))
- **Claude Sonnet 5 fully wired** — curated lists, intro pricing, and metadata across every route ([#&#8203;67932](https://github.com/NousResearch/hermes-agent/pull/67932) — [@&#8203;teknium1](https://github.com/teknium1))
- **Hide providers you don't use** — `enabled: false` per-provider flag + `excluded_providers` config scrub unwanted providers from `/model` pickers and built-in resolution ([#&#8203;67971](https://github.com/NousResearch/hermes-agent/pull/67971) — [@&#8203;teknium1](https://github.com/teknium1))
- Bedrock catalog wave: real context-window probing from the live endpoint, 1M-context rows for current-gen Claude + Fable, geo-prefix parity, versioned profile-ID pricing, Opus 4.8/4.7 rows ([#&#8203;68007](https://github.com/NousResearch/hermes-agent/pull/68007), [#&#8203;67977](https://github.com/NousResearch/hermes-agent/pull/67977), [#&#8203;68005](https://github.com/NousResearch/hermes-agent/pull/68005), [#&#8203;67976](https://github.com/NousResearch/hermes-agent/pull/67976) — [@&#8203;teknium1](https://github.com/teknium1))
- kimi-k3 rollout completed across Kimi-direct catalog surfaces with 1M context on canonical Kimi Coding endpoints ([#&#8203;68108](https://github.com/NousResearch/hermes-agent/pull/68108) — [@&#8203;teknium1](https://github.com/teknium1))
- Provider pickers: Qwen providers folded into one group row; collapsible provider groups in the desktop model picker; friendlier TUI model display grouping same-endpoint providers ([#&#8203;67758](https://github.com/NousResearch/hermes-agent/pull/67758), [#&#8203;67904](https://github.com/NousResearch/hermes-agent/pull/67904), [#&#8203;67908](https://github.com/NousResearch/hermes-agent/pull/67908) — [@&#8203;teknium1](https://github.com/teknium1))

##### Reasoning & MoA

- `max` + `ultra` effort levels across every surface and route ([#&#8203;62650](https://github.com/NousResearch/hermes-agent/pull/62650) — [@&#8203;teknium1](https://github.com/teknium1))
- Per-model reasoning\_effort overrides via a unified resolution chokepoint; per-task auxiliary effort; per-slot MoA preset effort; session-scoped `/reasoning` in the CLI ([#&#8203;64458](https://github.com/NousResearch/hermes-agent/pull/64458), [#&#8203;64597](https://github.com/NousResearch/hermes-agent/pull/64597), [#&#8203;64631](https://github.com/NousResearch/hermes-agent/pull/64631), [#&#8203;67946](https://github.com/NousResearch/hermes-agent/pull/67946) — [@&#8203;teknium1](https://github.com/teknium1))
- MoA: `reference_max_tokens` to cap advisor output and cut latency; per-preset fanout cadence (`user_turn` runs advisors once per user turn); stale presets surfaced without retries; half-filled preset saves rejected at the API boundary; aggregator resolves reasoning like an acting model ([#&#8203;56756](https://github.com/NousResearch/hermes-agent/pull/56756), [#&#8203;57591](https://github.com/NousResearch/hermes-agent/pull/57591), [#&#8203;64756](https://github.com/NousResearch/hermes-agent/pull/64756) — [@&#8203;teknium1](https://github.com/teknium1))

##### Delegation, approvals & the agent loop

- Live subagent transcripts + durable background completions (see Highlights) ([#&#8203;67479](https://github.com/NousResearch/hermes-agent/pull/67479), [#&#8203;63494](https://github.com/NousResearch/hermes-agent/pull/63494) — [@&#8203;teknium1](https://github.com/teknium1))
- Smart approvals default; user-defined deny rules (block even under yolo); `/deny <reason>` relays the denial reason; plugin `pre_tool_call` approve action escalates to a human gate (re-landed with rule keys) ([#&#8203;62661](https://github.com/NousResearch/hermes-agent/pull/62661), [#&#8203;59164](https://github.com/NousResearch/hermes-agent/pull/59164), [#&#8203;54518](https://github.com/NousResearch/hermes-agent/pull/54518), [#&#8203;60504](https://github.com/NousResearch/hermes-agent/pull/60504) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor))
- Unified delegation concurrency caps (`max_async_children` deprecated); explain long provider waits on the live status line; deterministic tool-output risk exposure ([#&#8203;56955](https://github.com/NousResearch/hermes-agent/pull/56955), [#&#8203;64775](https://github.com/NousResearch/hermes-agent/pull/64775), [#&#8203;61793](https://github.com/NousResearch/hermes-agent/pull/61793) — [@&#8203;teknium1](https://github.com/teknium1))
- Codex: live TUI/desktop tool cards for the app-server runtime, commentary streamed as visible interim messages, compaction routed through `thread/compact/start`, max-output truncation recovery, oversized message ids dropped on replay, banked usage-limit resets via `/usage reset` ([#&#8203;66514](https://github.com/NousResearch/hermes-agent/pull/66514), [#&#8203;66115](https://github.com/NousResearch/hermes-agent/pull/66115), [#&#8203;60114](https://github.com/NousResearch/hermes-agent/pull/60114), [#&#8203;58155](https://github.com/NousResearch/hermes-agent/pull/58155), [#&#8203;62225](https://github.com/NousResearch/hermes-agent/pull/62225) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor), [@&#8203;JoaoMarcos44](https://github.com/JoaoMarcos44), [#&#8203;64280](https://github.com/NousResearch/hermes-agent/pull/64280) — [@&#8203;teknium1](https://github.com/teknium1))
- Hooks: oversized hook-injected context spills to disk ([#&#8203;20468](https://github.com/NousResearch/hermes-agent/pull/20468) — [@&#8203;teknium1](https://github.com/teknium1))
- Vibe reactions — floating hearts on affection across CLI/TUI/desktop, token-free core detection ([#&#8203;62016](https://github.com/NousResearch/hermes-agent/pull/62016) — [@&#8203;OutThisLife](https://github.com/OutThisLife))

##### Secrets & config

- Pluggable `SecretSource` interface + Bitwarden & 1Password providers (see Highlights) ([#&#8203;59498](https://github.com/NousResearch/hermes-agent/pull/59498) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;hwrdprkns](https://github.com/hwrdprkns))
- `hermes config get` / `unset`; warn on unknown root config keys + doctor deprecated-key reporting; `display.timestamp_format` ([#&#8203;65540](https://github.com/NousResearch/hermes-agent/pull/65540), [#&#8203;67370](https://github.com/NousResearch/hermes-agent/pull/67370), [#&#8203;40622](https://github.com/NousResearch/hermes-agent/pull/40622) — [@&#8203;teknium1](https://github.com/teknium1))
- Auxiliary model usage recorded per task in session accounting; conversation-scoped Nous Portal usage tags across aux/MoA/delegate calls; `--usage-file` JSON report for `hermes -z` ([#&#8203;65537](https://github.com/NousResearch/hermes-agent/pull/65537), [#&#8203;65468](https://github.com/NousResearch/hermes-agent/pull/65468), [#&#8203;59615](https://github.com/NousResearch/hermes-agent/pull/59615) — [@&#8203;teknium1](https://github.com/teknium1))

##### Sessions & compression

- Sessions export: Markdown/QMD/HTML/prompt-only/trace formats, HF upload, `--redact`, unified filters; full prune filter surface + bulk archive; CLI workspace filter + restore-cwd-on-resume ([#&#8203;60186](https://github.com/NousResearch/hermes-agent/pull/60186), [#&#8203;60492](https://github.com/NousResearch/hermes-agent/pull/60492), [#&#8203;60507](https://github.com/NousResearch/hermes-agent/pull/60507), [#&#8203;59327](https://github.com/NousResearch/hermes-agent/pull/59327), [#&#8203;63091](https://github.com/NousResearch/hermes-agent/pull/63091) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;web3blind](https://github.com/web3blind))
- Compression: preserve human intent and durable handoffs; retain prompt cache when memory is unchanged; flatten multimodal content for the summarizer keeping image handles; gateway compression routing integrity ([#&#8203;67275](https://github.com/NousResearch/hermes-agent/pull/67275), [#&#8203;67916](https://github.com/NousResearch/hermes-agent/pull/67916), [#&#8203;65046](https://github.com/NousResearch/hermes-agent/pull/65046), [#&#8203;56868](https://github.com/NousResearch/hermes-agent/pull/56868) — [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor), [@&#8203;teknium1](https://github.com/teknium1))
- Gateway session metadata consolidated into state.db; routing index moved to state.db (sessions.json now an optional legacy mirror); exact API bytes persisted in an `api_content` sidecar ([#&#8203;58899](https://github.com/NousResearch/hermes-agent/pull/58899), [#&#8203;59203](https://github.com/NousResearch/hermes-agent/pull/59203), [#&#8203;67274](https://github.com/NousResearch/hermes-agent/pull/67274) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor))

##### 🌐 Gateway, Fleet & Relay

- **Durable delivery-obligation ledger** for final responses (see Highlights) ([#&#8203;67181](https://github.com/NousResearch/hermes-agent/pull/67181) — [@&#8203;teknium1](https://github.com/teknium1))
- **Profile-based routing for inbound messages** + multiplex hardening wave 2 + `GATEWAY_MULTIPLEX_PROFILES` override (see Highlights) ([#&#8203;64835](https://github.com/NousResearch/hermes-agent/pull/64835), [#&#8203;65700](https://github.com/NousResearch/hermes-agent/pull/65700), [#&#8203;60589](https://github.com/NousResearch/hermes-agent/pull/60589) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;benbarclay](https://github.com/benbarclay) + salvaged contributors)
- Per-session turn lease + conversation-scope funnel; unified session reset boundaries (reset sessions stay reset); truthful runtime readiness checks; per-channel model and system prompt overrides; per-session `/model` overrides persist across restarts ([#&#8203;67401](https://github.com/NousResearch/hermes-agent/pull/67401), [#&#8203;65783](https://github.com/NousResearch/hermes-agent/pull/65783), [#&#8203;62645](https://github.com/NousResearch/hermes-agent/pull/62645), [#&#8203;56967](https://github.com/NousResearch/hermes-agent/pull/56967), [#&#8203;57030](https://github.com/NousResearch/hermes-agent/pull/57030) — [@&#8203;teknium1](https://github.com/teknium1))
- Session auto-reset default off; `/sessions search <query>`; webhook payload filters + route scripts; platform HTTP event callback routing; configurable long-running status phrases ([#&#8203;60194](https://github.com/NousResearch/hermes-agent/pull/60194), [#&#8203;57685](https://github.com/NousResearch/hermes-agent/pull/57685), [#&#8203;60944](https://github.com/NousResearch/hermes-agent/pull/60944), [#&#8203;65702](https://github.com/NousResearch/hermes-agent/pull/65702), [#&#8203;58872](https://github.com/NousResearch/hermes-agent/pull/58872) — [@&#8203;teknium1](https://github.com/teknium1))
- Relay: generic OIDC client-credentials provisioning (NAS-free), routed profile carried from the connector wire source, channel context consumed from the connector; Nous auth forensics + `nous_session_valid` on `/api/status` for hosted self-heal; Docker re-seeds a terminally-dead Nous bootstrap session on boot ([#&#8203;60730](https://github.com/NousResearch/hermes-agent/pull/60730), [#&#8203;60586](https://github.com/NousResearch/hermes-agent/pull/60586), [#&#8203;64649](https://github.com/NousResearch/hermes-agent/pull/64649), [#&#8203;59976](https://github.com/NousResearch/hermes-agent/pull/59976), [#&#8203;59969](https://github.com/NousResearch/hermes-agent/pull/59969), [#&#8203;59983](https://github.com/NousResearch/hermes-agent/pull/59983) — [@&#8203;benbarclay](https://github.com/benbarclay))

##### 📱 Messaging Platforms

- **Inline choice pickers** for `/reasoning` and `/fast` on Telegram, Discord, and Matrix — one-tap native buttons instead of typing ([#&#8203;65799](https://github.com/NousResearch/hermes-agent/pull/65799) — [@&#8203;teknium1](https://github.com/teknium1))
- WhatsApp: native Baileys polls (clarify renders as a poll), locations, rich inbound metadata; dashboard pairing flow ([#&#8203;58865](https://github.com/NousResearch/hermes-agent/pull/58865), [#&#8203;60571](https://github.com/NousResearch/hermes-agent/pull/60571) — [@&#8203;teknium1](https://github.com/teknium1))
- Discord: recover messages missed during reconnect; auto-created threads renamed to generated session titles; configurable interactive view timeout; opt-in owner mentions on exec-approval prompts; optional admin-only gate for approval buttons ([#&#8203;66149](https://github.com/NousResearch/hermes-agent/pull/66149), [#&#8203;60187](https://github.com/NousResearch/hermes-agent/pull/60187), [#&#8203;60230](https://github.com/NousResearch/hermes-agent/pull/60230), [#&#8203;60493](https://github.com/NousResearch/hermes-agent/pull/60493), [#&#8203;51751](https://github.com/NousResearch/hermes-agent/pull/51751) — [@&#8203;teknium1](https://github.com/teknium1))
- Slack: live per-tool status line ([#&#8203;67080](https://github.com/NousResearch/hermes-agent/pull/67080) — [@&#8203;teknium1](https://github.com/teknium1), salvaging [#&#8203;62007](https://github.com/NousResearch/hermes-agent/issues/62007))
- Telegram: per-topic free-response allowlist; Google Chat clarify prompts rendered as cards ([#&#8203;65543](https://github.com/NousResearch/hermes-agent/pull/65543), [#&#8203;65546](https://github.com/NousResearch/hermes-agent/pull/65546) — [@&#8203;teknium1](https://github.com/teknium1))
- Voice: `stt.echo_transcripts` toggle; MEDIA: captions attached to the media bubble on standalone sends; `display.tool_progress: log` option ([#&#8203;58859](https://github.com/NousResearch/hermes-agent/pull/58859), [#&#8203;61415](https://github.com/NousResearch/hermes-agent/pull/61415), [#&#8203;57014](https://github.com/NousResearch/hermes-agent/pull/57014) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor))

##### 🖥️ Hermes Desktop App

- **Contribution-driven shell on a layout-tree model** — panes, zones, and layouts as data; plugin-scoped i18n locale bundles followed ([#&#8203;60638](https://github.com/NousResearch/hermes-agent/pull/60638), [#&#8203;67303](https://github.com/NousResearch/hermes-agent/pull/67303) — [@&#8203;OutThisLife](https://github.com/OutThisLife))
- **Capabilities page** — Skills/Tools/MCP + Hub in one place, with responsive overlay nav; CLI/dashboard parity for skills hub, MCP test/toggle/catalog, maintenance ops, log filters; five UX fixes from live testing ([#&#8203;57590](https://github.com/NousResearch/hermes-agent/pull/57590), [#&#8203;57441](https://github.com/NousResearch/hermes-agent/pull/57441), [#&#8203;67482](https://github.com/NousResearch/hermes-agent/pull/67482) — [@&#8203;OutThisLife](https://github.com/OutThisLife), [@&#8203;teknium1](https://github.com/teknium1))
- **Hermes Cloud connection mode** (salvage of [#&#8203;55402](https://github.com/NousResearch/hermes-agent/issues/55402)); soft gateway switch + gateway-settings polish; terminal execution backend picker with health probes ([#&#8203;61912](https://github.com/NousResearch/hermes-agent/pull/61912), [#&#8203;61916](https://github.com/NousResearch/hermes-agent/pull/61916), [#&#8203;67203](https://github.com/NousResearch/hermes-agent/pull/67203) — [@&#8203;OutThisLife](https://github.com/OutThisLife), [@&#8203;teknium1](https://github.com/teknium1))
- Keybind hint tooltips + keybinds settings tab + unified worktree dialog; base-branch picker for new worktrees; green unread dot for background-finished sessions; background-task sidebar indicators; grouped tool calls across text-less messages; auto-scrolling window for long tool-call runs ([#&#8203;65204](https://github.com/NousResearch/hermes-agent/pull/65204), [#&#8203;62243](https://github.com/NousResearch/hermes-agent/pull/62243), [#&#8203;65109](https://github.com/NousResearch/hermes-agent/pull/65109), [#&#8203;65174](https://github.com/NousResearch/hermes-agent/pull/65174), [#&#8203;61147](https://github.com/NousResearch/hermes-agent/pull/61147), [#&#8203;57913](https://github.com/NousResearch/hermes-agent/pull/57913) — [@&#8203;ethernet8023](https://github.com/ethernet8023), [@&#8203;OutThisLife](https://github.com/OutThisLife))
- Session + project color system (inherit from project, per-session override, shared across sidebar/tabs); unified active-project identity in chat status; workspace path status action ([#&#8203;67469](https://github.com/NousResearch/hermes-agent/pull/67469), [#&#8203;67681](https://github.com/NousResearch/hermes-agent/pull/67681), [#&#8203;67282](https://github.com/NousResearch/hermes-agent/pull/67282), [#&#8203;63086](https://github.com/NousResearch/hermes-agent/pull/63086) — [@&#8203;OutThisLife](https://github.com/OutThisLife))
- Declarative memory-provider panel + full-config modal; config-defined TTS/STT providers + xAI TTS params; custom endpoint settings; per-job cron model picker; profile-aware approval mode control; UI scale setting; Ctrl/Cmd+wheel zoom; chat backdrop toggle; `/journey` opens the memory graph overlay ([#&#8203;67206](https://github.com/NousResearch/hermes-agent/pull/67206) salvaging [@&#8203;erosika](https://github.com/erosika), [#&#8203;67209](https://github.com/NousResearch/hermes-agent/pull/67209), [#&#8203;67759](https://github.com/NousResearch/hermes-agent/pull/67759) — [@&#8203;austinpickett](https://github.com/austinpickett), [#&#8203;67472](https://github.com/NousResearch/hermes-agent/pull/67472), [#&#8203;63520](https://github.com/NousResearch/hermes-agent/pull/63520), [#&#8203;60457](https://github.com/NousResearch/hermes-agent/pull/60457), [#&#8203;67029](https://github.com/NousResearch/hermes-agent/pull/67029), [#&#8203;64598](https://github.com/NousResearch/hermes-agent/pull/64598), [#&#8203;57267](https://github.com/NousResearch/hermes-agent/pull/57267) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;OutThisLife](https://github.com/OutThisLife))
- Full TypeScript conversion of the desktop tree ([#&#8203;57855](https://github.com/NousResearch/hermes-agent/pull/57855) — [@&#8203;ethernet8023](https://github.com/ethernet8023))

##### 📊 Web Dashboard

- Memory provider switching; safe session import flow; WhatsApp pairing; Discord-specific toolsets editable from the web UI; clarified manual Telegram bot setup ([#&#8203;60569](https://github.com/NousResearch/hermes-agent/pull/60569), [#&#8203;63699](https://github.com/NousResearch/hermes-agent/pull/63699), [#&#8203;60571](https://github.com/NousResearch/hermes-agent/pull/60571), [#&#8203;65361](https://github.com/NousResearch/hermes-agent/pull/65361), [#&#8203;64636](https://github.com/NousResearch/hermes-agent/pull/64636) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor), [@&#8203;shannonsands](https://github.com/shannonsands))
- Terminal keep-alive + reattach for dashboard chat sessions; heavy turns isolated in a compute host; paste/drop images into Chat; `browser.headed` schema toggle; profile + gateway topology on `/api/status`; mobile/hosted OpenAI OAuth login ([#&#8203;60515](https://github.com/NousResearch/hermes-agent/pull/60515), [#&#8203;65895](https://github.com/NousResearch/hermes-agent/pull/65895), [#&#8203;61929](https://github.com/NousResearch/hermes-agent/pull/61929), [#&#8203;67046](https://github.com/NousResearch/hermes-agent/pull/67046), [#&#8203;60537](https://github.com/NousResearch/hermes-agent/pull/60537), [#&#8203;61330](https://github.com/NousResearch/hermes-agent/pull/61330) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;OutThisLife](https://github.com/OutThisLife), [@&#8203;benbarclay](https://github.com/benbarclay))
- `hermes serve` is a true headless backend (no web UI build/mount) ([#&#8203;55923](https://github.com/NousResearch/hermes-agent/pull/55923) — [@&#8203;OutThisLife](https://github.com/OutThisLife))

##### 🧰 CLI & TUI

- `/subscription` + `/topup` terminal billing (see Highlights) ([#&#8203;51639](https://github.com/NousResearch/hermes-agent/pull/51639) — [@&#8203;alt-glitch](https://github.com/alt-glitch))
- **`/model --once`** — one-turn model override that reverts automatically ([#&#8203;67113](https://github.com/NousResearch/hermes-agent/pull/67113) — [@&#8203;teknium1](https://github.com/teknium1), salvaging [#&#8203;29923](https://github.com/NousResearch/hermes-agent/issues/29923))
- **Stacked slash-skill invocations** — `/skill-a /skill-b do XYZ` loads both skills in order (Claude Code port), with autocomplete + ghost text ([#&#8203;57987](https://github.com/NousResearch/hermes-agent/pull/57987), [#&#8203;58763](https://github.com/NousResearch/hermes-agent/pull/58763) — [@&#8203;teknium1](https://github.com/teknium1))
- `--safe-mode` troubleshooting flag; uninstall dry-run; TLS failures fail fast with fix hints; `/compact` alias + preview flags; pip/Homebrew installs warned unsupported ([#&#8203;45300](https://github.com/NousResearch/hermes-agent/pull/45300), [#&#8203;60111](https://github.com/NousResearch/hermes-agent/pull/60111), [#&#8203;57992](https://github.com/NousResearch/hermes-agent/pull/57992), [#&#8203;57029](https://github.com/NousResearch/hermes-agent/pull/57029), [#&#8203;57225](https://github.com/NousResearch/hermes-agent/pull/57225) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;ethernet8023](https://github.com/ethernet8023))
- TUI: model picker refresh support; custom skill bundles dispatched as agent turns; banner sizes skills display to terminal width ([#&#8203;59782](https://github.com/NousResearch/hermes-agent/pull/59782) — [@&#8203;helix4u](https://github.com/helix4u), [#&#8203;62859](https://github.com/NousResearch/hermes-agent/pull/62859) — [@&#8203;Adolanium](https://github.com/Adolanium), [#&#8203;40624](https://github.com/NousResearch/hermes-agent/pull/40624) — [@&#8203;teknium1](https://github.com/teknium1))
- Hermes Console REPL + perf follow-ups; `hermes curator usage` all-skills view; entry-point plugins surfaced in `hermes plugins list` ([#&#8203;57781](https://github.com/NousResearch/hermes-agent/pull/57781) — [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor), [#&#8203;36727](https://github.com/NousResearch/hermes-agent/pull/36727), [#&#8203;40623](https://github.com/NousResearch/hermes-agent/pull/40623) — [@&#8203;teknium1](https://github.com/teknium1))

##### 🔧 Tool System, Skills & MCP

- MCP: `mcp__server__tool` naming convention; server log notifications surfaced in agent.log; hosted OAuth completed across Dashboard + Desktop; configurable `redirect_uri`/`redirect_host` for proxied/WAF setups; OAuth callback port races closed; Blender added to the MCP catalog with a curated 4-tool default ([#&#8203;52750](https://github.com/NousResearch/hermes-agent/pull/52750), [#&#8203;57416](https://github.com/NousResearch/hermes-agent/pull/57416), [#&#8203;66151](https://github.com/NousResearch/hermes-agent/pull/66151), [#&#8203;65610](https://github.com/NousResearch/hermes-agent/pull/65610), [#&#8203;65622](https://github.com/NousResearch/hermes-agent/pull/65622), [#&#8203;64463](https://github.com/NousResearch/hermes-agent/pull/64463) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;benbarclay](https://github.com/benbarclay))
- Skills: `security/unbroker` (autonomous data-broker removal) + blind opt-out hardening; `unreal-mcp` companion skill; blender-mcp reworked around the catalog entry; humanizer pattern expansion; `mcp-oauth-remote-gateway` optional skill ([#&#8203;57438](https://github.com/NousResearch/hermes-agent/pull/57438), [#&#8203;57902](https://github.com/NousResearch/hermes-agent/pull/57902), [#&#8203;65989](https://github.com/NousResearch/hermes-agent/pull/65989), [#&#8203;64715](https://github.com/NousResearch/hermes-agent/pull/64715) — [@&#8203;SHL0MS](https://github.com/SHL0MS), [#&#8203;65066](https://github.com/NousResearch/hermes-agent/pull/65066), [#&#8203;65486](https://github.com/NousResearch/hermes-agent/pull/65486) — [@&#8203;teknium1](https://github.com/teknium1))
- Browser: full snapshots stored on truncation, eval denylist opt-in; computer\_use follows cua-driver's verify→escalate ladder ([#&#8203;65923](https://github.com/NousResearch/hermes-agent/pull/65923), [#&#8203;67123](https://github.com/NousResearch/hermes-agent/pull/67123) — [@&#8203;teknium1](https://github.com/teknium1))
- Kanban: modal create-task dialog + editable board project directory; Done-card results made obvious; grab-to-pan board scrolling; attachment toolset + CLI with SSRF-guarded URL fetch; project directory captured at board creation ([#&#8203;66333](https://github.com/NousResearch/hermes-agent/pull/66333), [#&#8203;63638](https://github.com/NousResearch/hermes-agent/pull/63638), [#&#8203;60226](https://github.com/NousResearch/hermes-agent/pull/60226), [#&#8203;65698](https://github.com/NousResearch/hermes-agent/pull/65698), [#&#8203;63249](https://github.com/NousResearch/hermes-agent/pull/63249) — [@&#8203;teknium1](https://github.com/teknium1))
- Cron: durable execution audit history; one-shot stale-removal race fixed; run-claim TTL derived from HERMES\_CRON\_TIMEOUT ([#&#8203;61791](https://github.com/NousResearch/hermes-agent/pull/61791) — [@&#8203;teknium1](https://github.com/teknium1), [#&#8203;62014](https://github.com/NousResearch/hermes-agent/pull/62014) — [@&#8203;PRATHAMESH75](https://github.com/PRATHAMESH75), [#&#8203;59567](https://github.com/NousResearch/hermes-agent/pull/59567))
- mem0: self-hosted dashboard backend + recall tuning + setup-wizard mode ([#&#8203;56943](https://github.com/NousResearch/hermes-agent/pull/56943), [#&#8203;60494](https://github.com/NousResearch/hermes-agent/pull/60494) — [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor), [@&#8203;teknium1](https://github.com/teknium1))
- Image gen: Codex image inputs; unsupported Codex image accounts classified; tool args recursively normalized by schema (cline port) ([#&#8203;57017](https://github.com/NousResearch/hermes-agent/pull/57017), [#&#8203;63627](https://github.com/NousResearch/hermes-agent/pull/63627), [#&#8203;52220](https://github.com/NousResearch/hermes-agent/pull/52220) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor))

##### 🔒 Security & Reliability

- Vertex: credential/project/region resolution through the profile secret scope; `VERTEX_CREDENTIALS_PATH`/`GOOGLE_APPLICATION_CREDENTIALS` stripped from subprocess env ([#&#8203;56680](https://github.com/NousResearch/hermes-agent/pull/56680), [#&#8203;56582](https://github.com/NousResearch/hermes-agent/pull/56582) — [@&#8203;srojk34](https://github.com/srojk34))
- Six P1 hardening PRs salvaged in one pass — browser guards, MEDIA anchoring, .env lockdown, delegate ACP transport ([#&#8203;57660](https://github.com/NousResearch/hermes-agent/pull/57660) — [@&#8203;teknium1](https://github.com/teknium1))
- Media/vision/image-gen local-file reads routed through the shared credential-read guard; native image routing guarded by file-safety policy; unified image-source resolver + terminal-backend confinement ([#&#8203;58709](https://github.com/NousResearch/hermes-agent/pull/58709), [#&#8203;58752](https://github.com/NousResearch/hermes-agent/pull/58752), [#&#8203;57890](https://github.com/NousResearch/hermes-agent/pull/57890) — [@&#8203;teknium1](https://github.com/teknium1))
- Webhook body-cap sweep: explicit `client_max_size` on 3 uncapped aiohttp servers + completion sweep; Raft chunked-request body limit; timestamp-bound V2 webhook signatures ([#&#8203;59180](https://github.com/NousResearch/hermes-agent/pull/59180), [#&#8203;59215](https://github.com/NousResearch/hermes-agent/pull/59215), [#&#8203;58902](https://github.com/NousResearch/hermes-agent/pull/58902), [#&#8203;58508](https://github.com/NousResearch/hermes-agent/pull/58508) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;srojk34](https://github.com/srojk34))
- Redaction: Fireworks token prefixes + Telegram transport errors; env-lookup false positives fixed for KEY=value and JSON/YAML config fields; bot tokens scrubbed from Telegram connect/send errors ([#&#8203;58501](https://github.com/NousResearch/hermes-agent/pull/58501), [#&#8203;58534](https://github.com/NousResearch/hermes-agent/pull/58534), [#&#8203;58915](https://github.com/NousResearch/hermes-agent/pull/58915), [#&#8203;58893](https://github.com/NousResearch/hermes-agent/pull/58893) — [@&#8203;teknium1](https://github.com/teknium1))
- computer-use: subprocess env sanitized across all five cua-driver spawn sites ([#&#8203;58889](https://github.com/NousResearch/hermes-agent/pull/58889), [#&#8203;59165](https://github.com/NousResearch/hermes-agent/pull/59165) — [@&#8203;teknium1](https://github.com/teknium1))
- Dashboard: managed-files credential guard widened past .env + dir-tree gap closed; OAuth token TOCTOU closed with atomic 0o600 writes; stale dashboards can't recreate deleted profiles ([#&#8203;58222](https://github.com/NousResearch/hermes-agent/pull/58222) — [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor), [#&#8203;60236](https://github.com/NousResearch/hermes-agent/pull/60236) — [@&#8203;teknium1](https://github.com/teknium1), [#&#8203;49435](https://github.com/NousResearch/hermes-agent/pull/49435) — [@&#8203;LeonSGP43](https://github.com/LeonSGP43))
- CI: untrusted refs passed through env, not `run:` interpolation; JS/TS tests wired into CI with source-regex tests banned; js-autofix pushes via PR instead of direct-to-main ([#&#8203;57842](https://github.com/NousResearch/hermes-agent/pull/57842) — [@&#8203;jquesnelle](https://github.com/jquesnelle), [#&#8203;60707](https://github.com/NousResearch/hermes-agent/pull/60707), [#&#8203;65186](https://github.com/NousResearch/hermes-agent/pull/65186) — [@&#8203;ethernet8023](https://github.com/ethernet8023))
- Docker: terminal network toggle with full-path coverage; Git Bash Mandatory-ASLR install failures detected; Windows updater console hidden during handoff ([#&#8203;59149](https://github.com/NousResearch/hermes-agent/pull/59149) — [@&#8203;teknium1](https://github.com/teknium1), [#&#8203;64651](https://github.com/NousResearch/hermes-agent/pull/64651), [#&#8203;66040](https://github.com/NousResearch/hermes-agent/pull/66040) — [@&#8203;helix4u](https://github.com/helix4u))
- Anthropic: request-local clients so the stale/interrupt watchdog never corrupts SQLite; per-profile OAuth file; OAuth login 429 fixed (UA must not be claude-code/) ([#&#8203;67238](https://github.com/NousResearch/hermes-agent/pull/67238) — [@&#8203;OutThisLife](https://github.com/OutThisLife), [#&#8203;59339](https://github.com/NousResearch/hermes-agent/pull/59339), [#&#8203;58178](https://github.com/NousResearch/hermes-agent/pull/58178) — [@&#8203;teknium1](https://github.com/teknium1))
- Gateway/agent: tool\_call\_id deduplicated across pre-API sanitizers; background review inherits parent reasoning\_config for Anthropic cache parity; `/new` memory extraction moved off the command path ([#&#8203;58350](https://github.com/NousResearch/hermes-agent/pull/58350), [#&#8203;64379](https://github.com/NousResearch/hermes-agent/pull/64379), [#&#8203;61139](https://github.com/NousResearch/hermes-agent/pull/61139) — [@&#8203;teknium1](https://github.com/teknium1), [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor))

##### 🔁 Reverted in this window (for the record)

- iron-proxy credential-injection egress firewall ([#&#8203;30179](https://github.com/NousResearch/hermes-agent/issues/30179) → reverted in [#&#8203;58489](https://github.com/NousResearch/hermes-agent/pull/58489)) — not shipping in this release
- dynamic-workflow orchestration skill (landed, then reverted) — not shipping
- memory provider-actions extension point (landed, then reverted) — not shipping
- Note: the plugin `pre_tool_call` approve escalation was reverted mid-window but **re-landed** in [#&#8203;60504](https://github.com/NousResearch/hermes-agent/pull/60504) and ships in this release.

##### 👥 Contributors

**450+ people** contributed to this release (via commits, co-author trailers, and salvaged PRs) — the biggest contributor window yet. Thank you, all of you.

##### Core team

- [@&#8203;teknium1](https://github.com/teknium1) — release lead; TTFT perf wave, delivery + delegation durability, smart approvals, SecretSource, gateway multiplex + profile routing, sessions export, security round, and a \~290-PR community salvage burn
- [@&#8203;OutThisLife](https://github.com/OutThisLife) — desktop app (the speed wave, layout-tree shell, Capabilities page, session colors, vibe reactions, TUI incremental markdown, perf harness)
- [@&#8203;kshitijk4poor](https://github.com/kshitijk4poor) — GPT-5.6 end-to-end, DeepInfra + Upstage Solar providers, perf cluster, compression integrity, mem0, dashboard guards
- [@&#8203;ethernet8023](https://github.com/ethernet8023) — CI overhaul (JS/TS tests wired in, autofix-via-PR, python speedups), desktop keybinds/worktrees/status indicators, full desktop TypeScript conversion
- [@&#8203;benbarclay](https://github.com/benbarclay) — relay OIDC provisioning, gateway multiplex override, Nous auth self-heal, hosted MCP OAuth groundwork
- [@&#8203;alt-glitch](https://github.com/alt-glitch) — terminal billing (`/subscription`, `/topup`), desktop billing tab
- [@&#8203;helix4u](https://github.com/helix4u) — desktop provider/model UX, TUI model picker refresh, Windows install/updater hardening
- [@&#8203;austinpickett](https://github.com/austinpickett) — desktop custom endpoint settings
- [@&#8203;SHL0MS](https://github.com/SHL0MS) — unbroker + unreal-mcp skills, humanizer expansion

##### Top community contributors

- [@&#8203;srojk34](https://github.com/srojk34) — security hardening: Vertex credential/project/region scoping through the profile secret scope, subprocess env stripping, Raft chunked-request body limits
- [@&#8203;HexLab98](https://github.com/HexLab98) — 11 fixes across MCP capability gating, Windows installer PATH, desktop cron editing, gateway systemd warnings
- [@&#8203;UnathiCodex](https://github.com/UnathiCodex) — desktop stability: zoom across display moves, LaTeX rendering, resume-stall and runtime-readiness fixes
- [@&#8203;xxxigm](https://github.com/xxxigm) — `<think>` leak fix after thinking-only retry flush, dashboard auth/theme/PTY fixes
- [@&#8203;erosika](https://github.com/erosika) — desktop declarative memory-provider panel + honcho recall/timeout correctness
- [@&#8203;Frowtek](https://github.com/Frowtek) — credential security: master stores never mounted into skill sandboxes, live-transcript redaction, dashboard api\_key precedence
- [@&#8203;necoweb3](https://github.com/necoweb3) — browser private-page CDP guard, cron one-shot liveness, gateway compression fail-closed
- [@&#8203;DavidMetcalfe](https://github.com/DavidMetcalfe) — desktop updater version pill, Local/custom endpoint exposure, sidebar collapse behavior
- [@&#8203;shannonsands](https://github.com/shannonsands) — dashboard: mobile channel setup, Discord toolsets from web UI, Telegram setup clarity
- [@&#8203;vishal-dharm](https://github.com/vishal-dharm) — Gemini request-context improvements
- [@&#8203;PRATHAMESH75](https://github.com/PRATHAMESH75) — cron one-shot stale-removal race, dashboard multiplex port-binding guard
- [@&#8203;alelpoan](https://github.com/alelpoan), [@&#8203;embwl0x](https://github.com/embwl0x), [@&#8203;Adolanium](https://github.com/Adolanium), [@&#8203;giggling-ginger](https://github.com/giggling-ginger), [@&#8203;Drexuxux](https://github.com/Drexuxux), [@&#8203;frizikk](https://github.com/frizikk), [@&#8203;JoaoMarcos44](https://github.com/JoaoMarcos44), [@&#8203;wesleysimplici](https://github.com/wesleysimplici), [@&#8203;LeonSGP43](https://github.com/LeonSGP43), [@&#8203;pierrenode](https://github.com/pierrenode), [@&#8203;simpolism](https://github.com/simpolism), [@&#8203;MorAlekss](https://github.com/MorAlekss), [@&#8203;r266-tech](https://github.com/r266-tech), [@&#8203;WadydX](https://github.com/WadydX), [@&#8203;nv-kasikritc](https://github.com/nv-kasikritc) — targeted fixes across desktop, TUI, gateway, cron, webhook, nix, and browser surfaces
- Salvaged-work authors whose PRs were cherry-picked with credit this window: [@&#8203;Burgunthy](https://github.com/Burgunthy) (profile routing), [@&#8203;web3blind](https://github.com/web3blind) (sessions export), [@&#8203;hwrdprkns](https://github.com/hwrdprkns) (1Password), [@&#8203;Christopher-Schulze](https://github.com/Christopher-Schulze), [@&#8203;Ahmett101](https://github.com/Ahmett101), [@&#8203;sjiangtao2024](https://github.com/sjiangtao2024), and many more — see the salvage PR bodies for full attribution

##### All contributors

[@&#8203;0-CYBERDYNE-SYSTEMS-0](https://github.com/0-CYBERDYNE-SYSTEMS-0), [@&#8203;0disoft](https://github.com/0disoft), [@&#8203;0xbyt4](https://github.com/0xbyt4), [@&#8203;100yenadmin](https://github.com/100yenadmin), [@&#8203;17324393074](https://github.com/17324393074), [@&#8203;2751738943](https://github.com/2751738943), [@&#8203;8294](https://github.com/8294), [@&#8203;abhibansal-sg](https://github.com/abhibansal-sg),
[@&#8203;adambiggs](https://github.com/adambiggs), [@&#8203;Adolanium](https://github.com/Adolanium), [@&#8203;aeyeopsdev](https://github.com/aeyeopsdev), [@&#8203;aguung](https://github.com/aguung), [@&#8203;AhmetArif0](https://github.com/AhmetArif0), [@&#8203;Ahmett101](https://github.com/Ahmett101), [@&#8203;ai-ag2026](https://github.com/ai-ag2026), [@&#8203;AIalliAI](https://github.com/AIalliAI), [@&#8203;ajzrva-sys](https://github.com/ajzrva-sys),
[@&#8203;alastraz](https://github.com/alastraz), [@&#8203;alelpoan](https://github.com/alelpoan), [@&#8203;alex-fireworks](https://github.com/alex-fireworks), [@&#8203;alex-heritier](https://github.com/alex-heritier), [@&#8203;alex107ivanov](https://github.com/alex107ivanov), [@&#8203;AlexFucuson9](https://github.com/AlexFucuson9), [@&#8203;Alix-007](https://github.com/Alix-007),
[@&#8203;allenliang2022](https://github.com/allenliang2022), [@&#8203;Almurat123](https://github.com/Almurat123), [@&#8203;AlsayedHoota](https://github.com/AlsayedHoota), [@&#8203;alt-glitch](https://github.com/alt-glitch), [@&#8203;alvarosanchez](https://github.com/alvarosanchez), [@&#8203;amanning3390](https://github.com/amanning3390), [@&#8203;AmAzing129](https://github.com/AmAzing129),
[@&#8203;AndreasHiltner](https://github.com/AndreasHiltner), [@&#8203;andrewhomeyer](https://github.com/andrewhomeyer), [@&#8203;annguyenNous](https://github.com/annguyenNous), [@&#8203;ansel-f](https://github.com/ansel-f), [@&#8203;antydizajn](https://github.com/antydizajn), [@&#8203;arminanton](https://github.com/arminanton), [@&#8203;arnispiekus](https://github.com/arnispiekus), [@&#8203;asimons81](https://github.com/asimons81),
[@&#8203;asscan](https://github.com/asscan), [@&#8203;ats3v](https://github.com/ats3v), [@&#8203;austinlaw076](https://github.com/austinlaw076), [@&#8203;austinpickett](https://github.com/austinpickett), [@&#8203;avifenesh](https://github.com/avifenesh), [@&#8203;aydnOktay](https://github.com/aydnOktay), [@&#8203;Bartok9](https://github.com/Bartok9), [@&#8203;bautrey](https://github.com/bautrey), [@&#8203;bbednarski9](https://github.com/bbednarski9),
[@&#8203;bbopen](https://github.com/bbopen), [@&#8203;benbarclay](https://github.com/benbarclay), [@&#8203;bigstar0920](https://github.com/bigstar0920), [@&#8203;binhnt92](https://github.com/binhnt92), [@&#8203;bird](https://github.com/bird), [@&#8203;Black0Fox0](https://github.com/Black0Fox0), [@&#8203;BlackishGreen33](https://github.com/BlackishGreen33), [@&#8203;bo](https://github.com/bo).fu, [@&#8203;brendandebeasi](https://github.com/brendandebeasi),
[@&#8203;briandevans](https://github.com/briandevans), [@&#8203;BROCCOLO1D](https://github.com/BROCCOLO1D), [@&#8203;Bruce-anle](https://github.com/Bruce-anle), [@&#8203;brunz-me](https://github.com/brunz-me), [@&#8203;Burgunthy](https://github.com/Burgunthy), [@&#8203;bytesnail](https://github.com/bytesnail), [@&#8203;catbearlove1-lang](https://github.com/catbearlove1-lang), [@&#8203;Cdddo](https://github.com/Cdddo),
[@&#8203;cgarwood82](https://github.com/cgarwood82), [@&#8203;CharmingGroot](https://github.com/CharmingGroot), [@&#8203;chouqin](https://github.com/chouqin), [@&#8203;Christopher-Schulze](https://github.com/Christopher-Schulze), [@&#8203;claudlos](https://github.com/claudlos), [@&#8203;CocaKova](https://github.com/CocaKova), [@&#8203;Code-suphub](https://github.com/Code-suphub), [@&#8203;CodeForgeNet](https://github.com/CodeForgeNet),
[@&#8203;craigdfrench](https://github.com/craigdfrench), [@&#8203;CrazyBoyM](https://github.com/CrazyBoyM), [@&#8203;crazywriter1](https://github.com/crazywriter1), [@&#8203;cresslank](https://github.com/cresslank), [@&#8203;cruzanstx](https://github.com/cruzanstx), [@&#8203;cyrkstudios](https://github.com/cyrkstudios), [@&#8203;danilofalcao](https://github.com/danilofalcao),
[@&#8203;datachainsystems](https://github.com/datachainsystems), [@&#8203;DatTheMaster](https://github.com/DatTheMaster), [@&#8203;davidb73-hub](https://github.com/davidb73-hub), [@&#8203;davidgut1982](https://github.com/davidgut1982), [@&#8203;DavidMetcalfe](https://github.com/DavidMetcalfe), [@&#8203;davidrobertson](https://github.com/davidrobertson),
[@&#8203;deacon-botdoctor](https://github.com/deacon-botdoctor), [@&#8203;DECK6](https://github.com/DECK6), [@&#8203;deepujain](https://github.com/deepujain), [@&#8203;derek2000139](https://github.com/derek2000139), [@&#8203;designnotdrum](https://github.com/designnotdrum), [@&#8203;deusyu](https://github.com/deusyu), [@&#8203;devatnull](https://github.com/devatnull), [@&#8203;devorun](https://github.com/devorun),
[@&#8203;dexhunter](https://github.com/dexhunter), [@&#8203;dfein38347g](https://github.com/dfein38347g), [@&#8203;Dhravya](https://github.com/Dhravya), [@&#8203;DictatorBacon](https://github.com/DictatorBacon), [@&#8203;digitalbase](https://github.com/digitalbase), [@&#8203;dlkakbs](https://github.com/dlkakbs), [@&#8203;dmabry](https://github.com/dmabry), [@&#8203;DNAlec](https://github.com/DNAlec), [@&#8203;dodo-reach](https://github.com/dodo-reach),
[@&#8203;doncazper](https://github.com/doncazper), [@&#8203;dorokuma](https://github.com/dorokuma), [@&#8203;doxe0x](https://github.com/doxe0x), [@&#8203;Drexuxux](https://github.com/Drexuxux), [@&#8203;dschnurbusch](https://github.com/dschnurbusch), [@&#8203;Dusk1e](https://github.com/Dusk1e), [@&#8203;EdderTalmor](https://github.com/EdderTalmor), [@&#8203;egilewski](https://github.com/egilewski), [@&#8203;elashera](https://github.com/elashera),
[@&#8203;Elektrofussel](https://github.com/Elektrofussel), [@&#8203;eliteworkstation94-ai](https://github.com/eliteworkstation94-ai), [@&#8203;embwl0x](https://github.com/embwl0x), [@&#8203;emo-eth](https://github.com/emo-eth), [@&#8203;emozilla](https://github.com/emozilla), [@&#8203;enzo-adami](https://github.com/enzo-adami), [@&#8203;Epoxidex](https://github.com/Epoxidex), [@&#8203;ErnestHysa](https://github.com/ErnestHysa),
[@&#8203;Erosika](https://github.com/Erosika), [@&#8203;esthonjr](https://github.com/esthonjr), [@&#8203;ethernet8023](https://github.com/ethernet8023), [@&#8203;evefromwayback](https://github.com/evefromwayback), [@&#8203;evelynburger](https://github.com/evelynburger), [@&#8203;F4TB0Yz](https://github.com/F4TB0Yz), [@&#8203;falkoro](https://github.com/falkoro), [@&#8203;fanyangCS](https://github.com/fanyangCS), [@&#8203;firefly](https://github.com/firefly),
[@&#8203;fjlaowan1983](https://github.com/fjlaowan1983), [@&#8203;flewe](https://github.com/flewe), [@&#8203;flo1t](https://github.com/flo1t), [@&#8203;flow-digital-ny](https://github.com/flow-digital-ny), [@&#8203;floze-the-genius](https://github.com/floze-the-genius), [@&#8203;frizikk](https://github.com/frizikk), [@&#8203;Frowtek](https://github.com/Frowtek), [@&#8203;FuryMartin](https://github.com/FuryMartin),
[@&#8203;fyzanshaik](https://github.com/fyzanshaik), [@&#8203;gauravsaxena1997](https://github.com/gauravsaxena1997), [@&#8203;geoffreybutler94](https://github.com/geoffreybutler94), [@&#8203;georgedrury](https://github.com/georgedrury), [@&#8203;gigakun3030](https://github.com/gigakun3030), [@&#8203;giggling-ginger](https://github.com/giggling-ginger),
[@&#8203;Git-on-my-level](https://github.com/Git-on-my-level), [@&#8203;gitcommit90](https://github.com/gitcommit90), [@&#8203;githubespresso407](https://github.com/githubespresso407), [@&#8203;gnodet](https://github.com/gnodet), [@&#8203;GottZ](https://github.com/G…
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
…S-free) (NousResearch#60730)

For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway
obtain its caller-identity bearer from a generic OAuth2 client_credentials grant
against the operator's own IdP (e.g. Microsoft Entra ID) instead of only
resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim
(default tid) off that token as the tenant.

- gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials
  when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal
  (unchanged default). Wired into self_provision_relay().
- hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical
  resolver so the enroll CLI and the runtime self-provision path share ONE impl.

Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml
(env override GATEWAY_RELAY_IDP_*). No behaviour change when unset.

Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection,
request shape, config/env precedence, fail-closed). Relay suite 162 pass.

Validated via the cross-repo gateway<->connector live E2E (provision, managed
self-provision, inbound round-trip, /link) against a connector running the OIDC
tenant resolver with zero NAS config.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…S-free) (NousResearch#60730)

For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway
obtain its caller-identity bearer from a generic OAuth2 client_credentials grant
against the operator's own IdP (e.g. Microsoft Entra ID) instead of only
resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim
(default tid) off that token as the tenant.

- gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials
  when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal
  (unchanged default). Wired into self_provision_relay().
- hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical
  resolver so the enroll CLI and the runtime self-provision path share ONE impl.

Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml
(env override GATEWAY_RELAY_IDP_*). No behaviour change when unset.

Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection,
request shape, config/env precedence, fail-closed). Relay suite 162 pass.

Validated via the cross-repo gateway<->connector live E2E (provision, managed
self-provision, inbound round-trip, /link) against a connector running the OIDC
tenant resolver with zero NAS config.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants