Skip to content

fix(config): preserve owner on atomic writes - #56644

Merged
benbarclay merged 1 commit into
NousResearch:mainfrom
helix4u:fix/config-atomic-write-owner
Jul 3, 2026
Merged

fix(config): preserve owner on atomic writes#56644
benbarclay merged 1 commit into
NousResearch:mainfrom
helix4u:fix/config-atomic-write-owner

Conversation

@helix4u

@helix4u helix4u commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Preserves the existing uid/gid when Hermes rewrites files through the shared atomic JSON/YAML write helpers.

The root cause is that os.replace() swaps in the temporary file. When a command is run as root against a Docker/NAS-backed Hermes home, the temp file can be root-owned even if the original config.yaml was owned by the runtime user. Mode preservation alone does not prevent the replacement file from becoming unreadable to the Hermes process.

This restores the original owner on POSIX platforms after the atomic replace, best effort. Unsupported platforms and unprivileged callers continue normally if ownership cannot be changed.

Related Issue

No GitHub issue. Reported from support logs showing /opt/data/config.yaml becoming unreadable after a gateway setup/config write.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • utils.py: capture the original file owner before atomic JSON/YAML writes and restore it after atomic_replace().
  • utils.py: apply the same owner preservation to atomic_roundtrip_yaml_update().
  • tests/test_atomic_replace_symlinks.py: add focused tests for JSON writes, YAML writes through symlinks, and roundtrip YAML updates without requiring root.

How to Test

  1. Run the focused atomic-write test file:
    pytest tests/test_atomic_replace_symlinks.py -q
  2. Run the adjacent atomic JSON/YAML smoke set:
    pytest tests/test_atomic_replace_symlinks.py tests/hermes_cli/test_atomic_json_write.py tests/hermes_cli/test_atomic_yaml_write.py tests/test_yaml_indent_consistency_31999.py -q
  3. In a Docker-style install, start with a runtime-owned config file, run a root-owned setup/config write path, and confirm the rewritten config.yaml keeps the original owner.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: WSL2/Linux focused pytest

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Screenshots / Logs

Focused local checks:

pytest tests/test_atomic_replace_symlinks.py -q
17 passed in 0.25s

pytest tests/test_atomic_replace_symlinks.py tests/hermes_cli/test_atomic_json_write.py tests/hermes_cli/test_atomic_yaml_write.py tests/test_yaml_indent_consistency_31999.py -q
44 passed in 0.89s

Full pytest tests/ -q was not run locally; this PR came from support triage and the local run was intentionally scoped to the touched atomic-write behavior.

@helix4u
helix4u marked this pull request as ready for review July 1, 2026 21:37
@alt-glitch alt-glitch added type/bug Something isn't working area/config Config system, migrations, profiles area/docker Docker image, Compose, packaging P2 Medium — degraded but workaround exists labels Jul 1, 2026
@benbarclay
benbarclay merged commit 551e5af into NousResearch:main Jul 3, 2026
31 checks passed
abhibansal-sg added a commit to abhibansal-sg/hermes-agent-fork that referenced this pull request Jul 5, 2026
* feat(egress): iron-proxy credential-injection firewall for sandboxes

Rebuilds the iron-proxy egress feature cleanly onto current main. The
original feat/iron-proxy branch had diverged from main with an
unmergeable history (no usable merge-base after main history motion),
so the feature's content diff was re-applied onto a fresh main cut and
the three config/docs conflicts (commands.py status/egress, config.py
proxy vs computer_use, slash-commands.md) resolved keeping main's
content plus the egress additions.

Optional, off-by-default TLS-intercepting egress proxy for remote
terminal sandboxes. Sandboxes hold opaque proxy tokens; iron-proxy
swaps them for real provider API keys at the network boundary.

Includes the full review-cycle hardening:
- P0/P1/P2 rounds (GodsBoy, stephenschoettler, arshkumarsingh,
  annguyenNous, maxpetrusenko, sxuff findings)
- v0.39 schema realignment + Docker bridge-bind/listener-role fixes
- Docker UX/enforcement hardening

Salvaged security fixes folded in with credit:
- Three P0 gaps (version-probe env scrub, Bitwarden ImportError
  fail-closed, container-reuse egress-boundary) + Docker v29.5.3
  empty-label edge — kuangmi-bit (#48073)
- P1/P2 (fail-closed replace.require:true, NODE_OPTIONS CA-flag
  conflict, GPG checksum verify, threat-model wording) — Bartok9 (#48076)

Co-authored-by: kuangmi-bit <kuangmi@deeparchi.com>
Co-authored-by: Bartok9 <danielrpike9@gmail.com>

* fix(egress): close GOOGLE_API_KEY coverage gap + config/mappings write TOCTOU

Two correctness gaps surfaced in the review thread (texasich) that
survived the prior rounds:

- GOOGLE_API_KEY was warn-only while GEMINI_API_KEY was fail-closed,
  despite both authenticating the same generativelanguage LLM endpoint
  (auth.py treats them as interchangeable). An operator with only
  GOOGLE_API_KEY set + fail_on_uncovered_providers got false coverage.
  Added it to _LLM_SPECIFIC_NON_BEARER_PROVIDERS.
- write_proxy_config / write_mappings chmod'd AFTER os.replace, leaving
  the token-bearing files briefly world-readable under a slack umask
  (the 0o700 state dir mitigates but same-uid race remained). chmod the
  temp file BEFORE the atomic replace, matching the CA-key write path.

Tests: assert GOOGLE_API_KEY in blocked tier; assert proxy.yaml +
mappings.json land at 0o600.

* chore(egress): drop committed infographic PNG from tree

Infographics live at their hosted URL and are referenced from the PR
body — they are never committed to the repo (repo-cleanliness rule).
Removes the 1.8MB infographic/iron-proxy-egress/infographic.png that
the original PR added to the tree.

* feat(egress): smoother UX — restart command, auto-restart on setup, .env key discovery

- Add `hermes egress restart` (stop-then-start) so applying a config /
  token / Bitwarden-rotation change is one command instead of the
  stop+start dance.
- `hermes egress setup` now offers to restart a running daemon after
  rewriting config/tokens (asks on a tty; `--restart` / `--no-restart`
  for non-interactive control), so changes take effect without the
  operator remembering a manual restart.
- `setup` discovers provider keys kept only in ~/.hermes/.env, not just
  exported shell vars — no more confusing 'no provider keys found' when
  the keys plainly exist.
- Tests + docs updated.

* fix(cli): reliable interrupts, bounded exit, and exit feedback (#57000)

Three CLI reliability fixes:

1. Interrupt reliability: chat() only re-queued the user's interrupt
   message when the turn result carried interrupted=True. When the agent
   thread raced past its last interrupt check (or finished) before the
   interrupt landed, the message was silently dropped — and the stale
   _interrupt_requested flag left on the agent instantly aborted the
   NEXT turn. Un-acknowledged interrupt messages are now re-queued as
   the next turn and the stale flag is cleared (only when the agent
   thread actually exited). The clarify-race path also parks the message
   in _pending_input instead of dropping it.

2. Slow exit (5+ min): stdlib ThreadPoolExecutor workers are non-daemon
   and joined unconditionally by concurrent.futures' atexit hook — even
   after shutdown(wait=False). One wedged tool worker (abandoned after
   interrupt/timeout) held the process open forever. Promoted
   async_delegation's daemon executor to a shared tools/daemon_pool
   module and adopted it in tool_executor (concurrent tool batches),
   memory_manager (background sync), delegate_tool (child timeout wrapper
   + batch fan-out), and skills_hub (source fan-out). Added a 30s exit
   watchdog (HERMES_EXIT_WATCHDOG_S) armed at _run_cleanup start as a
   backstop for wedged cleanup steps.

3. Exit jank: after prompt_toolkit tears down the input/status bars the
   terminal sat silent for the whole cleanup window, looking hung. Print
   'Shutting down… (finalizing session)' immediately at exit start.

E2E: live PTY interrupt of a foreground 'sleep 120' terminal tool now
aborts in ~1s and the typed message runs as the next turn; wedged-worker
+ wedged-cleanup subprocess exits in 5.8s (watchdog) instead of hanging.

* chore(release): map ai-lab@foxmail.com to CrazyBoyM

Adds the AUTHOR_MAP entry for CrazyBoyM (ai-lab@foxmail.com) so the
contributor-attribution CI check passes when PR #55828's commits are
rebase-merged with authorship preserved.

* fix(codex): extend stale timeout for gateway-scale tool payloads

Lower the openai-codex stale-timeout floor from 25k to 10k estimated
tokens so Telegram/gateway sessions (~20k tools+instructions) are not
aborted at the generic 90s cutoff while Codex is still prefilling.

* test(codex): cover gateway-scale stale timeout floor and TTFB gate

* docs(codex): clarify stale-floor docstring reflects the 10k gate

The helper docstring described the typical ~15-25k gateway payload but
read as if that were the trigger range; the floor actually engages above
10k tokens. Clarify the prose to match the gate.

* fix(browser): guard Camofox snapshot/vision/images on private pages

Follow-up to #56874, which added the Camofox private-page SSRF guard
(_camofox_current_page_private_url) but wired it only into the Camofox
eval path (_camofox_eval). The other Camofox content-read tools —
camofox_snapshot, camofox_get_images, and camofox_vision — still read the
current page's accessibility tree / images / screenshot without the
guard, so on a non-local Camofox backend they can return the content of
an intranet or cloud-metadata page (e.g. 169.254.169.254) that the
terminal itself can't reach.

Apply the same guard, gated on _eval_ssrf_guard_active (non-local
backend, not a local sidecar, allow_private_urls unset) and fail-open on
probe failure, matching the eval-path guard and the main-browser
snapshot/vision guards. camofox_back is intentionally not changed: its
target is unknown until navigation completes, and the subsequent content
read is already guarded.

Adds regression tests covering the three read tools blocking on a private
page, the public-page pass-through, and the guard-inactive no-probe path.

* feat(image-gen): support Codex image inputs

* test(image-gen): cap Codex reference inputs

* refactor(image-gen): reuse shared image sniffer + raster allowlist in codex backend

Replace the plugin-local _IMAGE_MAGIC_MIME table + _sniff_image_mime
body with a delegation to agent.image_routing._sniff_mime_from_bytes,
the canonical magic-byte sniffer already used across the codebase, then
gate its result to the raster formats gpt-image-2's Responses
input_image actually accepts (png/jpeg/gif/webp).

The shared sniffer also recognizes SVG/TIFF/ICO; without the allowlist
those would pass local validation and be rejected server-side with an
opaque HTTP 400. Gating locally fails them cleanly as invalid_image_input.
Adds a regression test for SVG rejection.

Follow-up on top of @CrazyBoyM's #55828.

* fix(whatsapp): resolve LID sender IDs to phone numbers in bridge message payload

WhatsApp has migrated to Linked Identity Device (LID) format for user
IDs (e.g. 244645917392975@lid instead of 18505551234@s.whatsapp.net).

The bridge already resolves LIDs to phone numbers for its own allowlist
check via buildLidMap(), but the senderId field in the message payload
sent to the gateway still contained the raw LID. This caused the
gateway's WHATSAPP_ALLOWED_USERS check to reject all messages as
unauthorized, since the LID numbers don't match the phone numbers in
the allowlist.

Fix: resolve LID → phone in the senderId, senderName, and chatName
fields of the event payload before sending to the gateway, using the
existing lidToPhone mapping.

* chore: add AUTHOR_MAP entry for @ajmeese7 (#3219 salvage)

* fix(status): label provider as custom when config.yaml model.base_url is set

Salvage of the surviving hunk of #3296 by @Mibayy. The PR's gateway
_handle_provider_command hunk targets code removed on main (/provider was
absorbed into /model + /status, which already read model.base_url); the
hermes status mislabel was the remaining live symptom:
_effective_provider_label() only checked the legacy OPENAI_BASE_URL env var,
so a custom endpoint configured canonically in config.yaml still displayed
as OpenRouter.

* feat(gateway): add 'log' option to display.tool_progress

Salvage of #3459 by @keslerm, reimplemented against the restructured
progress-callback block in gateway/run.py (resolve_display_setting,
needs_progress_queue, thinking-relay). Duplicate PR #3458 by @dlkakbs was
submitted 4 minutes earlier with the same feature — both credited.

Co-authored-by: Dilee <uzmpsk.dilekakbas@gmail.com>

tool_progress: log keeps the chat silent and appends timestamped tool-call
lines to ~/.hermes/logs/tool_calls.log via a dedicated queue drained by an
async writer (RotatingFileHandler 5MB x 3, RedactingFormatter so secrets
never land on disk). Gateway-only by design; thinking_progress relaying and
the webhook gate are unaffected. /verbose now cycles
off -> new -> all -> verbose -> log.

* fix(i18n): add gateway.verbose.mode_log to all locale catalogs

* feat(commands): /compact alias + --preview/--dry-run flags for /compress (#3243 salvage)

Salvaged from PR #3243 by @Mibayy, reimplemented against current main
(the original diff targeted a removed gateway/run.py handler).

- /compact is now a first-class alias of /compress (CLI, gateway,
  Telegram/Slack/Discord command lists, autocomplete) — also fixes the
  dangling '/compact' references in gateway error messages
  (gateway/run.py context-exhausted banners).
- --preview / --dry-run: report what WOULD be compressed (message
  counts, token estimate, 'here [N]' boundary) without touching the
  transcript. Flags coexist with the existing 'here [N]' / focus-topic
  args on both the CLI and gateway surfaces via shared pure helpers in
  hermes_cli/partial_compress.py.
- --aggressive (LLM-free hard truncation) is intentionally NOT
  implemented: it would need its own transcript-persistence branch
  outside the guarded _compress_context rotation machinery (#44794
  data-loss class). The flag is recognized and returns an explanatory
  message pointing at '/compress here [N]' and /undo instead of being
  mis-parsed as a focus topic.
- locales: gateway.compress.aggressive_unsupported added to all 16
  catalogs (parity test enforced).
- release.py: AUTHOR_MAP entry for contributor credit.

* feat(api-server): per-client model routing via model_routes (#3176 salvage)

Adds a no-code routing layer to the OpenAI-compatible API server so one
Hermes deployment can map different API clients to different
model/provider backends. Clients pick a backend by sending a configured
alias as the OpenAI 'model' field; unmatched values fall back to the
global model. Configured aliases are listed by GET /v1/models.

Precedence (highest first): session /model override > model_routes
route > global config. Route provider credentials resolve through
_resolve_runtime_agent_kwargs_for_provider (same seam as
channel_overrides); per-route api_key/base_url are upstream provider
credential overrides — never caller auth, never logged.

Salvaged and rebased from PR #3176 by @Mibayy onto current main.

* feat(config): extra HTTP headers for LLM API calls (#3526 salvage)

Named providers / custom_providers entries in config.yaml now accept an
extra_headers dict scoped to that endpoint — for reverse proxies, API
gateways, and custom auth schemes (e.g. Cloudflare Access service tokens).

- hermes_cli/config.py: normalize extra_headers on provider entries
  (_normalize_custom_provider_entry + providers-dict translation), add
  get_custom_provider_extra_headers /
  apply_custom_provider_extra_headers_to_client_kwargs helpers keyed on
  base_url (case/trailing-slash insensitive, no substring bypass —
  mirrors the TLS helpers)
- hermes_cli/runtime_provider.py: surface extra_headers in the resolved
  runtime for named custom providers (providers dict, legacy
  custom_providers list, and the credential-pool path)
- run_agent.py / agent/agent_init.py: merge per-provider extra_headers
  onto the OpenAI client default_headers at construction and on every
  _apply_client_headers_for_base_url re-application (credential swaps,
  rebuilds), most-specific level wins; OpenAI-wire only (native
  Anthropic/Bedrock scoped out)
- agent/auxiliary_client.py: accept model.extra_headers as an alias of
  model.default_headers for the global variant
- cli-config.yaml.example: documented commented example
- Header values are treated as secrets and never logged

Salvaged from PR #3526 by @jneeee, reimplemented against current main.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>

* feat(gateway): persist per-session /model overrides across gateway restarts

Per-session /model overrides (_session_model_overrides) were in-memory only,
so a gateway restart silently reverted every session to the global default
model. Persist the non-secret parts (model/provider/base_url ONLY — never
api_key) into the session entry in sessions.json and lazily rehydrate them
on first use after a restart, re-resolving credentials through the normal
runtime provider resolution.

- gateway/session.py: SessionEntry.model_override field with
  sanitize_model_override() (allowlist: model/provider/base_url) applied on
  both serialization and deserialization; SessionStore.set_model_override /
  get_model_override accessors. reset_session() already creates a fresh entry,
  so /new keeps its clear-on-reset semantics — a restart cannot resurrect an
  override the user reset away.
- gateway/slash_commands.py: write-through at both /model set sites (text
  command + picker) after storing the in-memory override.
- gateway/run.py: _rehydrate_session_model_override() called from
  _resolve_session_agent_runtime(); in-memory state always wins, credentials
  are re-resolved per provider (credential-less fallback on failure). Session
  expiry finalization also drops the persisted override.
- tests/gateway/test_session_model_override_persistence.py: restart
  round-trip, /new clearing, api_key-never-serialized (including tampered
  sessions.json), rehydration + live-state precedence + credential-failure
  degradation.

Salvaged from #3659 by @Git-on-my-level, narrowed to the restart-persistence
gap confirmed in triage.

* fix(desktop): parse multiline slash commands + hand degenerate payloads back

parseSlashCommand used /^(\S+)\s*(.*)$/ where `.` can't cross a newline and
`$` anchors end-of-string, so any slash command whose arg contained a newline
(/goal <multi-line text>, a skill command with a long pasted context) failed
the whole match, parsed as an empty name, and rendered "empty slash command"
while the payload vanished — cleared from the composer and absent from the
Up-arrow history ring, which only derives from sent user messages.

- name now splits on any whitespace ([\s\S]* arg), matching the CLI and the
  gateway's split(maxsplit=1); multiline args flow to slash.exec intact
- the residual empty-name branch (bare "/", "/ text") restores the submitted
  text to the composer draft instead of eating it

Fixes #41323. Fixes #55510.

* fix(desktop): call checkUpdates() in startUpdatePoller so version pill auto-populates

startUpdatePoller() only called checkBackendUpdates() — never checkUpdates().
The statusbar version pill reads $updateStatus (set by checkUpdates()), so the
commit-behind counter stayed null after restart. It only appeared when the user
manually clicked the pill, which triggered checkUpdates() via openUpdateOverlayFor.

Added void checkUpdates() in three places alongside the existing
checkBackendUpdates() calls:
- On startup in startUpdatePoller()
- In the 30-minute setInterval callback
- In the onFocus handler

checkUpdates() uses the Electron IPC bridge (local git check), not the gateway,
so no mode gating is needed. The existing $updateChecking atom guard prevents
double-fire on overlap.

Fixes #53079

* fix(desktop): restore remote artifact rendering

* style(desktop): fix import ordering + padding lint in remote-artifact files

* feat(desktop): /journey opens the memory graph overlay instead of printing text

* style(desktop): fix pre-existing import-order lint in use-prompt-actions

* fix(desktop): restore remote file picker attachments

* fix(desktop): read attachment previews local-first in remote mode

attachImagePath fetched its thumbnail through readDesktopFileDataUrl, which in
remote mode routes every read to the gateway fs bridge. Paperclip picks,
clipboard saves, and OS drops always produce paths on the LOCAL machine, so the
gateway read 404s — toasting "image preview failed" and dropping the thumbnail
even though the attach itself works (upload reads local bytes via the Electron
bridge). Read the local bridge first and fall back to the remote facade, which
still serves in-app drags from the remote project tree. Local mode is
unchanged (the facade already reads locally there).

Follow-up to #56572, which restored the remote paperclip picker and made this
path reachable from the picker as well.

* fix(desktop): load remote model options before session

Both Desktop picker surfaces (status-bar model menu, settings/onboarding
dialog) only asked the connected gateway's model.options once a session
existed; before that they fell back to the Desktop REST/global options, which
can't see virtual providers a remote gateway exposes — including the MoA
presets from #53817. Centralize the fetch rule in requestModelOptions(): prefer
the connected gateway whenever one exists (no session_id needed — the RPC
resolves disk config), REST only when no gateway is connected.

The status-bar MoA preset section now renders from the same model.options
payload (the virtual `moa` provider row) instead of the local /api/model/moa
REST config, so remote presets appear correctly; the row is filtered out of
the main provider groups so presets don't list twice. Preset selection keeps
the persistent switchTo path from #56417 and drops the vestigial session gate —
like regular model rows, a pre-session pick ships on the next session.create.

Fixes #53817.

Rebased and reconciled with #56417 (persistent MoA selection), which landed
after this PR was opened and covered its one-shot-/moa half.

* fix(terminal): set MSYS_NO_PATHCONV for Windows Git Bash subprocesses

Git Bash mangles native Windows command flags (/FO, /TN, /Create) into
bogus paths. Hermes terminal and background spawns now opt out by default
so tasklist, schtasks, and wmic work without manual prefixes.

Fixes #56700.

* test(terminal): cover MSYS_NO_PATHCONV defaults on Windows env builders

* fix(terminal): also set MSYS2_ARG_CONV_EXCL for MSYS2/Cygwin bash fallback

MSYS_NO_PATHCONV is honored by Git for Windows bash only. _find_bash's
final shutil.which fallback can return MSYS2-proper or Cygwin bash,
which ignore it and honor MSYS2_ARG_CONV_EXCL instead. Set both so argv
path conversion stays disabled regardless of which bash flavor spawns.
Also subsumes the cmd /c mangling in #56147.

* feat(desktop): collapse profile rail to a select past 13 profiles (#57306)

The colored-square rail stops scaling once a user racks up many profiles:
tiny drag targets and an endless horizontal scroll strip. Past a threshold
(13) the rail swaps the squares for a compact select dropdown — same active
tint + initial glyph, minus the drag-reorder / long-press-recolor / per-row
context menu that only make sense at small counts. Two render paths behind
one flag; the left default↔all toggle, the "+" create button, and Manage
stay put in both. Rename/delete/color remain reachable via Manage.

* Prevent deleted profile skeleton revival

* feat(auth): make xAI Grok OAuth device-code-only, drop loopback login

Replace the loopback/PKCE-callback server and manual-paste fallback with
the RFC 8628 device-code flow as the only xAI Grok OAuth login path. The
flow works in headless/SSH/container sessions with no 127.0.0.1 listener,
shrinking the local attack surface.

- Poll the token endpoint with server-provided interval, honoring
  slow_down and expires_in; store tokens with auth_mode
  oauth_device_code.
- Adaptive proactive refresh skew for short-lived device-code JWTs;
  rotated tokens sync back to auth.json, the global root store, and the
  credential pool (no refresh-token replay).
- Clear source suppression on successful re-login (CLI + dashboard) and
  drop the duplicate dashboard pool entry so exactly one seeded
  device_code entry exists.
- Use the shared device_code source name for consistency with the
  nous/codex device-code providers.
- Desktop: remove the loopback OAuth flow states and dead type variants;
  pkce providers' sign-in URL selection is unchanged.
- Docs (EN + zh-Hans) rewritten for device-code login; drop the deleted
  --manual-paste flag from documented commands.

* fix(auth): remove stale loopback_pkce reference in xAI quarantine removal list

The terminal-refresh quarantine filtered in-memory entries on
source == "device_code" but built removed_ids from the deleted
"loopback_pkce" source name, so the revoked device-code entry was
never pruned from the persisted pool in auth.json. Also restores the
_print_loopback_ssh_hint test suite scoped to Spotify (the helper's
remaining caller) instead of deleting it wholesale.

* fix(desktop): skip ensureBackend after profile-delete teardown to prevent respawn loop

When the renderer sends a DELETE /api/profiles/{name} request, the IPC
handler tears down the profile's pool backend (or primary backend) via
prepareProfileDeleteRequest.  However, the very next line calls
ensureBackend(profile), which spawns a fresh pool backend for the just-
deleted profile.  The new backend's startup path calls ensure_hermes_home(),
which recreates the profile directory — defeating the deletion and leaving
the process as a zombie.

On the next Desktop restart the cycle repeats: the profile directory exists,
the Desktop spawns a backend, the backend recreates the directory after
deletion, and PIDs accumulate indefinitely.

Fix: make prepareProfileDeleteRequest return the torn-down profile name.
The IPC handler uses this to route the DELETE to the primary backend
instead of spawning a new pool backend for the deleted profile.

Fixes #52279

* fix(desktop): refresh profile rail after deletion (#49289)

* fix(desktop,tui-gateway,zai): stop thinking-off from reverting to medium

A Z.ai desktop user reported thinking reverting to medium after one turn,
burning ~200% of a week's credits in 4 days despite reasoning_effort: false
in config.yaml. Four compounding bugs:

- _session_info reported reasoning_effort "" for disabled reasoning,
  indistinguishable from unset — the desktop adopted it after the first
  turn, wiping its sticky "thinking off" pick so every later chat
  reverted to the default effort.
- config.set key=reasoning always wrote agent.reasoning_effort to global
  config.yaml, so every desktop model-menu selection (preset.effort ??
  'medium') clobbered the user's configured value. Now session-scoped
  like the messaging gateway's /reasoning, landing on
  create_reasoning_override so lazily-built sessions keep it too.
- YAML `reasoning_effort: false`/`off`/`no` (boolean False) was coerced
  to "" by every loader's `str(x or "")`, silently re-enabling thinking.
  parse_reasoning_effort now treats False/"false"/"disabled" as
  {"enabled": False}; loaders (tui gateway, gateway, cli, cron,
  delegate) pass the raw value through. The desktop config reader also
  crashed on the boolean (false.trim()), aborting voice/STT settings.
- The zai provider profile never sent thinking on the wire, and GLM-4.5+
  defaults to thinking ON server-side — so disabling reasoning was a
  silent no-op on direct Z.ai, the actual token burner. The profile now
  emits extra_body.thinking {"type": "enabled"|"disabled"} for
  thinking-capable GLM models, mirroring the DeepSeek profile.

Also: /new (session reset) now carries reasoning_config across the
rebuild like model_override; config.get reasoning prefers the session's
live value and maps a config False to "none"; Settings shows "Off"
instead of a blank select for hand-written false.

* fix(cli): stop profile-bound backends before deleting so rmtree converges

delete_profile stopped only the process named in gateway.pid, but a Desktop
app spawns a headless `serve`/`dashboard` backend per profile that holds the
profile's SQLite connection open and keeps writing sessions/WAL/sandbox files.
That backend is never in gateway.pid, so a CLI `hermes profile delete` run
while the Desktop app is up left it writing into the tree — rmtree's final
rmdir then failed with ENOTEMPTY (#47368 "Bug 2"), and pre-guard it also
resurrected the directory.

- _profile_bound_backend_pids(): find running Hermes backends bound to this
  profile via a `--profile <name>` selector or a HERMES_HOME env resolving to
  the profile dir. Tightly scoped — current-user only, backend subcommands
  (serve/dashboard/gateway) only so an interactive chat is never killed, and
  never this process or its ancestors.
- _stop_profile_backends(): terminate them (graceful, then force), best-effort
  so it can never make delete worse.
- _rmtree_with_retry(): a few spaced retries absorb the ENOTEMPTY / Windows
  file-lock race from a just-terminated writer's in-flight -wal/-shm/sandbox
  writes instead of failing the whole delete on a race the next attempt wins.

Complements the recreation guard (deleted profiles no longer reappear) and the
Desktop teardown-before-delete flow; this is the CLI-side convergence fix for a
delete run while a Desktop-managed backend is live.

Part of #47368.

* fix(tui_gateway): route setup.runtime_check and setup.status to RPC pool

setup.runtime_check and setup.status are polled by the Desktop frontend on
connect and periodically (use-status-snapshot → evaluateRuntimeReadiness), but
neither was in _LONG_HANDLERS — so dispatch() ran both inline on the WS reader
thread. Under GIL pressure from concurrent agent turns (terminal I/O, large
output, background-process completions) either can block for seconds:

- setup.runtime_check → resolve_runtime_provider() (config read, auth check,
  may probe the provider endpoint)
- setup.status → _has_any_provider_configured() (provider config + credential
  scan)

While either blocks the reader thread the WS read loop can't service later
requests; the frontend RPC timeout fires, the client drops the socket, and the
lost setup.runtime_check response reads as ready=false — a false "needs setup"
/ "Settings failed to load" even though the provider is configured.

Route both to the RPC pool (same precedent as #55545's session.list/pet.info/
process.list). The handlers are read-only and pool writes go through the
lock-guarded write_json, so there's no ordering or safety concern.

Test asserts all 5 frontend-polled RPCs are pool-routed.

Co-authored-by: izumi0uu <izumi0uu@gmail.com>

* chore(release): map yingliang-zhang in AUTHOR_MAP for #57335

* fix(usage): capture reasoning_tokens from completion_tokens_details on chat_completions (#57340)

normalize_usage only read output_tokens_details.reasoning_tokens (the
Responses API shape). Chat Completions providers — OpenAI, OpenRouter,
DeepSeek, and every OpenAI-compatible proxy — report it under
completion_tokens_details.reasoning_tokens, so reasoning_tokens was 0 for
every chat_completions reasoning model: hidden thinking was invisible in
session accounting, MoA traces, and the eval's per-task token columns.

Measured impact (HermesBench MoA run on deepseek-v4-flash, 4,828 advisor
calls): reasoning_tokens showed 0 everywhere while individual calls burned
up to 21.5K hidden thinking tokens to emit ~500 visible tokens. Verified
live against OpenRouter: deepseek-v4-flash returns
completion_tokens_details.reasoning_tokens=61 for a 74-completion-token
call; the field was simply never read.

Responses-shape reads are unchanged; the new read only fires when the
Responses shape yielded nothing.

* fix(slack): keep blank-line-separated ordered items in one rich_text_list

When a Markdown ordered list has blank lines between items (common in
LLM-authored content), the list run loop breaks on each blank line.
Slack numbers each rich_text_list independently, so N items produce N
lists each starting at 1.

Skip blank lines inside the list run as soft separators instead of
breaking, so ordered items stay in one rich_text_list and Slack renders
the correct numbering.

Fixes #57076

* fix(slack): guard blank-line list continuation on next-item lookahead

Refine the blank-line handling so a blank line only continues a list run
when the next non-blank line is another list item. This keeps a list ->
paragraph -> list sequence as three separate blocks and matches the
contiguous-list layout for mixed/nested lists (one rich_text block, split
into sub-lists by (indent, ordered)), rather than emitting a separate
block per item.

Adds regression tests for the mixed blank-separated layout and the
list->paragraph->list boundary.

* refactor(slack): extract _is_list_line helper for list-marker checks

Deduplicate the '_BULLET_RE.match or _ORDERED_RE.match' idiom used at the
list-run entry guard and the blank-line lookahead into a single helper, so
adding future marker types is a one-point change. Pure refactor, no
behavior change (22 block_kit tests still pass).

* fix: refresh NVIDIA featured models

* fix(agent): honor live vLLM context limits on local endpoints

Reconcile stale local disk cache against live vLLM/Ollama max_model_len
probes, probe local servers before the llama hardcoded default, parse
vLLM max_model_len overflow errors, and surface the non-agentic Hermes 3/4
warning at agent init on gateway/TUI.

Sub-64K live probes are returned for startup rejection but are not
persisted to the context cache — preserving the 64K minimum-context
contract instead of normalizing undersized windows as valid config.

(cherry picked from commit c3a02db4fd9d57b7b0eb2732de91f8334d311aa5)

* test(agent): cover local vLLM context-length resolution

Add regression tests for vLLM max_model_len error parsing, stale local
cache reconciliation, live probes over llama defaults, and the 64K minimum
guard on persistent cache writes.

(cherry picked from commit 1cb47ef437de7ce289cb358e8d6b89e9194b43ed)

* fix(gateway): close webhook sessions on delivery completion so prune can reap them

Webhook deliveries created a unique one-shot session (delivery_id baked into
the session key at gateway/platforms/webhook.py:668) but the adapter fired
handle_message via asyncio.create_task WITHOUT ever ending the session
(webhook.py:713, pre-fix). Nothing else closes it: the gateway caches/expires
the agent per session_key but never calls end_session for the webhook path,
and _end_session_on_close teardown doesn't run for these fire-and-forget tasks.

SessionDB.prune_sessions (hermes_state.py:4965) only deletes rows WHERE
ended_at IS NOT NULL. So every webhook session stayed with ended_at NULL ->
unprunable -> unbounded state.db growth. This was the primary driver of the
SQLite lock-contention gateway outage.

Fix: wrap the delivery in _run_delivery_and_close, which awaits
handle_message and then (in finally, so failures still reap) calls
_end_webhook_session -> SessionDB.end_session(session_id, 'webhook_complete').
This mirrors how cron closes its session with 'cron_complete'
(cron/scheduler.py:3065). end_session is first-reason-wins and no-ops on an
already-ended row, so it never clobbers a compression/agent_close reason.

Adds tests/gateway/test_webhook_session_close.py asserting the invariant
(a completed webhook session has ended_at set + is prunable), including the
error-path case, against a real SessionStore + SessionDB.

* chore: map gumclaw@gumroad.com in AUTHOR_MAP for PR #57322 salvage

* refactor(gateway): add SessionStore.peek_session_id public accessor for webhook close

Replace the webhook delivery-close path's direct reach into private
SessionStore._entries (which also bypassed the store lock) with a public,
lock-held peek_session_id(session_key) accessor. Mirrors the existing
lookup_by_session_id inverse helper. Keeps a getattr fallback for older
stores / test doubles. Adds a unit test for the accessor.

* fix(agent): resolve review findings on vLLM local-context salvage

Salvage review of #56431 surfaced one Critical + two Warning issues; fix
them on top of the contributor's cherry-picked commits:

1. Critical — duplicate non-agentic warning on the interactive CLI. The new
   agent_init warning fires on every platform, but cli.py show_banner()
   already warns on CLI (richer output + /model hint), so a CLI user saw the
   warning twice per startup. Guard the agent_init emit to skip platform=="cli"
   — it now fills exactly the gateway/TUI gap the PR intended, no duplication.

2. Warning — vLLM error-parse regex under-matched. The patterns required a
   literal space before the number, so "max_model_len: 32768", "=32768",
   "(32768)", and "... is 32768" all returned None. Broaden both patterns to
   accept :/=/(/ 'is' delimiters. Add a parametrized test over all delimiter
   variants.

3. Warning — per-call live probe latency on local endpoints. The new
   reconcile-on-hit + pre-defaults step-7 probe made every local resolution
   fire a synchronous network probe (banner + /model switch + compressor
   update_model each within one startup). Add a 30s in-process TTL cache
   keyed by (model, base_url) around _query_local_context_length so back-to-
   back resolutions reuse one round-trip; not persisted to disk, so the
   reconcile freshness contract (re-probe after restart) is preserved. Add an
   autouse fixture clearing the cache between tests + TTL coverage.

Tests: 148 passed (was 138). ruff clean.

* chore(release): add infinitycrew39 to AUTHOR_MAP (#56431 salvage)

* chore: add trismegistus-wanderer to AUTHOR_MAP for PR #31856 salvage

* fix(dashboard): disable ws keepalive ping on loopback to survive event-loop stalls

Desktop/dashboard WebSocket connections drop during long agent operations
(delegate_task subagents, large model outputs) when the uvicorn event loop is
GIL-starved for minutes. Root cause: uvicorn's ws keepalive ping runs on the
SAME event loop as agent turns. A single synchronous GIL-holding call on a
worker thread (a regex/scrub over a large output, or a long subagent turn)
freezes the loop, so it cannot process the incoming pong within ws_ping_timeout
and uvicorn closes an otherwise-healthy connection (#53773: 'event loop stalled
226.3s'; #48445/#50005). Loosening the timeout only raises the threshold — a
multi-minute stall sails past any finite window.

The keepalive ping exists to detect half-open connections (reverse-proxy 524,
dropped tunnels), which cannot happen on loopback: there is no network or proxy
in the path, and a dead local client tears the socket down with a real FIN/RST
that starlette surfaces as WebSocketDisconnect regardless of the ping. So on
loopback the ping provides ~no liveness value while actively killing
recoverable stalls — disable it entirely (ws_ping_interval/timeout=None).

Non-loopback (public) binds sit behind a Cloudflare Tunnel where half-open IS a
real failure mode, so the ping stays at 20/20 to detect it.

Empirically verified (real uvicorn + websockets peer): with ws_ping=None the
server never closes a silent peer during an 8s window; with the pre-fix 2s/2s
window uvicorn closes it. A genuinely-dead client still fires the
WebSocketDisconnect reap path regardless of the ping.

Note: this fixes the local Desktop case (the OP's scenario). A remote Desktop
over an authenticated public dashboard route (McCalebTheSecond's comment) keeps
the ping and needs the deeper GIL-hotspot fix — tracked separately.

Closes #53773

* fix(agent): self-review follow-ups on vLLM local-context salvage

Self-review (ruff+ty lint diff = 0 net-new; 2-agent deep review) surfaced one
Warning + comment-accuracy nits; no Critical:

- W1: the local-probe TTL cache memoized None (probe failure) for 30s, so a
  probe that failed during a startup race would suppress a legit retry once
  the server came up. Cache only positive results — still fully bounds the
  hot-path probe rate (reachable servers cache their value) while an
  unreachable one re-probes on the next call. Add a regression test asserting
  a None result is NOT cached (retry re-probes); mutation-verified.

- Tighten the platform-guard comment: gateway/TUI/cron already construct with
  quiet_mode=True (gated by `not agent.quiet_mode`), so the guard's active job
  is CLI dedup vs show_banner, not "filling the gateway/TUI gap" as originally
  worded.

Verified not-issues (per review): positive-value 30s cache does not break the
reconcile-after-restart freshness contract (restart = fresh process, empty
cache); cache key is collision-safe; platform guard is correct in both
directions (no runtime path leaves platform None on a non-CLI surface).

Tests: 149 passed. ruff clean; ty 0 net-new vs base.

* fix(gateway): keep idle cached agents alive until session actually expires

The idle-TTL sweep (_sweep_idle_cached_agents) was evicting agents
as soon as they passed _AGENT_CACHE_IDLE_TTL_SECS, even when the
session hadn't expired yet. In daily-reset mode the reset can fire
hours after the last user message — evicting the agent early means
the session-expiry watcher has no agent in cache to call
on_session_end() with, so memory providers miss the live transcript.

Now the sweep checks the session store before evicting: if the
session still exists and hasn't expired, the agent stays in cache
so the expiry watcher can tear it down properly later.
When the session store is unavailable or throws, falls back to the
original eviction behavior (safe default).

Fixes: #11205

* fix(gateway): complete on_session_end coverage across all eviction paths

Follow-up to the cherry-picked #31856 fix. The contributor's guard defers
idle-TTL eviction until the session store reports the session expired, so the
expiry watcher can tear the agent down and fire MemoryProvider.on_session_end()
with the live transcript. Two gaps remained:

1. Memory-leak regression for mode='none' sessions. _is_session_expired()
   returns False forever for the 'none' reset policy, so the naive guard would
   never idle-evict those agents — reopening the unbounded-cache leak the idle
   sweep (#11565) exists to relieve. Added SessionStore.is_session_finalizable()
   (a public predicate: will the expiry watcher EVER finalize this session?) and
   gate the deferral on it. mode='none' agents fall through to soft eviction as
   before.

2. on_session_end still dropped on the LRU-cap path. Both cache-pressure paths
   (_enforce_agent_cache_cap and _sweep_idle_cached_agents) soft-evict via
   _release_evicted_agent_soft, which by design does NOT fire on_session_end.
   If cache pressure evicts a finalizable-but-not-yet-expired agent before it
   expires, the watcher later finds no cached agent and the hook is skipped.
   Added _commit_memory_before_soft_evict(): at LRU eviction, if the session is
   finalizable and not yet expired, commit end-of-session extraction via the
   live agent's own (fully-scoped) memory manager using commit_memory_session()
   — extraction WITHOUT provider teardown, so the eviction stays soft and a
   resumed turn keeps working. Skipped for mode='none' (no missed boundary to
   compensate) and expired sessions (the watcher tears those down directly).

This closes #11205 for ALL eviction paths and reset policies, not just the
idle-sweep + finite-policy case, while preserving the soft-eviction
resumability contract (never calls close() on a live session).

Tests: 5 new cases in test_agent_cache.py (mode='none' still reaped, LRU-cap
commits for finalizable / skips for none, real is_session_finalizable
predicate); all mutation-checked. Contributor's original 2 tests updated to
assert the finalizable path explicitly.

* fix(providers): pass extra headers to model discovery

* refactor(providers): dedupe extra_headers normalizer + key picker groups by headers

Follow-up to @helix4u's #57336 salvage. Two review findings:

- W1: model-picker grouped custom-provider rows by
  (api_url, credential, api_mode) but NOT extra_headers. Entries sharing a
  URL+credential+api_mode yet declaring different headers (e.g. per-tenant
  routing behind one proxy) collapsed into one row and probed /models with
  whichever header set was seen first (order-dependent). Fold a canonical
  header identity into group_key so distinct header-authed endpoints stay
  separate; drops the now-dead first-non-empty merge branch.
- W2: the extra_headers stringify+None-filter comprehension existed in 5
  copies (config.py x2, runtime_provider.py, model_switch.py, models.py).
  Extract one shared hermes_cli.config.normalize_extra_headers primitive;
  all sites now call it.

Tests: +normalize_extra_headers unit tests, +regression test proving two
same-endpoint entries with different headers stay distinct and each probes
with its own headers. 223 targeted tests pass; ruff clean.

* fix(desktop): let settings content use full pane width

Remove the max-w-4xl wrapper from SettingsContent so every settings
page can use the available overlay width.

* fix(desktop): extend profile startup REST timeouts (#48504)

* fix(desktop): extend startup long-timeout to the whole boot data burst

Broadens Tranquil-Flow's profile-startup timeout fix (#48518) from getProfiles
+ refreshActiveProfile to the rest of the calls the desktop fires during
connect: /api/config, /api/config/defaults, /api/model/info, /api/model/options,
/api/cron/jobs. On a profile-heavy or remote install any of these can exceed
the 15s DEFAULT_FETCH_TIMEOUT_MS while the backend is alive-but-busy (e.g.
list_profiles walks the skill tree per profile), surfacing as the spurious
"Timed out connecting to Hermes backend after 15000ms" that hangs the UI
(#48504).

Uses the surgical per-call mechanism (renamed STARTUP_PROFILE_REQUEST_TIMEOUT_MS
→ STARTUP_REQUEST_TIMEOUT_MS) rather than raising the global default (the
alternative in #48526): the liveness poll /api/status and all interactive/
runtime calls keep the short default, so a genuinely-dead backend is still
detected fast and the boot readiness probe (waitForHermes) is untouched.

Supersedes #48518 (carried as the base commit) and #48526 (global-default
raise). Fixes #48504.

Co-authored-by: YapBi <129007007+HeLLGURD@users.noreply.github.com>
Co-authored-by: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com>

* fix(webhook): close per-delivery session at the true end of the run (#57423)

The merged webhook session-close fix (#57370, salvaging #57322) wrapped
handle_message in a try/finally — but BasePlatformAdapter.handle_message
is fire-and-forget: it spawns _process_message_background and returns
before the agent run starts. The finally-close therefore ran BEFORE
get_or_create_session created the session row, found no session_id, and
silently no-op'd — the ghost-session leak persisted on the real path.
(The shipped test masked this by stubbing handle_message with a fake
that created the row synchronously.)

Move the close to an on_processing_complete override — the lifecycle
hook the base class fires at the TRUE end of the run, on the success,
failure, and cancellation paths alike. Empirically verified through the
real fire-and-forget pipeline: before, ended_at stayed NULL; after,
ended_at is set with end_reason=webhook_complete and the row is
prunable.

Tests now stub only the runner-side _message_handler (the seam the live
gateway injects) so handle_message / _process_message_background /
on_processing_complete all run for real; adds an AsyncSessionDB-facade
coverage test for the coroutine-await branch.

* fix(dump): flag API keys visible only to the shell, not the managed backend

hermes debug share reads os.getenv — the invoking terminal's environment — but
launchd/systemd and the desktop-spawned `serve` backend load credentials from
~/.hermes/.env, not the login shell. A key exported in the shell but absent
from .env is invisible to the backend, yet the dump printed a bare "set",
sending support down a phantom "the key is configured" path.

This was the actual trap behind a "Desktop has no web_search / no tools"
report: FIRECRAWL_API_KEY was a shell export (so `debug share` in a terminal
read "firecrawl set") but not in .env, so the launchd backend's
check_web_api_key returned False and web_search was gated off — which a
contributor then misdiagnosed as a missing `desktop` platform registration.

The dump now annotates any key set in-process but missing from ~/.hermes/.env
with "(shell only — not in .env; managed/desktop backend may not see it)" so
the mismatch is obvious instead of hidden behind "set".

* feat(skills): add security/unbroker (autonomous data-broker removal)

unbroker finds where a consenting person's info is exposed across data
brokers and people-search sites and files the removals, running as far as
each site allows and handing only genuinely human-only steps (hard CAPTCHA,
gov-ID, phone, fax) back as an end-of-run digest.

- Deterministic stdlib CLI (scripts/pdd.py) owns config, dossiers+consent,
  the broker DB, tier planning, the ledger, email, and the autonomous
  action queue; the agent scans/submits with native tools (web_extract,
  browser_*, delegate_task, cronjob, terminal).
- Verify-before-disclose, least-disclosure (never volunteers SSN), consent
  gate, opaque ids, optional age-at-rest encryption, file-locked ledger.
- Jurisdiction-aware (CCPA/CPRA, GDPR, generic); CA DROP one-shot covers
  the state registry (~545) in a single request; BADBOOL + curated
  people-search coverage; scheduled re-scan for re-listing.
- No CAPTCHA-solving services or anti-bot bypass; browser email mode needs
  no stored password.
- 85 hermetic tests (tests/skills/test_unbroker_skill.py; SMTP/IMAP via
  injected fakes, registry via CSV fixtures). Ships placeholder data only.

Broker dataset adapted from BADBOOL (Yael Grauer, CC BY-NC-SA 4.0).

* docs(unbroker): point README image link at hermes-agent; sync test count (85)

* feat(desktop): cap overlay inner-page width at 75rem

Add a shared PAGE_MAX_W (1200px) and center OverlayMain within its pane
so settings and command center bodies stay readable instead of sprawling
on wide/ultrawide displays.

* fix(desktop): stop macOS Tahoe misplacing the traffic lights

On macOS Tahoe (Darwin 25+), a nonzero titleBarOverlay height makes
setWindowButtonPosition() miscalculate the native traffic-light position
(electron#49183), shoving the lights into the left titlebar tools. Pass
height 0 there so the lights land at the configured inset; the renderer
paints its own drag strips, so nothing is lost. Pre-Tahoe is unchanged.

Gate on the truthful Darwin kernel major (25 = Tahoe) rather than the
product version, which macOS reports as 16 or 26 depending on build SDK.

* fix(desktop): clear stale active todos on turn end AND on rehydration

A turn that ends without a final `todo` update left the composer "Tasks N/M"
panel pinned with its last item stuck pending/in_progress, and it survived
restarts because the panel is read back from stored session history.

Two coupled fixes (the first alone is undone by the second path):

- Turn end: clear a still-active todo list on `message.complete` and on a
  terminal `error` (new `clearActiveSessionTodos` — active lists only; a
  finished list keeps its short linger so the last checkmark still lands).
- Rehydration: `hydrateFromStoredSession` runs *after* a turn completes, so an
  "active" stored list is stale, not in-flight. It now restores only a
  *finished* list (via new `todosForHydration`) and drops anything still
  active — otherwise it re-pinned the panel right after the turn-end clear and
  resurrected it on every restart.

Salvages #52996 (@0disoft): the fix shape (clearActiveSessionTodos on turn
completion, preserving the finished-list linger) is carried forward and ported
onto the current use-message-stream/ folder split (gateway-event.ts), then
extended to the rehydration path per review.

Co-authored-by: 0disoft <rodisoft1@gmail.com>

* fix(unbroker): suppress-first for PeopleConnect (deletion undoes suppression)

PeopleConnect is the exception to deletion-beats-suppression: "DELETE MY
USER DATA" also deletes suppressions on file, and deletion does not stop the
people-search sites from showing you (public records re-list). Suppression
is the effective lever and must be maintained.

- intelius.json: deletion.prefer=false; playbook/quirks/notes rewritten with
  the verbatim privacy-center language; delete is the data-purge-only path.
- autopilot: honor deletion.prefer -> prefer_suppression when false.
- methods.md / SKILL.md / README: exception called out.
- tests updated + prefer-flag routing test (86 tests).

* fix(file-tools): preserve container paths for docker file ops (#56637)

* fix(config): preserve owner on atomic writes (#56644)

* fix(desktop): use gift codicon for update-available toast

Add optional notification icon override and use codicon-gift on the
update-ready toast so it reads as a present rather than generic info.

* fix(desktop): use symbol-namespace codicon for Model settings nav

* fix(desktop): cancel downloads triggered by link-title fetch window

The hidden BrowserWindow used by fetchLinkTitle to scrape page titles
had no will-download handler on its session.  When a link artifact URL
responds with Content-Disposition: attachment, Electron fires will-download
and the file is saved for real — explaining the spurious download on the
Artifacts page.

Add guardLinkTitleSession() (parallel to the existing audio-mute guard for
#49505) that installs a will-download handler which immediately cancels
every download item on the hermes:link-titles session.  Call it from
getLinkTitleSession() right after the request-type blocklist is wired up.

* fix(slack): MPIMs (group DMs) obey shared-surface mention gating + reaction guard

Group DMs (MPIMs) were classified as DMs and thereby exempted from every
operator control that shared surfaces are supposed to honor: allowed_channels,
require_mention, strict_mention, free_response_channels, and the reaction
guard. Symptom: the bot added :eyes:/:white_check_mark: to unmentioned MPIM
messages and still invoked the agent (which then returned NO_REPLY) instead of
the gateway dropping the event before model execution. Removing an MPIM from
allowed_channels did not disable it.

Root cause is the DM classification at adapter.py:
    is_dm = channel_type in {"im", "mpim"}
used for BOTH routing exemptions and reaction gating. An MPIM is a shared
surface (multiple humans can see and trigger the bot), not a private 1:1 DM,
so it must be gated like a channel.

This behavior was introduced/reinforced by a trail of Slack group-DM PRs:
- #4633  fix(slack): treat group DMs (mpim) like DMs + reaction guard
- #54632 fix(slack): subscribe to message.mpim + mpim scopes so group DMs work
- #54663 fix(slack): group DMs work OOTB + reinstall nudge
#54632/#54663 correctly made MPIM messages *reachable*; #4633 over-reached by
giving them the DM mention/reaction *exemptions*. This corrects only that
over-reach.

Fix (minimal): introduce `is_one_to_one_dm = channel_type == "im"` and key the
two EXEMPTION sites off it instead of `is_dm`:
- mention/allowlist gating block (`if not is_one_to_one_dm and bot_uid:`)
- reaction guard (`(is_one_to_one_dm or is_mentioned)`)
`is_dm` is intentionally retained for session/thread scoping and chat_type
labeling, where treating an MPIM as a persistent multi-party conversation is
correct — only the mention/reaction exemptions were wrong.

Docs: slack.md now distinguishes 1:1 DMs (mention-exempt) from group DMs
(shared surface; obey require_mention/strict_mention/allowed_channels/
free_response_channels; reactions only when @mentioned).

Tests: +7 in test_slack_mention.py (MPIM unmentioned dropped under
require_mention and strict_mention; MPIM mentioned processed; MPIM off
allowed_channels dropped; MPIM in free_response opted in; 1:1 IM still exempt;
reaction guard drops unmentioned MPIM). Updated _would_process to model the
is_one_to_one_dm gating + strict_mention. 72 passed.

* test(slack): give the MPIM reaction-guard test real teeth

The reaction-guard regression test defined a local _should_react lambda and
asserted it against itself — a tautology that would stay green even if the
production guard at _handle_slack_message reverted to (is_dm or is_mentioned),
re-introducing the unmentioned-MPIM reaction spam this PR fixes.

Replace it with a shared _reaction_guard helper plus a source-introspection
test that pins the production expression: asserts (is_one_to_one_dm or
is_mentioned) is present and (is_dm or is_mentioned) is absent. Mutation-checked
— reverting the adapter guard now fails the test.

Follow-up self-review finding on the salvage of #57339.

* fix(agent): strip _db_persisted when assembling rotation compression transcript (#57491)

Shallow messages[i].copy() during context compression propagated the
_db_persisted marker from cached gateway incremental flushes into the
post-rotation compressed list. _flush_messages_to_session_db then skipped
every row when writing to the new child session, so gateway restarts
lost the compacted transcript (severe amnesia).

Strip the marker in _fresh_compaction_message_copy() and add regression
tests for rotation flush + compressor assembly.

Fixes #57491

* fix(agent): enforce marker-strip invariant with a single terminal sweep (#57491)

Follow-up to the per-site strips from the review gate. The two copy-site
strips are correct but positional — a copy site added after the assembly
loops would re-leak _db_persisted into the child-session flush. Add a single
terminal sweep (_strip_persistence_markers) run once on the fully-assembled
compressed list so the invariant 'no compacted message leaves compress()
carrying a persistence marker' is structural, not dependent on copy-site order.

- agent/context_compressor.py: _strip_persistence_markers() called before
  compress() returns; helper docstring notes the sweep is the authoritative guard
- tests/agent/test_context_compressor.py: structural regression — neuter the
  per-site helper to a leaking copy, assert the terminal sweep still strips
- tests/run_agent/test_compression_persistence.py: pin the fixture assumption
  behind the exact-equality row-count assertion

* fix(moa): default temperatures to unset — provider default, like single-model agents (#57440)

A single-model Hermes agent never sends temperature; the provider default
applies. MoA hardcoded reference_temperature=0.6 / aggregator_temperature=0.4,
and the coercion float(preset.get(key, 0.6) or 0.6) made unset IMPOSSIBLE to
express: absent, null, empty, and even an explicit 0 all collapsed to the
baked-in default. Every MoA advisor and aggregator therefore ran at 0.6/0.4
while the same model running solo used the provider default — silently
skewing solo-vs-MoA comparisons and overriding provider-tuned defaults.

- moa_config normalization: temperatures coerce to None when absent/blank/
  invalid (new _coerce_float_or_none); explicit values incl. 0 honored.
- moa_loop: _preset_temperature() resolves preset values; None flows to
  call_llm, which already omits the parameter when None (same contract as
  max_tokens). Aggregator still inherits the acting agent's own configured
  temperature when the preset doesn't pin one.
- conversation_loop (context-mode MoA): same resolution, no more hardcoded
  0.6/0.4 at the call site.
- DEFAULT_CONFIG preset + web_server payload models + docs updated: unset
  is the default, pinning stays available.

* fix(opencode-go): heal stripped /v1 base_url so non-minimax models stop 404ing (#57585)

OpenCode Go serves minimax/qwen via Anthropic Messages (base URL without
/v1 — the SDK appends /v1/messages) and glm/kimi/deepseek/mimo via OpenAI
chat completions (base URL WITH /v1). The runtime stripped /v1 for
anthropic-routed models, and the TUI/desktop + gateway persisted that
stripped URL to model.base_url. Every later chat_completions model then
POSTed to https://opencode.ai/zen/go/chat/completions — a 404 (the
marketing site). Result: only minimax worked; glm/deepseek/kimi all 404ed.

- New normalize_opencode_base_url(): symmetric /v1 normalization —
  strip for anthropic_messages, re-append for chat_completions /
  codex_responses on opencode.ai hosts (heals persisted stripped URLs;
  custom proxy overrides untouched)
- Applied at all three former one-way strip sites (resolve_runtime_provider
  x2, switch_model)
- opencode_model_api_mode: all Qwen models on Go AND Zen now route via
  /v1/messages per current published endpoint tables (previously only
  qwen3.7-max on Go — qwen3.6-plus etc. would 404 the same way)
- Catalog refresh: Go gains deepseek-v4-pro/flash, glm-5.2,
  kimi-k2.7-code, minimax-m3, qwen3.7-plus; Zen gains glm-5.2,
  kimi-k2.7-code, minimax-m3, qwen3.7-plus

Reported by IndieSuperhuman on X: opencode-go 404s for any model other
than minimax.

* feat(moa): per-preset fanout cadence — user_turn runs advisors once per user turn (#57591)

New preset key 'fanout': 'per_iteration' (default, unchanged behavior)
re-runs the reference fan-out whenever the advisory view changes — every
tool iteration. 'user_turn' runs the advisors ONCE per user turn and lets
the aggregator act alone for the rest of the tool loop — the original MoA
shape (upfront multi-model synthesis, then a single acting model), and the
obvious lever on MoA's wall/cost multiplier (advisor generation dominates
per-turn latency).

Implementation reuses the existing turn-scoped reference cache: in
user_turn mode the cache signature hashes only the prefix up to the LAST
user message, so mid-turn advisory-view growth doesn't change the key and
iteration 2+ is a cache HIT (advice reused, zero advisor spend, no
re-trace). A new user message changes the prefix and re-triggers the
fan-out. Unknown fanout values normalize to per_iteration.

* feat(desktop): CLI/dashboard parity — skills hub, MCP test/toggle/catalog, maintenance ops, log filters (#57441)

* feat(desktop): CLI/dashboard parity — skills hub browser, MCP test/toggle/catalog, maintenance ops, log filters

Brings desktop GUI to parity with hermes skills/mcp/doctor/backup/debug-share/
curator/memory CLI commands and the dashboard's System + Skills-hub pages:

- Skills page: new Browse Hub tab (search official/GitHub/community sources,
  preview SKILL.md, security scan verdicts, install/update with live action log)
- MCP settings: connection test (tool listing), per-server enable/disable
  toggle, and a Catalog tab installing Nous-approved MCP servers with env prompts
- Command Center: new Maintenance section (doctor, security audit, backup,
  debug share links, curator status/pause/run, memory file status + reset)
- Command Center system logs: file (agent/errors/gateway/desktop), level, and
  substring filters instead of a fixed agent.log tail
- hermes.ts API client + types for all the above; en/zh locale strings (ja and
  zh-hant inherit via defineLocale)

* feat(desktop): backend model catalogs in toolset config — hermes tools parity

Completes the `hermes tools` parity gap: after picking an image/video
generation backend the CLI runs a model picker (e.g. FAL's multi-model
catalog with speed/strengths/price); the desktop toolset drawer now has the
same flow as a radio-card list.

- web_server: GET /api/tools/toolsets/{name}/models (catalog + current +
  default for the active or named provider row) and PUT .../model
  (validated write to image_gen.model / video_gen.model), reusing the CLI's
  plugin catalog helpers so GUI and `hermes tools` stay in lockstep
- desktop: ModelCatalogPicker in ToolsetConfigPanel — per-model cards with
  speed/strengths/price, in-use + default badges, disabled until the
  backend is the active one; provider selection now mirrors is_active
  locally so the catalog unlocks without a refetch
- tests: 3 backend endpoint tests (catalog shape invariants, persist +
  validation), 2 component tests, 2 API-contract tests; en/zh strings

* fix(browser): retry next candidate when debug launch exits early

* fix(browser): surface launch diagnostics when debug browser never opens the CDP port

Follow-up to the salvaged early-exit retry fix (#35617): the debug-browser
launch path was fire-and-forget (stderr to DEVNULL, no logging), so every
platform failure — Windows singleton forward to an existing instance, bad
profile dir, missing shared libraries, policy blocks — collapsed into the
same unactionable 'port 9222 isn't responding yet' message and debug
reports contained nothing.

- launch_chrome_debug() returns a structured ChromeDebugLaunch with
  per-candidate attempts (state, exit code, stderr tail)
- browser stderr is captured to <hermes_home>/chrome-debug/launch-stderr.log
- clean exit (code 0) without the port opening is detected as Chromium's
  single-instance forward and produces a targeted user hint to close all
  running instances of that browser
- crash exits surface the stderr tail (e.g. missing libnspr4.so)
- every spawn/exit is logged to agent.log so hermes debug share captures it
- CLI (/browser connect) and TUI/desktop (browser.manage) both print the hint

* fix(moa): user_turn fanout — synthetic advisory marker must not count as a user turn (#57598)

The advisory view appends a synthetic user marker when it ends on an
assistant turn (Anthropic end-on-user rule) — i.e. on every tool iteration
after the first. The user_turn prefix hash treated that marker as the last
user message, so the hashed prefix included the grown mid-turn context and
the signature changed every iteration: advisors re-ran per iteration,
silently defeating the once-per-turn cadence (live smoke test: 2 fan-outs
for a 2-iteration task; expected 1). Hoist the marker to a module constant
and skip it when locating the last REAL user message. Verified: iteration-2
signature now equals iteration-1 (cache HIT); a new real user message still
re-triggers the fan-out.

* fix(desktop): poll messaging sessions so platform traffic appears live

Inbound Telegram/WeChat/Discord messages are written by the background
gateway, not the desktop websocket that drives local chats. Without
explicit polling the messaging sidebar and the open transcript stay
frozen until the user manually refreshes.

Desktop:
- MESSAGING_POLL_INTERVAL_MS (10 s): interval poll of the messaging
  session list so new platform sessions surface automatically.
- ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS (5 s): poll the currently-
  viewed messaging transcript and re-hydrate the chat state when the
  FNV-1a signature changes (hash covers role + timestamp + content).
- sameCronSignature now compares lineage_root_id / source / profile /
  preview / message_count / last_active / ended_at so stale previews
  and activity times are no longer silently ignored.
- sessionMatchesStoredId helper de-dups the id / _lineage_root_id check.
- refreshMessagingSessions exposed from useSessionListActions so the
  controller can use it in the poll effect.

Gateway:
- SessionStore._compression_tip_for_session_id: look up the latest
  compression continuation for a session id.
- SessionStore._heal_compression_tip_locked: rewrite a stale entry to
  the compression child before returning it, so a restart or failed send
  no longer leaves the store pinned to the compressed parent.

Co-authored-by: lawyer112 <lawyer112@users.noreply.github.com>

* refactor(desktop): only poll the transcript when it's a messaging session

The active-transcript poll armed a 5 s timer for every selected session
and no-op'd inside the tick for local chats (already live over the
websocket). Derive activeIsMessaging and gate the effect on it so local
chats never spin an idle timer.

* test(gateway): stub get_compression_tip in stale-guard db mock

The routing-heal added to get_or_create_session calls
SessionDB.get_compression_tip; the stale-guard suite's bare MagicMock db
returned a Mock the heal then assigned as session_id, failing JSON
serialization. Model the real contract (a non-compressed…
zebadee2kk added a commit to zebadee2kk/hermes-agent that referenced this pull request Jul 5, 2026
…_server.enabled is false (#3)

* fix(desktop): guard configured-cwd override against active sessions

Follow-up to the #39227 salvage: config refreshes fire mid-session too
(gateway events, settings saves), so applying terminal.cwd
unconditionally would yank the workspace out from under an attached
session. Gate the override on activeSessionIdRef like the sibling
reasoning/tier settings, keep branch refresh on the live cwd, and add
coverage for the active-session path. Also lint-polish the new test
file (typed config mock, prettier formatting).

* chore: add AUTHOR_MAP entry for @sahibzada-allahyar (#39227 salvage)

* fix(terminal): stop stripping CLAUDE_CODE_OAUTH_TOKEN from spawned subprocesses (#56935)

CLAUDE_CODE_OAUTH_TOKEN is set and owned by the user's Claude Code
install (subscription OAuth), not a Hermes-managed inference
credential — Claude subscription auth is not a working Hermes provider
path. Blocklisting it broke agent-spawned claude CLIs: with no token in
the child env, claude fell through to the shared macOS Keychain /
~/.claude/.credentials.json store and, on auth failure, cleared it —
logging the user out of their interactive Claude sessions and the
desktop app.

Exempt it from _HERMES_PROVIDER_ENV_BLOCKLIST (it arrives via the
anthropic registry entry, so discard explicitly with rationale).
ANTHROPIC_API_KEY / ANTHROPIC_TOKEN and every other provider credential
remain stripped, and the GHSA-rhgp-j443-p4rf fail-closed passthrough
guard is unchanged for everything still on the blocklist.

Fixes #55878

* feat(delegation): unify concurrency caps — deprecate max_async_children (#56955)

delegation.max_concurrent_children is now the single cap for both a
batch's parallelism and concurrent background delegation units.

- _get_max_async_children() delegates to _get_max_concurrent_children();
  a leftover max_async_children key logs a one-time deprecation warning
- config v32→33 migration removes the stale key, folding a raised
  max_async_children into max_concurrent_children (max wins, no lost
  headroom)
- capacity error messages now point at max_concurrent_children
- pool-at-capacity sync fallback now attaches an explanatory note so
  the model/user know why the call blocked instead of dispatching async

Previously users who raised max_concurrent_children (e.g. to 15) still
hit the invisible default-3 async cap: the 4th background delegate_task
silently ran inline, blocking the turn with no signal.

* fix(webhook): remove unused payload from delivery state

* fix(webhook): remove unused payload from delivery state

* chore: add AUTHOR_MAP entry for @VolodymyrBg (#2861 salvage)

* fix(config): accept 'on' as truthy for env flags via shared env_var_enabled helper

Salvage of #2863 by @aydnOktay, reimplemented against current main using the
existing utils.env_var_enabled / TRUTHY_STRINGS helper instead of per-site
tuple edits. Covers the 7 gateway/config.py env-flag sites that still rejected
'on' (WHATSAPP_ENABLED, SIGNAL_IGNORE_STORIES, MATRIX_ENCRYPTION,
API_SERVER_ENABLED, WEBHOOK_ENABLED, MSGRAPH_WEBHOOK_ENABLED,
BLUEBUBBLES_SEND_READ_RECEIPTS) plus HERMES_DESKTOP gating in
read_terminal/close_terminal. The PR's approval.py HERMES_YOLO_MODE portion is
already on main via is_truthy_value.

* test: env-flag 'on' truthy behavior contract (#2863 follow-up)

* feat(gateway): per-channel model and system prompt overrides (Fixes #1955)

- config: ChannelOverride + PlatformConfig.channel_overrides

- run: _resolve_model_for_channel, _get_system_prompt_for_channel, channel provider runtime

- tests: channel overrides + config guard for bare runner; conftest asyncio fix; slack/whatsapp warning filters

Made-with: Cursor

* feat(gateway): per-channel model and system prompt overrides (Fixes #1955)

- ChannelOverride + channel_overrides on PlatformConfig
- Resolve model/runtime: session /model, then channel_overrides, then global
- Thread/parent channel lookup; bridge discord.channel_overrides from YAML
- Drop unrelated test and delegate_tool changes from PR scope

* feat(gateway): per-channel model and system prompt overrides (Fixes #1955)
- ChannelOverride + channel_overrides; session /model > channel > global
- Thread/parent lookup; YAML bridge for discord.channel_overrides
- Guard channel_overrides when config lacks platforms (test mocks)
- Add sampiyonyus@gmail.com to AUTHOR_MAP

* fix(email): harden adapter against malformed IMAP responses

Salvage of #2794 by @CharmingGroot, ported to the relocated
plugins/platforms/email/adapter.py:

- Guard raw_email = msg_data[0][1] against IndexError/TypeError and
  non-bytes payloads. UIDs are added to _seen_uids before fetch, so an
  exception mid-batch permanently skipped every remaining message in
  the batch — now the bad message is logged and skipped instead.
- Message-ID domain generation falls back to 'localhost' when
  EMAIL_ADDRESS lacks '@' (now via a shared _message_id_domain() helper
  covering all 3 send paths; the PR fixed 2 of 3).

* feat(api-server): inline MEDIA: image tags as base64 data URLs for remote frontends

Salvage of the surviving piece of #2696 by @tarunravi. The PR's other two
changes (tool progress streaming, SSE None-sentinel fix) were independently
superseded on main by the structured hermes.tool.progress SSE events and the
rewritten queue-drain loop.

Remote OpenAI-compatible frontends can't read server-local file paths, so
MEDIA:<path> tags (browser screenshots, generated images) were dead text.
_resolve_media_to_data_urls() now inlines small (<=5MB) local images as
markdown data URLs across all four response surfaces: chat completions
(non-streaming), session chat, session chat stream final event, and the
Responses API. Non-image, missing, or oversized paths pass through
untouched.

* fix(cli): reliable interrupts, bounded exit, and exit feedback (#57000)

Three CLI reliability fixes:

1. Interrupt reliability: chat() only re-queued the user's interrupt
   message when the turn result carried interrupted=True. When the agent
   thread raced past its last interrupt check (or finished) before the
   interrupt landed, the message was silently dropped — and the stale
   _interrupt_requested flag left on the agent instantly aborted the
   NEXT turn. Un-acknowledged interrupt messages are now re-queued as
   the next turn and the stale flag is cleared (only when the agent
   thread actually exited). The clarify-race path also parks the message
   in _pending_input instead of dropping it.

2. Slow exit (5+ min): stdlib ThreadPoolExecutor workers are non-daemon
   and joined unconditionally by concurrent.futures' atexit hook — even
   after shutdown(wait=False). One wedged tool worker (abandoned after
   interrupt/timeout) held the process open forever. Promoted
   async_delegation's daemon executor to a shared tools/daemon_pool
   module and adopted it in tool_executor (concurrent tool batches),
   memory_manager (background sync), delegate_tool (child timeout wrapper
   + batch fan-out), and skills_hub (source fan-out). Added a 30s exit
   watchdog (HERMES_EXIT_WATCHDOG_S) armed at _run_cleanup start as a
   backstop for wedged cleanup steps.

3. Exit jank: after prompt_toolkit tears down the input/status bars the
   terminal sat silent for the whole cleanup window, looking hung. Print
   'Shutting down… (finalizing session)' immediately at exit start.

E2E: live PTY interrupt of a foreground 'sleep 120' terminal tool now
aborts in ~1s and the typed message runs as the next turn; wedged-worker
+ wedged-cleanup subprocess exits in 5.8s (watchdog) instead of hanging.

* chore(release): map ai-lab@foxmail.com to CrazyBoyM

Adds the AUTHOR_MAP entry for CrazyBoyM (ai-lab@foxmail.com) so the
contributor-attribution CI check passes when PR #55828's commits are
rebase-merged with authorship preserved.

* fix(codex): extend stale timeout for gateway-scale tool payloads

Lower the openai-codex stale-timeout floor from 25k to 10k estimated
tokens so Telegram/gateway sessions (~20k tools+instructions) are not
aborted at the generic 90s cutoff while Codex is still prefilling.

* test(codex): cover gateway-scale stale timeout floor and TTFB gate

* docs(codex): clarify stale-floor docstring reflects the 10k gate

The helper docstring described the typical ~15-25k gateway payload but
read as if that were the trigger range; the floor actually engages above
10k tokens. Clarify the prose to match the gate.

* fix(browser): guard Camofox snapshot/vision/images on private pages

Follow-up to #56874, which added the Camofox private-page SSRF guard
(_camofox_current_page_private_url) but wired it only into the Camofox
eval path (_camofox_eval). The other Camofox content-read tools —
camofox_snapshot, camofox_get_images, and camofox_vision — still read the
current page's accessibility tree / images / screenshot without the
guard, so on a non-local Camofox backend they can return the content of
an intranet or cloud-metadata page (e.g. 169.254.169.254) that the
terminal itself can't reach.

Apply the same guard, gated on _eval_ssrf_guard_active (non-local
backend, not a local sidecar, allow_private_urls unset) and fail-open on
probe failure, matching the eval-path guard and the main-browser
snapshot/vision guards. camofox_back is intentionally not changed: its
target is unknown until navigation completes, and the subsequent content
read is already guarded.

Adds regression tests covering the three read tools blocking on a private
page, the public-page pass-through, and the guard-inactive no-probe path.

* feat(image-gen): support Codex image inputs

* test(image-gen): cap Codex reference inputs

* refactor(image-gen): reuse shared image sniffer + raster allowlist in codex backend

Replace the plugin-local _IMAGE_MAGIC_MIME table + _sniff_image_mime
body with a delegation to agent.image_routing._sniff_mime_from_bytes,
the canonical magic-byte sniffer already used across the codebase, then
gate its result to the raster formats gpt-image-2's Responses
input_image actually accepts (png/jpeg/gif/webp).

The shared sniffer also recognizes SVG/TIFF/ICO; without the allowlist
those would pass local validation and be rejected server-side with an
opaque HTTP 400. Gating locally fails them cleanly as invalid_image_input.
Adds a regression test for SVG rejection.

Follow-up on top of @CrazyBoyM's #55828.

* fix(whatsapp): resolve LID sender IDs to phone numbers in bridge message payload

WhatsApp has migrated to Linked Identity Device (LID) format for user
IDs (e.g. 244645917392975@lid instead of 18505551234@s.whatsapp.net).

The bridge already resolves LIDs to phone numbers for its own allowlist
check via buildLidMap(), but the senderId field in the message payload
sent to the gateway still contained the raw LID. This caused the
gateway's WHATSAPP_ALLOWED_USERS check to reject all messages as
unauthorized, since the LID numbers don't match the phone numbers in
the allowlist.

Fix: resolve LID → phone in the senderId, senderName, and chatName
fields of the event payload before sending to the gateway, using the
existing lidToPhone mapping.

* chore: add AUTHOR_MAP entry for @ajmeese7 (#3219 salvage)

* fix(status): label provider as custom when config.yaml model.base_url is set

Salvage of the surviving hunk of #3296 by @Mibayy. The PR's gateway
_handle_provider_command hunk targets code removed on main (/provider was
absorbed into /model + /status, which already read model.base_url); the
hermes status mislabel was the remaining live symptom:
_effective_provider_label() only checked the legacy OPENAI_BASE_URL env var,
so a custom endpoint configured canonically in config.yaml still displayed
as OpenRouter.

* feat(gateway): add 'log' option to display.tool_progress

Salvage of #3459 by @keslerm, reimplemented against the restructured
progress-callback block in gateway/run.py (resolve_display_setting,
needs_progress_queue, thinking-relay). Duplicate PR #3458 by @dlkakbs was
submitted 4 minutes earlier with the same feature — both credited.

Co-authored-by: Dilee <uzmpsk.dilekakbas@gmail.com>

tool_progress: log keeps the chat silent and appends timestamped tool-call
lines to ~/.hermes/logs/tool_calls.log via a dedicated queue drained by an
async writer (RotatingFileHandler 5MB x 3, RedactingFormatter so secrets
never land on disk). Gateway-only by design; thinking_progress relaying and
the webhook gate are unaffected. /verbose now cycles
off -> new -> all -> verbose -> log.

* fix(i18n): add gateway.verbose.mode_log to all locale catalogs

* feat(commands): /compact alias + --preview/--dry-run flags for /compress (#3243 salvage)

Salvaged from PR #3243 by @Mibayy, reimplemented against current main
(the original diff targeted a removed gateway/run.py handler).

- /compact is now a first-class alias of /compress (CLI, gateway,
  Telegram/Slack/Discord command lists, autocomplete) — also fixes the
  dangling '/compact' references in gateway error messages
  (gateway/run.py context-exhausted banners).
- --preview / --dry-run: report what WOULD be compressed (message
  counts, token estimate, 'here [N]' boundary) without touching the
  transcript. Flags coexist with the existing 'here [N]' / focus-topic
  args on both the CLI and gateway surfaces via shared pure helpers in
  hermes_cli/partial_compress.py.
- --aggressive (LLM-free hard truncation) is intentionally NOT
  implemented: it would need its own transcript-persistence branch
  outside the guarded _compress_context rotation machinery (#44794
  data-loss class). The flag is recognized and returns an explanatory
  message pointing at '/compress here [N]' and /undo instead of being
  mis-parsed as a focus topic.
- locales: gateway.compress.aggressive_unsupported added to all 16
  catalogs (parity test enforced).
- release.py: AUTHOR_MAP entry for contributor credit.

* feat(api-server): per-client model routing via model_routes (#3176 salvage)

Adds a no-code routing layer to the OpenAI-compatible API server so one
Hermes deployment can map different API clients to different
model/provider backends. Clients pick a backend by sending a configured
alias as the OpenAI 'model' field; unmatched values fall back to the
global model. Configured aliases are listed by GET /v1/models.

Precedence (highest first): session /model override > model_routes
route > global config. Route provider credentials resolve through
_resolve_runtime_agent_kwargs_for_provider (same seam as
channel_overrides); per-route api_key/base_url are upstream provider
credential overrides — never caller auth, never logged.

Salvaged and rebased from PR #3176 by @Mibayy onto current main.

* feat(config): extra HTTP headers for LLM API calls (#3526 salvage)

Named providers / custom_providers entries in config.yaml now accept an
extra_headers dict scoped to that endpoint — for reverse proxies, API
gateways, and custom auth schemes (e.g. Cloudflare Access service tokens).

- hermes_cli/config.py: normalize extra_headers on provider entries
  (_normalize_custom_provider_entry + providers-dict translation), add
  get_custom_provider_extra_headers /
  apply_custom_provider_extra_headers_to_client_kwargs helpers keyed on
  base_url (case/trailing-slash insensitive, no substring bypass —
  mirrors the TLS helpers)
- hermes_cli/runtime_provider.py: surface extra_headers in the resolved
  runtime for named custom providers (providers dict, legacy
  custom_providers list, and the credential-pool path)
- run_agent.py / agent/agent_init.py: merge per-provider extra_headers
  onto the OpenAI client default_headers at construction and on every
  _apply_client_headers_for_base_url re-application (credential swaps,
  rebuilds), most-specific level wins; OpenAI-wire only (native
  Anthropic/Bedrock scoped out)
- agent/auxiliary_client.py: accept model.extra_headers as an alias of
  model.default_headers for the global variant
- cli-config.yaml.example: documented commented example
- Header values are treated as secrets and never logged

Salvaged from PR #3526 by @jneeee, reimplemented against current main.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>

* feat(gateway): persist per-session /model overrides across gateway restarts

Per-session /model overrides (_session_model_overrides) were in-memory only,
so a gateway restart silently reverted every session to the global default
model. Persist the non-secret parts (model/provider/base_url ONLY — never
api_key) into the session entry in sessions.json and lazily rehydrate them
on first use after a restart, re-resolving credentials through the normal
runtime provider resolution.

- gateway/session.py: SessionEntry.model_override field with
  sanitize_model_override() (allowlist: model/provider/base_url) applied on
  both serialization and deserialization; SessionStore.set_model_override /
  get_model_override accessors. reset_session() already creates a fresh entry,
  so /new keeps its clear-on-reset semantics — a restart cannot resurrect an
  override the user reset away.
- gateway/slash_commands.py: write-through at both /model set sites (text
  command + picker) after storing the in-memory override.
- gateway/run.py: _rehydrate_session_model_override() called from
  _resolve_session_agent_runtime(); in-memory state always wins, credentials
  are re-resolved per provider (credential-less fallback on failure). Session
  expiry finalization also drops the persisted override.
- tests/gateway/test_session_model_override_persistence.py: restart
  round-trip, /new clearing, api_key-never-serialized (including tampered
  sessions.json), rehydration + live-state precedence + credential-failure
  degradation.

Salvaged from #3659 by @Git-on-my-level, narrowed to the restart-persistence
gap confirmed in triage.

* fix(desktop): parse multiline slash commands + hand degenerate payloads back

parseSlashCommand used /^(\S+)\s*(.*)$/ where `.` can't cross a newline and
`$` anchors end-of-string, so any slash command whose arg contained a newline
(/goal <multi-line text>, a skill command with a long pasted context) failed
the whole match, parsed as an empty name, and rendered "empty slash command"
while the payload vanished — cleared from the composer and absent from the
Up-arrow history ring, which only derives from sent user messages.

- name now splits on any whitespace ([\s\S]* arg), matching the CLI and the
  gateway's split(maxsplit=1); multiline args flow to slash.exec intact
- the residual empty-name branch (bare "/", "/ text") restores the submitted
  text to the composer draft instead of eating it

Fixes #41323. Fixes #55510.

* fix(desktop): call checkUpdates() in startUpdatePoller so version pill auto-populates

startUpdatePoller() only called checkBackendUpdates() — never checkUpdates().
The statusbar version pill reads $updateStatus (set by checkUpdates()), so the
commit-behind counter stayed null after restart. It only appeared when the user
manually clicked the pill, which triggered checkUpdates() via openUpdateOverlayFor.

Added void checkUpdates() in three places alongside the existing
checkBackendUpdates() calls:
- On startup in startUpdatePoller()
- In the 30-minute setInterval callback
- In the onFocus handler

checkUpdates() uses the Electron IPC bridge (local git check), not the gateway,
so no mode gating is needed. The existing $updateChecking atom guard prevents
double-fire on overlap.

Fixes #53079

* fix(desktop): restore remote artifact rendering

* style(desktop): fix import ordering + padding lint in remote-artifact files

* feat(desktop): /journey opens the memory graph overlay instead of printing text

* style(desktop): fix pre-existing import-order lint in use-prompt-actions

* fix(desktop): restore remote file picker attachments

* fix(desktop): read attachment previews local-first in remote mode

attachImagePath fetched its thumbnail through readDesktopFileDataUrl, which in
remote mode routes every read to the gateway fs bridge. Paperclip picks,
clipboard saves, and OS drops always produce paths on the LOCAL machine, so the
gateway read 404s — toasting "image preview failed" and dropping the thumbnail
even though the attach itself works (upload reads local bytes via the Electron
bridge). Read the local bridge first and fall back to the remote facade, which
still serves in-app drags from the remote project tree. Local mode is
unchanged (the facade already reads locally there).

Follow-up to #56572, which restored the remote paperclip picker and made this
path reachable from the picker as well.

* fix(desktop): load remote model options before session

Both Desktop picker surfaces (status-bar model menu, settings/onboarding
dialog) only asked the connected gateway's model.options once a session
existed; before that they fell back to the Desktop REST/global options, which
can't see virtual providers a remote gateway exposes — including the MoA
presets from #53817. Centralize the fetch rule in requestModelOptions(): prefer
the connected gateway whenever one exists (no session_id needed — the RPC
resolves disk config), REST only when no gateway is connected.

The status-bar MoA preset section now renders from the same model.options
payload (the virtual `moa` provider row) instead of the local /api/model/moa
REST config, so remote presets appear correctly; the row is filtered out of
the main provider groups so presets don't list twice. Preset selection keeps
the persistent switchTo path from #56417 and drops the vestigial session gate —
like regular model rows, a pre-session pick ships on the next session.create.

Fixes #53817.

Rebased and reconciled with #56417 (persistent MoA selection), which landed
after this PR was opened and covered its one-shot-/moa half.

* fix(terminal): set MSYS_NO_PATHCONV for Windows Git Bash subprocesses

Git Bash mangles native Windows command flags (/FO, /TN, /Create) into
bogus paths. Hermes terminal and background spawns now opt out by default
so tasklist, schtasks, and wmic work without manual prefixes.

Fixes #56700.

* test(terminal): cover MSYS_NO_PATHCONV defaults on Windows env builders

* fix(terminal): also set MSYS2_ARG_CONV_EXCL for MSYS2/Cygwin bash fallback

MSYS_NO_PATHCONV is honored by Git for Windows bash only. _find_bash's
final shutil.which fallback can return MSYS2-proper or Cygwin bash,
which ignore it and honor MSYS2_ARG_CONV_EXCL instead. Set both so argv
path conversion stays disabled regardless of which bash flavor spawns.
Also subsumes the cmd /c mangling in #56147.

* feat(desktop): collapse profile rail to a select past 13 profiles (#57306)

The colored-square rail stops scaling once a user racks up many profiles:
tiny drag targets and an endless horizontal scroll strip. Past a threshold
(13) the rail swaps the squares for a compact select dropdown — same active
tint + initial glyph, minus the drag-reorder / long-press-recolor / per-row
context menu that only make sense at small counts. Two render paths behind
one flag; the left default↔all toggle, the "+" create button, and Manage
stay put in both. Rename/delete/color remain reachable via Manage.

* Prevent deleted profile skeleton revival

* feat(auth): make xAI Grok OAuth device-code-only, drop loopback login

Replace the loopback/PKCE-callback server and manual-paste fallback with
the RFC 8628 device-code flow as the only xAI Grok OAuth login path. The
flow works in headless/SSH/container sessions with no 127.0.0.1 listener,
shrinking the local attack surface.

- Poll the token endpoint with server-provided interval, honoring
  slow_down and expires_in; store tokens with auth_mode
  oauth_device_code.
- Adaptive proactive refresh skew for short-lived device-code JWTs;
  rotated tokens sync back to auth.json, the global root store, and the
  credential pool (no refresh-token replay).
- Clear source suppression on successful re-login (CLI + dashboard) and
  drop the duplicate dashboard pool entry so exactly one seeded
  device_code entry exists.
- Use the shared device_code source name for consistency with the
  nous/codex device-code providers.
- Desktop: remove the loopback OAuth flow states and dead type variants;
  pkce providers' sign-in URL selection is unchanged.
- Docs (EN + zh-Hans) rewritten for device-code login; drop the deleted
  --manual-paste flag from documented commands.

* fix(auth): remove stale loopback_pkce reference in xAI quarantine removal list

The terminal-refresh quarantine filtered in-memory entries on
source == "device_code" but built removed_ids from the deleted
"loopback_pkce" source name, so the revoked device-code entry was
never pruned from the persisted pool in auth.json. Also restores the
_print_loopback_ssh_hint test suite scoped to Spotify (the helper's
remaining caller) instead of deleting it wholesale.

* fix(desktop): skip ensureBackend after profile-delete teardown to prevent respawn loop

When the renderer sends a DELETE /api/profiles/{name} request, the IPC
handler tears down the profile's pool backend (or primary backend) via
prepareProfileDeleteRequest.  However, the very next line calls
ensureBackend(profile), which spawns a fresh pool backend for the just-
deleted profile.  The new backend's startup path calls ensure_hermes_home(),
which recreates the profile directory — defeating the deletion and leaving
the process as a zombie.

On the next Desktop restart the cycle repeats: the profile directory exists,
the Desktop spawns a backend, the backend recreates the directory after
deletion, and PIDs accumulate indefinitely.

Fix: make prepareProfileDeleteRequest return the torn-down profile name.
The IPC handler uses this to route the DELETE to the primary backend
instead of spawning a new pool backend for the deleted profile.

Fixes #52279

* fix(desktop): refresh profile rail after deletion (#49289)

* fix(desktop,tui-gateway,zai): stop thinking-off from reverting to medium

A Z.ai desktop user reported thinking reverting to medium after one turn,
burning ~200% of a week's credits in 4 days despite reasoning_effort: false
in config.yaml. Four compounding bugs:

- _session_info reported reasoning_effort "" for disabled reasoning,
  indistinguishable from unset — the desktop adopted it after the first
  turn, wiping its sticky "thinking off" pick so every later chat
  reverted to the default effort.
- config.set key=reasoning always wrote agent.reasoning_effort to global
  config.yaml, so every desktop model-menu selection (preset.effort ??
  'medium') clobbered the user's configured value. Now session-scoped
  like the messaging gateway's /reasoning, landing on
  create_reasoning_override so lazily-built sessions keep it too.
- YAML `reasoning_effort: false`/`off`/`no` (boolean False) was coerced
  to "" by every loader's `str(x or "")`, silently re-enabling thinking.
  parse_reasoning_effort now treats False/"false"/"disabled" as
  {"enabled": False}; loaders (tui gateway, gateway, cli, cron,
  delegate) pass the raw value through. The desktop config reader also
  crashed on the boolean (false.trim()), aborting voice/STT settings.
- The zai provider profile never sent thinking on the wire, and GLM-4.5+
  defaults to thinking ON server-side — so disabling reasoning was a
  silent no-op on direct Z.ai, the actual token burner. The profile now
  emits extra_body.thinking {"type": "enabled"|"disabled"} for
  thinking-capable GLM models, mirroring the DeepSeek profile.

Also: /new (session reset) now carries reasoning_config across the
rebuild like model_override; config.get reasoning prefers the session's
live value and maps a config False to "none"; Settings shows "Off"
instead of a blank select for hand-written false.

* fix(cli): stop profile-bound backends before deleting so rmtree converges

delete_profile stopped only the process named in gateway.pid, but a Desktop
app spawns a headless `serve`/`dashboard` backend per profile that holds the
profile's SQLite connection open and keeps writing sessions/WAL/sandbox files.
That backend is never in gateway.pid, so a CLI `hermes profile delete` run
while the Desktop app is up left it writing into the tree — rmtree's final
rmdir then failed with ENOTEMPTY (#47368 "Bug 2"), and pre-guard it also
resurrected the directory.

- _profile_bound_backend_pids(): find running Hermes backends bound to this
  profile via a `--profile <name>` selector or a HERMES_HOME env resolving to
  the profile dir. Tightly scoped — current-user only, backend subcommands
  (serve/dashboard/gateway) only so an interactive chat is never killed, and
  never this process or its ancestors.
- _stop_profile_backends(): terminate them (graceful, then force), best-effort
  so it can never make delete worse.
- _rmtree_with_retry(): a few spaced retries absorb the ENOTEMPTY / Windows
  file-lock race from a just-terminated writer's in-flight -wal/-shm/sandbox
  writes instead of failing the whole delete on a race the next attempt wins.

Complements the recreation guard (deleted profiles no longer reappear) and the
Desktop teardown-before-delete flow; this is the CLI-side convergence fix for a
delete run while a Desktop-managed backend is live.

Part of #47368.

* fix(tui_gateway): route setup.runtime_check and setup.status to RPC pool

setup.runtime_check and setup.status are polled by the Desktop frontend on
connect and periodically (use-status-snapshot → evaluateRuntimeReadiness), but
neither was in _LONG_HANDLERS — so dispatch() ran both inline on the WS reader
thread. Under GIL pressure from concurrent agent turns (terminal I/O, large
output, background-process completions) either can block for seconds:

- setup.runtime_check → resolve_runtime_provider() (config read, auth check,
  may probe the provider endpoint)
- setup.status → _has_any_provider_configured() (provider config + credential
  scan)

While either blocks the reader thread the WS read loop can't service later
requests; the frontend RPC timeout fires, the client drops the socket, and the
lost setup.runtime_check response reads as ready=false — a false "needs setup"
/ "Settings failed to load" even though the provider is configured.

Route both to the RPC pool (same precedent as #55545's session.list/pet.info/
process.list). The handlers are read-only and pool writes go through the
lock-guarded write_json, so there's no ordering or safety concern.

Test asserts all 5 frontend-polled RPCs are pool-routed.

Co-authored-by: izumi0uu <izumi0uu@gmail.com>

* chore(release): map yingliang-zhang in AUTHOR_MAP for #57335

* fix(usage): capture reasoning_tokens from completion_tokens_details on chat_completions (#57340)

normalize_usage only read output_tokens_details.reasoning_tokens (the
Responses API shape). Chat Completions providers — OpenAI, OpenRouter,
DeepSeek, and every OpenAI-compatible proxy — report it under
completion_tokens_details.reasoning_tokens, so reasoning_tokens was 0 for
every chat_completions reasoning model: hidden thinking was invisible in
session accounting, MoA traces, and the eval's per-task token columns.

Measured impact (HermesBench MoA run on deepseek-v4-flash, 4,828 advisor
calls): reasoning_tokens showed 0 everywhere while individual calls burned
up to 21.5K hidden thinking tokens to emit ~500 visible tokens. Verified
live against OpenRouter: deepseek-v4-flash returns
completion_tokens_details.reasoning_tokens=61 for a 74-completion-token
call; the field was simply never read.

Responses-shape reads are unchanged; the new read only fires when the
Responses shape yielded nothing.

* fix(slack): keep blank-line-separated ordered items in one rich_text_list

When a Markdown ordered list has blank lines between items (common in
LLM-authored content), the list run loop breaks on each blank line.
Slack numbers each rich_text_list independently, so N items produce N
lists each starting at 1.

Skip blank lines inside the list run as soft separators instead of
breaking, so ordered items stay in one rich_text_list and Slack renders
the correct numbering.

Fixes #57076

* fix(slack): guard blank-line list continuation on next-item lookahead

Refine the blank-line handling so a blank line only continues a list run
when the next non-blank line is another list item. This keeps a list ->
paragraph -> list sequence as three separate blocks and matches the
contiguous-list layout for mixed/nested lists (one rich_text block, split
into sub-lists by (indent, ordered)), rather than emitting a separate
block per item.

Adds regression tests for the mixed blank-separated layout and the
list->paragraph->list boundary.

* refactor(slack): extract _is_list_line helper for list-marker checks

Deduplicate the '_BULLET_RE.match or _ORDERED_RE.match' idiom used at the
list-run entry guard and the blank-line lookahead into a single helper, so
adding future marker types is a one-point change. Pure refactor, no
behavior change (22 block_kit tests still pass).

* fix: refresh NVIDIA featured models

* fix(agent): honor live vLLM context limits on local endpoints

Reconcile stale local disk cache against live vLLM/Ollama max_model_len
probes, probe local servers before the llama hardcoded default, parse
vLLM max_model_len overflow errors, and surface the non-agentic Hermes 3/4
warning at agent init on gateway/TUI.

Sub-64K live probes are returned for startup rejection but are not
persisted to the context cache — preserving the 64K minimum-context
contract instead of normalizing undersized windows as valid config.

(cherry picked from commit c3a02db4fd9d57b7b0eb2732de91f8334d311aa5)

* test(agent): cover local vLLM context-length resolution

Add regression tests for vLLM max_model_len error parsing, stale local
cache reconciliation, live probes over llama defaults, and the 64K minimum
guard on persistent cache writes.

(cherry picked from commit 1cb47ef437de7ce289cb358e8d6b89e9194b43ed)

* fix(gateway): close webhook sessions on delivery completion so prune can reap them

Webhook deliveries created a unique one-shot session (delivery_id baked into
the session key at gateway/platforms/webhook.py:668) but the adapter fired
handle_message via asyncio.create_task WITHOUT ever ending the session
(webhook.py:713, pre-fix). Nothing else closes it: the gateway caches/expires
the agent per session_key but never calls end_session for the webhook path,
and _end_session_on_close teardown doesn't run for these fire-and-forget tasks.

SessionDB.prune_sessions (hermes_state.py:4965) only deletes rows WHERE
ended_at IS NOT NULL. So every webhook session stayed with ended_at NULL ->
unprunable -> unbounded state.db growth. This was the primary driver of the
SQLite lock-contention gateway outage.

Fix: wrap the delivery in _run_delivery_and_close, which awaits
handle_message and then (in finally, so failures still reap) calls
_end_webhook_session -> SessionDB.end_session(session_id, 'webhook_complete').
This mirrors how cron closes its session with 'cron_complete'
(cron/scheduler.py:3065). end_session is first-reason-wins and no-ops on an
already-ended row, so it never clobbers a compression/agent_close reason.

Adds tests/gateway/test_webhook_session_close.py asserting the invariant
(a completed webhook session has ended_at set + is prunable), including the
error-path case, against a real SessionStore + SessionDB.

* chore: map gumclaw@gumroad.com in AUTHOR_MAP for PR #57322 salvage

* refactor(gateway): add SessionStore.peek_session_id public accessor for webhook close

Replace the webhook delivery-close path's direct reach into private
SessionStore._entries (which also bypassed the store lock) with a public,
lock-held peek_session_id(session_key) accessor. Mirrors the existing
lookup_by_session_id inverse helper. Keeps a getattr fallback for older
stores / test doubles. Adds a unit test for the accessor.

* fix(agent): resolve review findings on vLLM local-context salvage

Salvage review of #56431 surfaced one Critical + two Warning issues; fix
them on top of the contributor's cherry-picked commits:

1. Critical — duplicate non-agentic warning on the interactive CLI. The new
   agent_init warning fires on every platform, but cli.py show_banner()
   already warns on CLI (richer output + /model hint), so a CLI user saw the
   warning twice per startup. Guard the agent_init emit to skip platform=="cli"
   — it now fills exactly the gateway/TUI gap the PR intended, no duplication.

2. Warning — vLLM error-parse regex under-matched. The patterns required a
   literal space before the number, so "max_model_len: 32768", "=32768",
   "(32768)", and "... is 32768" all returned None. Broaden both patterns to
   accept :/=/(/ 'is' delimiters. Add a parametrized test over all delimiter
   variants.

3. Warning — per-call live probe latency on local endpoints. The new
   reconcile-on-hit + pre-defaults step-7 probe made every local resolution
   fire a synchronous network probe (banner + /model switch + compressor
   update_model each within one startup). Add a 30s in-process TTL cache
   keyed by (model, base_url) around _query_local_context_length so back-to-
   back resolutions reuse one round-trip; not persisted to disk, so the
   reconcile freshness contract (re-probe after restart) is preserved. Add an
   autouse fixture clearing the cache between tests + TTL coverage.

Tests: 148 passed (was 138). ruff clean.

* chore(release): add infinitycrew39 to AUTHOR_MAP (#56431 salvage)

* chore: add trismegistus-wanderer to AUTHOR_MAP for PR #31856 salvage

* fix(dashboard): disable ws keepalive ping on loopback to survive event-loop stalls

Desktop/dashboard WebSocket connections drop during long agent operations
(delegate_task subagents, large model outputs) when the uvicorn event loop is
GIL-starved for minutes. Root cause: uvicorn's ws keepalive ping runs on the
SAME event loop as agent turns. A single synchronous GIL-holding call on a
worker thread (a regex/scrub over a large output, or a long subagent turn)
freezes the loop, so it cannot process the incoming pong within ws_ping_timeout
and uvicorn closes an otherwise-healthy connection (#53773: 'event loop stalled
226.3s'; #48445/#50005). Loosening the timeout only raises the threshold — a
multi-minute stall sails past any finite window.

The keepalive ping exists to detect half-open connections (reverse-proxy 524,
dropped tunnels), which cannot happen on loopback: there is no network or proxy
in the path, and a dead local client tears the socket down with a real FIN/RST
that starlette surfaces as WebSocketDisconnect regardless of the ping. So on
loopback the ping provides ~no liveness value while actively killing
recoverable stalls — disable it entirely (ws_ping_interval/timeout=None).

Non-loopback (public) binds sit behind a Cloudflare Tunnel where half-open IS a
real failure mode, so the ping stays at 20/20 to detect it.

Empirically verified (real uvicorn + websockets peer): with ws_ping=None the
server never closes a silent peer during an 8s window; with the pre-fix 2s/2s
window uvicorn closes it. A genuinely-dead client still fires the
WebSocketDisconnect reap path regardless of the ping.

Note: this fixes the local Desktop case (the OP's scenario). A remote Desktop
over an authenticated public dashboard route (McCalebTheSecond's comment) keeps
the ping and needs the deeper GIL-hotspot fix — tracked separately.

Closes #53773

* fix(agent): self-review follow-ups on vLLM local-context salvage

Self-review (ruff+ty lint diff = 0 net-new; 2-agent deep review) surfaced one
Warning + comment-accuracy nits; no Critical:

- W1: the local-probe TTL cache memoized None (probe failure) for 30s, so a
  probe that failed during a startup race would suppress a legit retry once
  the server came up. Cache only positive results — still fully bounds the
  hot-path probe rate (reachable servers cache their value) while an
  unreachable one re-probes on the next call. Add a regression test asserting
  a None result is NOT cached (retry re-probes); mutation-verified.

- Tighten the platform-guard comment: gateway/TUI/cron already construct with
  quiet_mode=True (gated by `not agent.quiet_mode`), so the guard's active job
  is CLI dedup vs show_banner, not "filling the gateway/TUI gap" as originally
  worded.

Verified not-issues (per review): positive-value 30s cache does not break the
reconcile-after-restart freshness contract (restart = fresh process, empty
cache); cache key is collision-safe; platform guard is correct in both
directions (no runtime path leaves platform None on a non-CLI surface).

Tests: 149 passed. ruff clean; ty 0 net-new vs base.

* fix(gateway): keep idle cached agents alive until session actually expires

The idle-TTL sweep (_sweep_idle_cached_agents) was evicting agents
as soon as they passed _AGENT_CACHE_IDLE_TTL_SECS, even when the
session hadn't expired yet. In daily-reset mode the reset can fire
hours after the last user message — evicting the agent early means
the session-expiry watcher has no agent in cache to call
on_session_end() with, so memory providers miss the live transcript.

Now the sweep checks the session store before evicting: if the
session still exists and hasn't expired, the agent stays in cache
so the expiry watcher can tear it down properly later.
When the session store is unavailable or throws, falls back to the
original eviction behavior (safe default).

Fixes: #11205

* fix(gateway): complete on_session_end coverage across all eviction paths

Follow-up to the cherry-picked #31856 fix. The contributor's guard defers
idle-TTL eviction until the session store reports the session expired, so the
expiry watcher can tear the agent down and fire MemoryProvider.on_session_end()
with the live transcript. Two gaps remained:

1. Memory-leak regression for mode='none' sessions. _is_session_expired()
   returns False forever for the 'none' reset policy, so the naive guard would
   never idle-evict those agents — reopening the unbounded-cache leak the idle
   sweep (#11565) exists to relieve. Added SessionStore.is_session_finalizable()
   (a public predicate: will the expiry watcher EVER finalize this session?) and
   gate the deferral on it. mode='none' agents fall through to soft eviction as
   before.

2. on_session_end still dropped on the LRU-cap path. Both cache-pressure paths
   (_enforce_agent_cache_cap and _sweep_idle_cached_agents) soft-evict via
   _release_evicted_agent_soft, which by design does NOT fire on_session_end.
   If cache pressure evicts a finalizable-but-not-yet-expired agent before it
   expires, the watcher later finds no cached agent and the hook is skipped.
   Added _commit_memory_before_soft_evict(): at LRU eviction, if the session is
   finalizable and not yet expired, commit end-of-session extraction via the
   live agent's own (fully-scoped) memory manager using commit_memory_session()
   — extraction WITHOUT provider teardown, so the eviction stays soft and a
   resumed turn keeps working. Skipped for mode='none' (no missed boundary to
   compensate) and expired sessions (the watcher tears those down directly).

This closes #11205 for ALL eviction paths and reset policies, not just the
idle-sweep + finite-policy case, while preserving the soft-eviction
resumability contract (never calls close() on a live session).

Tests: 5 new cases in test_agent_cache.py (mode='none' still reaped, LRU-cap
commits for finalizable / skips for none, real is_session_finalizable
predicate); all mutation-checked. Contributor's original 2 tests updated to
assert the finalizable path explicitly.

* fix(providers): pass extra headers to model discovery

* refactor(providers): dedupe extra_headers normalizer + key picker groups by headers

Follow-up to @helix4u's #57336 salvage. Two review findings:

- W1: model-picker grouped custom-provider rows by
  (api_url, credential, api_mode) but NOT extra_headers. Entries sharing a
  URL+credential+api_mode yet declaring different headers (e.g. per-tenant
  routing behind one proxy) collapsed into one row and probed /models with
  whichever header set was seen first (order-dependent). Fold a canonical
  header identity into group_key so distinct header-authed endpoints stay
  separate; drops the now-dead first-non-empty merge branch.
- W2: the extra_headers stringify+None-filter comprehension existed in 5
  copies (config.py x2, runtime_provider.py, model_switch.py, models.py).
  Extract one shared hermes_cli.config.normalize_extra_headers primitive;
  all sites now call it.

Tests: +normalize_extra_headers unit tests, +regression test proving two
same-endpoint entries with different headers stay distinct and each probes
with its own headers. 223 targeted tests pass; ruff clean.

* fix(desktop): let settings content use full pane width

Remove the max-w-4xl wrapper from SettingsContent so every settings
page can use the available overlay width.

* fix(desktop): extend profile startup REST timeouts (#48504)

* fix(desktop): extend startup long-timeout to the whole boot data burst

Broadens Tranquil-Flow's profile-startup timeout fix (#48518) from getProfiles
+ refreshActiveProfile to the rest of the calls the desktop fires during
connect: /api/config, /api/config/defaults, /api/model/info, /api/model/options,
/api/cron/jobs. On a profile-heavy or remote install any of these can exceed
the 15s DEFAULT_FETCH_TIMEOUT_MS while the backend is alive-but-busy (e.g.
list_profiles walks the skill tree per profile), surfacing as the spurious
"Timed out connecting to Hermes backend after 15000ms" that hangs the UI
(#48504).

Uses the surgical per-call mechanism (renamed STARTUP_PROFILE_REQUEST_TIMEOUT_MS
→ STARTUP_REQUEST_TIMEOUT_MS) rather than raising the global default (the
alternative in #48526): the liveness poll /api/status and all interactive/
runtime calls keep the short default, so a genuinely-dead backend is still
detected fast and the boot readiness probe (waitForHermes) is untouched.

Supersedes #48518 (carried as the base commit) and #48526 (global-default
raise). Fixes #48504.

Co-authored-by: YapBi <129007007+HeLLGURD@users.noreply.github.com>
Co-authored-by: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com>

* fix(webhook): close per-delivery session at the true end of the run (#57423)

The merged webhook session-close fix (#57370, salvaging #57322) wrapped
handle_message in a try/finally — but BasePlatformAdapter.handle_message
is fire-and-forget: it spawns _process_message_background and returns
before the agent run starts. The finally-close therefore ran BEFORE
get_or_create_session created the session row, found no session_id, and
silently no-op'd — the ghost-session leak persisted on the real path.
(The shipped test masked this by stubbing handle_message with a fake
that created the row synchronously.)

Move the close to an on_processing_complete override — the lifecycle
hook the base class fires at the TRUE end of the run, on the success,
failure, and cancellation paths alike. Empirically verified through the
real fire-and-forget pipeline: before, ended_at stayed NULL; after,
ended_at is set with end_reason=webhook_complete and the row is
prunable.

Tests now stub only the runner-side _message_handler (the seam the live
gateway injects) so handle_message / _process_message_background /
on_processing_complete all run for real; adds an AsyncSessionDB-facade
coverage test for the coroutine-await branch.

* fix(dump): flag API keys visible only to the shell, not the managed backend

hermes debug share reads os.getenv — the invoking terminal's environment — but
launchd/systemd and the desktop-spawned `serve` backend load credentials from
~/.hermes/.env, not the login shell. A key exported in the shell but absent
from .env is invisible to the backend, yet the dump printed a bare "set",
sending support down a phantom "the key is configured" path.

This was the actual trap behind a "Desktop has no web_search / no tools"
report: FIRECRAWL_API_KEY was a shell export (so `debug share` in a terminal
read "firecrawl set") but not in .env, so the launchd backend's
check_web_api_key returned False and web_search was gated off — which a
contributor then misdiagnosed as a missing `desktop` platform registration.

The dump now annotates any key set in-process but missing from ~/.hermes/.env
with "(shell only — not in .env; managed/desktop backend may not see it)" so
the mismatch is obvious instead of hidden behind "set".

* feat(skills): add security/unbroker (autonomous data-broker removal)

unbroker finds where a consenting person's info is exposed across data
brokers and people-search sites and files the removals, running as far as
each site allows and handing only genuinely human-only steps (hard CAPTCHA,
gov-ID, phone, fax) back as an end-of-run digest.

- Deterministic stdlib CLI (scripts/pdd.py) owns config, dossiers+consent,
  the broker DB, tier planning, the ledger, email, and the autonomous
  action queue; the agent scans/submits with native tools (web_extract,
  browser_*, delegate_task, cronjob, terminal).
- Verify-before-disclose, least-disclosure (never volunteers SSN), consent
  gate, opaque ids, optional age-at-rest encryption, file-locked ledger.
- Jurisdiction-aware (CCPA/CPRA, GDPR, generic); CA DROP one-shot covers
  the state registry (~545) in a single request; BADBOOL + curated
  people-search coverage; scheduled re-scan for re-listing.
- No CAPTCHA-solving services or anti-bot bypass; browser email mode needs
  no stored password.
- 85 hermetic tests (tests/skills/test_unbroker_skill.py; SMTP/IMAP via
  injected fakes, registry via CSV fixtures). Ships placeholder data only.

Broker dataset adapted from BADBOOL (Yael Grauer, CC BY-NC-SA 4.0).

* docs(unbroker): point README image link at hermes-agent; sync test count (85)

* feat(desktop): cap overlay inner-page width at 75rem

Add a shared PAGE_MAX_W (1200px) and center OverlayMain within its pane
so settings and command center bodies stay readable instead of sprawling
on wide/ultrawide displays.

* fix(desktop): stop macOS Tahoe misplacing the traffic lights

On macOS Tahoe (Darwin 25+), a nonzero titleBarOverlay height makes
setWindowButtonPosition() miscalculate the native traffic-light position
(electron#49183), shoving the lights into the left titlebar tools. Pass
height 0 there so the lights land at the configured inset; the renderer
paints its own drag strips, so nothing is lost. Pre-Tahoe is unchanged.

Gate on the truthful Darwin kernel major (25 = Tahoe) rather than the
product version, which macOS reports as 16 or 26 depending on build SDK.

* fix(desktop): clear stale active todos on turn end AND on rehydration

A turn that ends without a final `todo` update left the composer "Tasks N/M"
panel pinned with its last item stuck pending/in_progress, and it survived
restarts because the panel is read back from stored session history.

Two coupled fixes (the first alone is undone by the second path):

- Turn end: clear a still-active todo list on `message.complete` and on a
  terminal `error` (new `clearActiveSessionTodos` — active lists only; a
  finished list keeps its short linger so the last checkmark still lands).
- Rehydration: `hydrateFromStoredSession` runs *after* a turn completes, so an
  "active" stored list is stale, not in-flight. It now restores only a
  *finished* list (via new `todosForHydration`) and drops anything still
  active — otherwise it re-pinned the panel right after the turn-end clear and
  resurrected it on every restart.

Salvages #52996 (@0disoft): the fix shape (clearActiveSessionTodos on turn
completion, preserving the finished-list linger) is carried forward and ported
onto the current use-message-stream/ folder split (gateway-event.ts), then
extended to the rehydration path per review.

Co-authored-by: 0disoft <rodisoft1@gmail.com>

* fix(unbroker): suppress-first for PeopleConnect (deletion undoes suppression)

PeopleConnect is the exception to deletion-beats-suppression: "DELETE MY
USER DATA" also deletes suppressions on file, and deletion does not stop the
people-search sites from showing you (public records re-list). Suppression
is the effective lever and must be maintained.

- intelius.json: deletion.prefer=false; playbook/quirks/notes rewritten with
  the verbatim privacy-center language; delete is the data-purge-only path.
- autopilot: honor deletion.prefer -> prefer_suppression when false.
- methods.md / SKILL.md / README: exception called out.
- tests updated + prefer-flag routing test (86 tests).

* fix(file-tools): preserve container paths for docker file ops (#56637)

* fix(config): preserve owner on atomic writes (#56644)

* fix(desktop): use gift codicon for update-available toast

Add optional notification icon override and use codicon-gift on the
update-ready toast so it reads as a present rather than generic info.

* fix(desktop): use symbol-namespace codicon for Model settings nav

* fix(desktop): cancel downloads triggered by link-title fetch window

The hidden BrowserWindow used by fetchLinkTitle to scrape page titles
had no will-download handler on its session.  When a link artifact URL
responds with Content-Disposition: attachment, Electron fires will-download
and the file is saved for real — explaining the spurious download on the
Artifacts page.

Add guardLinkTitleSession() (parallel to the existing audio-mute guard for
#49505) that installs a will-download handler which immediately cancels
every download item on the hermes:link-titles session.  Call it from
getLinkTitleSession() right after the request-type blocklist is wired up.

* fix(slack): MPIMs (group DMs) obey shared-surface mention gating + reaction guard

Group DMs (MPIMs) were classified as DMs and thereby exempted from every
operator control that shared surfaces are supposed to honor: allowed_channels,
require_mention, strict_mention, free_response_channels, and the reaction
guard. Symptom: the bot added :eyes:/:white_check_mark: to unmentioned MPIM
messages and still invoked the agent (which then returned NO_REPLY) instead of
the gateway dropping the event before model execution. Removing an MPIM from
allowed_channels did not disable it.

Root cause is the DM classification at adapter.py:
    is_dm = channel_type in {"im", "mpim"}
used for BOTH routing exemptions and reaction gating. An MPIM is a shared
surface (multiple humans can see and trigger the bot), not a private 1:1 DM,
so it must be gated like a channel.

This behavior was introduced/reinforced by a trail of Slack group-DM PRs:
- #4633  fix(slack): treat group DMs (mpim) like DMs + reaction guard
- #54632 fix(slack): subscribe to message.mpim + mpim scopes so group DMs work
- #54663 fix(slack): group DMs work OOTB + reinstall nudge
#54632/#54663 correctly made MPIM messages *reachable*; #4633 over-reached by
giving them the DM mention/reaction *exemptions*. This corrects only that
over-reach.

Fix (minimal): introduce `is_one_to_one_dm = channel_type == "im"` and key the
two EXEMPTION sites off it instead of `is_dm`:
- mention/allowlist gating block (`if not is_one_to_one_dm and bot_uid:`)
- reaction guard (`(is_one_to_one_dm or is_mentioned)`)
`is_dm` is intentionally retained for session/thread scoping and chat_type
labeling, where treating an MPIM as a persistent multi-party conversation is
correct — only the mention/reaction exemptions were wrong.

Docs: slack.md now distinguishes 1:1 DMs (mention-exempt) from group DMs
(shared surface; obey require_mention/strict_mention/allowed_channels/
free_response_channels; reactions only when @mentioned).

Tests: +7 in test_slack_mention.py (MPIM unmentioned dropped under
require_mention and strict_mention; MPIM mentioned processed; MPIM off
allowed_channels dropped; MPIM in free_response opted in; 1:1 IM still exempt;
reaction guard drops unmentioned MPIM). Updated _would_process to model the
is_one_to_one_dm gating + strict_mention. 72 passed.

* test(slack): give the MPIM reaction-guard test real teeth

The reaction-guard regression test defined a local _should_react lambda and
asserted it against itself — a tautology that would stay green even if the
production guard at _handle_slack_message reverted to (is_dm or is_mentioned),
re-introducing the unmentioned-MPIM reaction spam this PR fixes.

Replace it with a shared _reaction_guard helper plus a source-introspection
test that pins the production expression: asserts (is_one_to_one_dm or
is_mentioned) is present and (is_dm or is_mentioned) is absent. Mutation-checked
— reverting the adapter guard now fails the test.

Follow-up self-review finding on the salvage of #57339.

* fix(agent): strip _db_persisted when assembling rotation compression transcript (#57491)

Shallow messages[i].copy() during context compression propagated the
_db_persisted marker from cached gateway incremental flushes into the
post-rotation compressed list. _flush_messages_to_session_db then skipped
every row when writing to the new child session, so gateway restarts
lost the compacted transcript (severe amnesia).

Strip the marker in _fresh_compaction_message_copy() and add regression
tests for rotation flush + compressor assembly.

Fixes #57491

* fix(agent): enforce marker-strip invariant with a single terminal sweep (#57491)

Follow-up to the per-site strips from the review gate. The two copy-site
strips are correct but positional — a copy site added after the assembly
loops would re-leak _db_persisted into the child-session flush. Add a single
terminal sweep (_strip_persistence_markers) run once on the fully-assembled
compressed list so the invariant 'no compacted message leaves compress()
carrying a persistence marker' is structural, not dependent on copy-site order.

- agent/context_compressor.py: _strip_persistence_markers() called before
  compress() returns; helper docstring notes the sweep is the authoritative guard
- tests/agent/test_context_compressor.py: structural regression — neuter the
  per-site helper to a leaking copy, assert the terminal sweep still strips
- tests/run_agent/test_compression_persistence.py: pin the fixture assumption
  behind the exact-equality row-count assertion

* fix(moa): default temperatures to unset — provider default, like single-model agents (#57440)

A single-model Hermes agent never sends temperature; the provider default
applies. MoA hardcoded reference_temperature=0.6 / aggregator_temperature=0.4,
and the coercion float(preset.get(key, 0.6) or 0.6) made unset IMPOSSIBLE to
express: absent, null, empty, and even an explicit 0 all collapsed to the
baked-in default. Every MoA advisor and aggregator therefore ran at 0.6/0.4
while the same model running solo used the provider default — silently
skewing solo-vs-MoA comparisons and overriding provider-tuned defaults.

- moa_config normalization: temperatures coerce to None when absent/blank/
  invalid (new _coerce_float_or_none); explicit values incl. 0 honored.
- moa_loop: _preset_temperature() resolves preset values; None flows to
  call_llm, which already omits the parameter when None (same contract as
  max_tokens). Aggregator still inherits the acting agent's own configured
  temperature when the preset doesn't pin one.
- conversation_loop (context-mode MoA): same resolution, no more hardcoded
  0.6/0.4 at the call site.
- DEFAULT_CONFIG preset + web_server payload models + docs updated: unset
  is the default, pinning stays available.

* fix(opencode-go): heal stripped /v1 base_url so non-minimax models stop 404ing (#57585)

OpenCode Go serves minimax/qwen via Anthropic Messages (base URL without
/v1 — the SDK appends /v1/messages) and glm/kimi/deepseek/mimo via OpenAI
chat completions (base URL WITH /v1). The runtime stripped /v1 for
anthropic-routed models, and the TUI/desktop + gateway persisted that
stripped URL to model.base_url. Every later chat_completions model then
POSTed to https://opencode.ai/zen/go/chat/completions — a 404 (the
marketing site). Result: only minimax worked; glm/deepseek/kimi all 404ed.

- New normalize_opencode_base_url(): symmetric /v1 normalization —
  strip for anthropic_messages, re-append for chat_completions /
  codex_responses on opencode.ai hosts (heals persisted stripped URLs;
  custom proxy overrides untouched)
- Applied at all three former one-way strip sites (resolve_runtime_provider
  x2, switch_model)
- opencode_model_api_mode: all Qwen models on Go AND Zen now route via
  /v1/messages per current published endpoint tables (previously only
  qwen3.7-max on Go — qwen3.6-plus etc. would 404 the same way)
- Catalog refresh: Go gains deepseek-v4-pro/flash, glm-5.2,
  kimi-k2.7-code, minimax-m3, qwen3.7-plus; Zen gains glm-5.2,
  kimi-k2.7-code, minimax-m3, qwen3.7-plus

Reported by IndieSuperhuman on X: opencode-go 404s for any model other
than minimax.

* feat(moa): per-preset fanout cadence — user_turn runs advisors once per user turn (#57591)

New preset key 'fanout': 'per_iteration' (default, unchanged behavior)
re-runs the reference fan-out whenever the advisory view changes — every
tool iteration. 'user_turn' runs the advisors ONCE per user turn and lets
the aggregator act alone for the rest of the tool loop — the original MoA
shape (upfront multi-model synthesis, then a single acting model), and the
obvious lever on MoA's wall/cost multiplier (advisor generation dominates
per-turn latency).

Implementation reuses the existing turn-scoped reference cache: in
user_turn mode the cache signature hashes only the prefix up to the LAST
user message, so mid-turn advisory-view growth doesn't change the key and
iteration 2+ is a cache HIT (advice reused, zero advisor spend, no
re-trace). A new user message changes the prefix and re-triggers the
fan-out. Unknown fanout values normalize to per_iteration.

* feat(desktop): CLI/dashboard parity — skills hub, MCP test/toggle/catalog, maintenance ops, log filters (#57441)

* feat(desktop): CLI/dashboard parity — skills hub browser, MCP test/toggle/catalog, maintenance ops, log filters

Brings desktop GUI to parity with hermes skills/mcp/doctor/backup/debug-share/
curator/memory CLI commands and the dashboard's System + Skills-hub pages:

- Skills page: new Browse Hub tab (search official/GitHub/community sources,
  preview SKILL.md, security scan verdicts, install/update with live action log)
- MCP settings: connection test (tool listing), per-server enable/disable
  toggle, and a Catalog tab installing Nous-approved MCP servers with env prompts
- Command Center: new Maintenance section (doctor, security audit, backup,
  debug share links, curator status/pause/run, memory file status + reset)
- Command Center system logs: file (agent/errors/gateway/desktop), level, and
  substring filters instead of a fixed agent.log tail
- hermes.ts API client + types for all the above; en/zh locale strings (ja and
  zh-hant inherit via defineLocale)

* feat(desktop): backend model catalogs in toolset config — hermes tools parity

Completes the `hermes tools` parity gap: after picking an image/video
generation backend the CLI runs a model picker (e.g. FAL's multi-model
catalog with speed/strengths/price); the desktop toolset drawer now has the
same flow as a radio-card list.

- web_server: GET /api/tools/toolsets/{name}/models (catalog + current +
  default for the active or named provider row) and PUT .../model
  (validated write to image_gen.model / video_gen.model), reusing the CLI's
  plugin catalog helpers so GUI and `hermes tools` stay in lockstep
- desktop: ModelCatalogPicker in ToolsetConfigPanel — per-model cards with
  speed/strengths/price, in-use + default badges, disabled until the
  backend is the active one; provider selection now mirrors is_active
  locally so the catalog unlocks without a refetch
- tests: 3 backend endpoint tests (catalog shape invariants, persist +
  validation), 2 component tests, 2 API-contract tests; en/zh strings

* fix(browser): retry next candidate when debug launch exits early

* fix(browser): surface launch diagnostics when debug browser never opens the CDP port

Follow-up to the salvaged early-exit retry fix (#35617): the debug-browser
launch path was fire-and-forget (stderr to DEVNULL, no logging), so every
platform failure — Windows singleton forward to an existing instance, bad
profile dir, missing shared libraries, policy blocks — collapsed into the
same unactionable 'port 9222 isn't responding yet' message and debug
reports contained nothing.

- launch_chrome_debug() returns a structured ChromeDebugLaunch with
  per-candidate attempts (state, exit code, stderr tail)
- browser stderr is captured to <hermes_home>/chrome-debug/launch-stderr.log
- clean exit (code 0) without the port opening is detected as Chromium's
  single-instance forward and produces a targeted user hint to close all
  running instances of that browser
- crash exits surface the stderr tail (e.g. missing libnspr4.so)
- every spawn/exit is logged to agent.log so hermes debug share captures it
- CLI (/browser connect) and TUI/desktop (browser.manage) both print the hint

* fix(moa): user_turn fanout — synthetic advisory marker must not count as a user turn (#57598)

The advisory view appends a synthetic user marker when it ends on an
assistant turn (Anthropic end-on-user rule) — i.e. on every tool iteration
after the first. The user_turn prefix hash treated that marker as the last
user message, so the hashed prefix included the grown mid-turn context and
the signature changed…
zebadee2kk added a commit to zebadee2kk/hermes-agent that referenced this pull request Jul 5, 2026
…sticky active_profile (#4)

* chore: add AUTHOR_MAP entry for @sahibzada-allahyar (#39227 salvage)

* fix(terminal): stop stripping CLAUDE_CODE_OAUTH_TOKEN from spawned subprocesses (#56935)

CLAUDE_CODE_OAUTH_TOKEN is set and owned by the user's Claude Code
install (subscription OAuth), not a Hermes-managed inference
credential — Claude subscription auth is not a working Hermes provider
path. Blocklisting it broke agent-spawned claude CLIs: with no token in
the child env, claude fell through to the shared macOS Keychain /
~/.claude/.credentials.json store and, on auth failure, cleared it —
logging the user out of their interactive Claude sessions and the
desktop app.

Exempt it from _HERMES_PROVIDER_ENV_BLOCKLIST (it arrives via the
anthropic registry entry, so discard explicitly with rationale).
ANTHROPIC_API_KEY / ANTHROPIC_TOKEN and every other provider credential
remain stripped, and the GHSA-rhgp-j443-p4rf fail-closed passthrough
guard is unchanged for everything still on the blocklist.

Fixes #55878

* feat(delegation): unify concurrency caps — deprecate max_async_children (#56955)

delegation.max_concurrent_children is now the single cap for both a
batch's parallelism and concurrent background delegation units.

- _get_max_async_children() delegates to _get_max_concurrent_children();
  a leftover max_async_children key logs a one-time deprecation warning
- config v32→33 migration removes the stale key, folding a raised
  max_async_children into max_concurrent_children (max wins, no lost
  headroom)
- capacity error messages now point at max_concurrent_children
- pool-at-capacity sync fallback now attaches an explanatory note so
  the model/user know why the call blocked instead of dispatching async

Previously users who raised max_concurrent_children (e.g. to 15) still
hit the invisible default-3 async cap: the 4th background delegate_task
silently ran inline, blocking the turn with no signal.

* fix(webhook): remove unused payload from delivery state

* fix(webhook): remove unused payload from delivery state

* chore: add AUTHOR_MAP entry for @VolodymyrBg (#2861 salvage)

* fix(config): accept 'on' as truthy for env flags via shared env_var_enabled helper

Salvage of #2863 by @aydnOktay, reimplemented against current main using the
existing utils.env_var_enabled / TRUTHY_STRINGS helper instead of per-site
tuple edits. Covers the 7 gateway/config.py env-flag sites that still rejected
'on' (WHATSAPP_ENABLED, SIGNAL_IGNORE_STORIES, MATRIX_ENCRYPTION,
API_SERVER_ENABLED, WEBHOOK_ENABLED, MSGRAPH_WEBHOOK_ENABLED,
BLUEBUBBLES_SEND_READ_RECEIPTS) plus HERMES_DESKTOP gating in
read_terminal/close_terminal. The PR's approval.py HERMES_YOLO_MODE portion is
already on main via is_truthy_value.

* test: env-flag 'on' truthy behavior contract (#2863 follow-up)

* feat(gateway): per-channel model and system prompt overrides (Fixes #1955)

- config: ChannelOverride + PlatformConfig.channel_overrides

- run: _resolve_model_for_channel, _get_system_prompt_for_channel, channel provider runtime

- tests: channel overrides + config guard for bare runner; conftest asyncio fix; slack/whatsapp warning filters

Made-with: Cursor

* feat(gateway): per-channel model and system prompt overrides (Fixes #1955)

- ChannelOverride + channel_overrides on PlatformConfig
- Resolve model/runtime: session /model, then channel_overrides, then global
- Thread/parent channel lookup; bridge discord.channel_overrides from YAML
- Drop unrelated test and delegate_tool changes from PR scope

* feat(gateway): per-channel model and system prompt overrides (Fixes #1955)
- ChannelOverride + channel_overrides; session /model > channel > global
- Thread/parent lookup; YAML bridge for discord.channel_overrides
- Guard channel_overrides when config lacks platforms (test mocks)
- Add sampiyonyus@gmail.com to AUTHOR_MAP

* fix(email): harden adapter against malformed IMAP responses

Salvage of #2794 by @CharmingGroot, ported to the relocated
plugins/platforms/email/adapter.py:

- Guard raw_email = msg_data[0][1] against IndexError/TypeError and
  non-bytes payloads. UIDs are added to _seen_uids before fetch, so an
  exception mid-batch permanently skipped every remaining message in
  the batch — now the bad message is logged and skipped instead.
- Message-ID domain generation falls back to 'localhost' when
  EMAIL_ADDRESS lacks '@' (now via a shared _message_id_domain() helper
  covering all 3 send paths; the PR fixed 2 of 3).

* feat(api-server): inline MEDIA: image tags as base64 data URLs for remote frontends

Salvage of the surviving piece of #2696 by @tarunravi. The PR's other two
changes (tool progress streaming, SSE None-sentinel fix) were independently
superseded on main by the structured hermes.tool.progress SSE events and the
rewritten queue-drain loop.

Remote OpenAI-compatible frontends can't read server-local file paths, so
MEDIA:<path> tags (browser screenshots, generated images) were dead text.
_resolve_media_to_data_urls() now inlines small (<=5MB) local images as
markdown data URLs across all four response surfaces: chat completions
(non-streaming), session chat, session chat stream final event, and the
Responses API. Non-image, missing, or oversized paths pass through
untouched.

* fix(cli): reliable interrupts, bounded exit, and exit feedback (#57000)

Three CLI reliability fixes:

1. Interrupt reliability: chat() only re-queued the user's interrupt
   message when the turn result carried interrupted=True. When the agent
   thread raced past its last interrupt check (or finished) before the
   interrupt landed, the message was silently dropped — and the stale
   _interrupt_requested flag left on the agent instantly aborted the
   NEXT turn. Un-acknowledged interrupt messages are now re-queued as
   the next turn and the stale flag is cleared (only when the agent
   thread actually exited). The clarify-race path also parks the message
   in _pending_input instead of dropping it.

2. Slow exit (5+ min): stdlib ThreadPoolExecutor workers are non-daemon
   and joined unconditionally by concurrent.futures' atexit hook — even
   after shutdown(wait=False). One wedged tool worker (abandoned after
   interrupt/timeout) held the process open forever. Promoted
   async_delegation's daemon executor to a shared tools/daemon_pool
   module and adopted it in tool_executor (concurrent tool batches),
   memory_manager (background sync), delegate_tool (child timeout wrapper
   + batch fan-out), and skills_hub (source fan-out). Added a 30s exit
   watchdog (HERMES_EXIT_WATCHDOG_S) armed at _run_cleanup start as a
   backstop for wedged cleanup steps.

3. Exit jank: after prompt_toolkit tears down the input/status bars the
   terminal sat silent for the whole cleanup window, looking hung. Print
   'Shutting down… (finalizing session)' immediately at exit start.

E2E: live PTY interrupt of a foreground 'sleep 120' terminal tool now
aborts in ~1s and the typed message runs as the next turn; wedged-worker
+ wedged-cleanup subprocess exits in 5.8s (watchdog) instead of hanging.

* chore(release): map ai-lab@foxmail.com to CrazyBoyM

Adds the AUTHOR_MAP entry for CrazyBoyM (ai-lab@foxmail.com) so the
contributor-attribution CI check passes when PR #55828's commits are
rebase-merged with authorship preserved.

* fix(codex): extend stale timeout for gateway-scale tool payloads

Lower the openai-codex stale-timeout floor from 25k to 10k estimated
tokens so Telegram/gateway sessions (~20k tools+instructions) are not
aborted at the generic 90s cutoff while Codex is still prefilling.

* test(codex): cover gateway-scale stale timeout floor and TTFB gate

* docs(codex): clarify stale-floor docstring reflects the 10k gate

The helper docstring described the typical ~15-25k gateway payload but
read as if that were the trigger range; the floor actually engages above
10k tokens. Clarify the prose to match the gate.

* fix(browser): guard Camofox snapshot/vision/images on private pages

Follow-up to #56874, which added the Camofox private-page SSRF guard
(_camofox_current_page_private_url) but wired it only into the Camofox
eval path (_camofox_eval). The other Camofox content-read tools —
camofox_snapshot, camofox_get_images, and camofox_vision — still read the
current page's accessibility tree / images / screenshot without the
guard, so on a non-local Camofox backend they can return the content of
an intranet or cloud-metadata page (e.g. 169.254.169.254) that the
terminal itself can't reach.

Apply the same guard, gated on _eval_ssrf_guard_active (non-local
backend, not a local sidecar, allow_private_urls unset) and fail-open on
probe failure, matching the eval-path guard and the main-browser
snapshot/vision guards. camofox_back is intentionally not changed: its
target is unknown until navigation completes, and the subsequent content
read is already guarded.

Adds regression tests covering the three read tools blocking on a private
page, the public-page pass-through, and the guard-inactive no-probe path.

* feat(image-gen): support Codex image inputs

* test(image-gen): cap Codex reference inputs

* refactor(image-gen): reuse shared image sniffer + raster allowlist in codex backend

Replace the plugin-local _IMAGE_MAGIC_MIME table + _sniff_image_mime
body with a delegation to agent.image_routing._sniff_mime_from_bytes,
the canonical magic-byte sniffer already used across the codebase, then
gate its result to the raster formats gpt-image-2's Responses
input_image actually accepts (png/jpeg/gif/webp).

The shared sniffer also recognizes SVG/TIFF/ICO; without the allowlist
those would pass local validation and be rejected server-side with an
opaque HTTP 400. Gating locally fails them cleanly as invalid_image_input.
Adds a regression test for SVG rejection.

Follow-up on top of @CrazyBoyM's #55828.

* fix(whatsapp): resolve LID sender IDs to phone numbers in bridge message payload

WhatsApp has migrated to Linked Identity Device (LID) format for user
IDs (e.g. 244645917392975@lid instead of 18505551234@s.whatsapp.net).

The bridge already resolves LIDs to phone numbers for its own allowlist
check via buildLidMap(), but the senderId field in the message payload
sent to the gateway still contained the raw LID. This caused the
gateway's WHATSAPP_ALLOWED_USERS check to reject all messages as
unauthorized, since the LID numbers don't match the phone numbers in
the allowlist.

Fix: resolve LID → phone in the senderId, senderName, and chatName
fields of the event payload before sending to the gateway, using the
existing lidToPhone mapping.

* chore: add AUTHOR_MAP entry for @ajmeese7 (#3219 salvage)

* fix(status): label provider as custom when config.yaml model.base_url is set

Salvage of the surviving hunk of #3296 by @Mibayy. The PR's gateway
_handle_provider_command hunk targets code removed on main (/provider was
absorbed into /model + /status, which already read model.base_url); the
hermes status mislabel was the remaining live symptom:
_effective_provider_label() only checked the legacy OPENAI_BASE_URL env var,
so a custom endpoint configured canonically in config.yaml still displayed
as OpenRouter.

* feat(gateway): add 'log' option to display.tool_progress

Salvage of #3459 by @keslerm, reimplemented against the restructured
progress-callback block in gateway/run.py (resolve_display_setting,
needs_progress_queue, thinking-relay). Duplicate PR #3458 by @dlkakbs was
submitted 4 minutes earlier with the same feature — both credited.

Co-authored-by: Dilee <uzmpsk.dilekakbas@gmail.com>

tool_progress: log keeps the chat silent and appends timestamped tool-call
lines to ~/.hermes/logs/tool_calls.log via a dedicated queue drained by an
async writer (RotatingFileHandler 5MB x 3, RedactingFormatter so secrets
never land on disk). Gateway-only by design; thinking_progress relaying and
the webhook gate are unaffected. /verbose now cycles
off -> new -> all -> verbose -> log.

* fix(i18n): add gateway.verbose.mode_log to all locale catalogs

* feat(commands): /compact alias + --preview/--dry-run flags for /compress (#3243 salvage)

Salvaged from PR #3243 by @Mibayy, reimplemented against current main
(the original diff targeted a removed gateway/run.py handler).

- /compact is now a first-class alias of /compress (CLI, gateway,
  Telegram/Slack/Discord command lists, autocomplete) — also fixes the
  dangling '/compact' references in gateway error messages
  (gateway/run.py context-exhausted banners).
- --preview / --dry-run: report what WOULD be compressed (message
  counts, token estimate, 'here [N]' boundary) without touching the
  transcript. Flags coexist with the existing 'here [N]' / focus-topic
  args on both the CLI and gateway surfaces via shared pure helpers in
  hermes_cli/partial_compress.py.
- --aggressive (LLM-free hard truncation) is intentionally NOT
  implemented: it would need its own transcript-persistence branch
  outside the guarded _compress_context rotation machinery (#44794
  data-loss class). The flag is recognized and returns an explanatory
  message pointing at '/compress here [N]' and /undo instead of being
  mis-parsed as a focus topic.
- locales: gateway.compress.aggressive_unsupported added to all 16
  catalogs (parity test enforced).
- release.py: AUTHOR_MAP entry for contributor credit.

* feat(api-server): per-client model routing via model_routes (#3176 salvage)

Adds a no-code routing layer to the OpenAI-compatible API server so one
Hermes deployment can map different API clients to different
model/provider backends. Clients pick a backend by sending a configured
alias as the OpenAI 'model' field; unmatched values fall back to the
global model. Configured aliases are listed by GET /v1/models.

Precedence (highest first): session /model override > model_routes
route > global config. Route provider credentials resolve through
_resolve_runtime_agent_kwargs_for_provider (same seam as
channel_overrides); per-route api_key/base_url are upstream provider
credential overrides — never caller auth, never logged.

Salvaged and rebased from PR #3176 by @Mibayy onto current main.

* feat(config): extra HTTP headers for LLM API calls (#3526 salvage)

Named providers / custom_providers entries in config.yaml now accept an
extra_headers dict scoped to that endpoint — for reverse proxies, API
gateways, and custom auth schemes (e.g. Cloudflare Access service tokens).

- hermes_cli/config.py: normalize extra_headers on provider entries
  (_normalize_custom_provider_entry + providers-dict translation), add
  get_custom_provider_extra_headers /
  apply_custom_provider_extra_headers_to_client_kwargs helpers keyed on
  base_url (case/trailing-slash insensitive, no substring bypass —
  mirrors the TLS helpers)
- hermes_cli/runtime_provider.py: surface extra_headers in the resolved
  runtime for named custom providers (providers dict, legacy
  custom_providers list, and the credential-pool path)
- run_agent.py / agent/agent_init.py: merge per-provider extra_headers
  onto the OpenAI client default_headers at construction and on every
  _apply_client_headers_for_base_url re-application (credential swaps,
  rebuilds), most-specific level wins; OpenAI-wire only (native
  Anthropic/Bedrock scoped out)
- agent/auxiliary_client.py: accept model.extra_headers as an alias of
  model.default_headers for the global variant
- cli-config.yaml.example: documented commented example
- Header values are treated as secrets and never logged

Salvaged from PR #3526 by @jneeee, reimplemented against current main.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>

* feat(gateway): persist per-session /model overrides across gateway restarts

Per-session /model overrides (_session_model_overrides) were in-memory only,
so a gateway restart silently reverted every session to the global default
model. Persist the non-secret parts (model/provider/base_url ONLY — never
api_key) into the session entry in sessions.json and lazily rehydrate them
on first use after a restart, re-resolving credentials through the normal
runtime provider resolution.

- gateway/session.py: SessionEntry.model_override field with
  sanitize_model_override() (allowlist: model/provider/base_url) applied on
  both serialization and deserialization; SessionStore.set_model_override /
  get_model_override accessors. reset_session() already creates a fresh entry,
  so /new keeps its clear-on-reset semantics — a restart cannot resurrect an
  override the user reset away.
- gateway/slash_commands.py: write-through at both /model set sites (text
  command + picker) after storing the in-memory override.
- gateway/run.py: _rehydrate_session_model_override() called from
  _resolve_session_agent_runtime(); in-memory state always wins, credentials
  are re-resolved per provider (credential-less fallback on failure). Session
  expiry finalization also drops the persisted override.
- tests/gateway/test_session_model_override_persistence.py: restart
  round-trip, /new clearing, api_key-never-serialized (including tampered
  sessions.json), rehydration + live-state precedence + credential-failure
  degradation.

Salvaged from #3659 by @Git-on-my-level, narrowed to the restart-persistence
gap confirmed in triage.

* fix(desktop): parse multiline slash commands + hand degenerate payloads back

parseSlashCommand used /^(\S+)\s*(.*)$/ where `.` can't cross a newline and
`$` anchors end-of-string, so any slash command whose arg contained a newline
(/goal <multi-line text>, a skill command with a long pasted context) failed
the whole match, parsed as an empty name, and rendered "empty slash command"
while the payload vanished — cleared from the composer and absent from the
Up-arrow history ring, which only derives from sent user messages.

- name now splits on any whitespace ([\s\S]* arg), matching the CLI and the
  gateway's split(maxsplit=1); multiline args flow to slash.exec intact
- the residual empty-name branch (bare "/", "/ text") restores the submitted
  text to the composer draft instead of eating it

Fixes #41323. Fixes #55510.

* fix(desktop): call checkUpdates() in startUpdatePoller so version pill auto-populates

startUpdatePoller() only called checkBackendUpdates() — never checkUpdates().
The statusbar version pill reads $updateStatus (set by checkUpdates()), so the
commit-behind counter stayed null after restart. It only appeared when the user
manually clicked the pill, which triggered checkUpdates() via openUpdateOverlayFor.

Added void checkUpdates() in three places alongside the existing
checkBackendUpdates() calls:
- On startup in startUpdatePoller()
- In the 30-minute setInterval callback
- In the onFocus handler

checkUpdates() uses the Electron IPC bridge (local git check), not the gateway,
so no mode gating is needed. The existing $updateChecking atom guard prevents
double-fire on overlap.

Fixes #53079

* fix(desktop): restore remote artifact rendering

* style(desktop): fix import ordering + padding lint in remote-artifact files

* feat(desktop): /journey opens the memory graph overlay instead of printing text

* style(desktop): fix pre-existing import-order lint in use-prompt-actions

* fix(desktop): restore remote file picker attachments

* fix(desktop): read attachment previews local-first in remote mode

attachImagePath fetched its thumbnail through readDesktopFileDataUrl, which in
remote mode routes every read to the gateway fs bridge. Paperclip picks,
clipboard saves, and OS drops always produce paths on the LOCAL machine, so the
gateway read 404s — toasting "image preview failed" and dropping the thumbnail
even though the attach itself works (upload reads local bytes via the Electron
bridge). Read the local bridge first and fall back to the remote facade, which
still serves in-app drags from the remote project tree. Local mode is
unchanged (the facade already reads locally there).

Follow-up to #56572, which restored the remote paperclip picker and made this
path reachable from the picker as well.

* fix(desktop): load remote model options before session

Both Desktop picker surfaces (status-bar model menu, settings/onboarding
dialog) only asked the connected gateway's model.options once a session
existed; before that they fell back to the Desktop REST/global options, which
can't see virtual providers a remote gateway exposes — including the MoA
presets from #53817. Centralize the fetch rule in requestModelOptions(): prefer
the connected gateway whenever one exists (no session_id needed — the RPC
resolves disk config), REST only when no gateway is connected.

The status-bar MoA preset section now renders from the same model.options
payload (the virtual `moa` provider row) instead of the local /api/model/moa
REST config, so remote presets appear correctly; the row is filtered out of
the main provider groups so presets don't list twice. Preset selection keeps
the persistent switchTo path from #56417 and drops the vestigial session gate —
like regular model rows, a pre-session pick ships on the next session.create.

Fixes #53817.

Rebased and reconciled with #56417 (persistent MoA selection), which landed
after this PR was opened and covered its one-shot-/moa half.

* fix(terminal): set MSYS_NO_PATHCONV for Windows Git Bash subprocesses

Git Bash mangles native Windows command flags (/FO, /TN, /Create) into
bogus paths. Hermes terminal and background spawns now opt out by default
so tasklist, schtasks, and wmic work without manual prefixes.

Fixes #56700.

* test(terminal): cover MSYS_NO_PATHCONV defaults on Windows env builders

* fix(terminal): also set MSYS2_ARG_CONV_EXCL for MSYS2/Cygwin bash fallback

MSYS_NO_PATHCONV is honored by Git for Windows bash only. _find_bash's
final shutil.which fallback can return MSYS2-proper or Cygwin bash,
which ignore it and honor MSYS2_ARG_CONV_EXCL instead. Set both so argv
path conversion stays disabled regardless of which bash flavor spawns.
Also subsumes the cmd /c mangling in #56147.

* feat(desktop): collapse profile rail to a select past 13 profiles (#57306)

The colored-square rail stops scaling once a user racks up many profiles:
tiny drag targets and an endless horizontal scroll strip. Past a threshold
(13) the rail swaps the squares for a compact select dropdown — same active
tint + initial glyph, minus the drag-reorder / long-press-recolor / per-row
context menu that only make sense at small counts. Two render paths behind
one flag; the left default↔all toggle, the "+" create button, and Manage
stay put in both. Rename/delete/color remain reachable via Manage.

* Prevent deleted profile skeleton revival

* feat(auth): make xAI Grok OAuth device-code-only, drop loopback login

Replace the loopback/PKCE-callback server and manual-paste fallback with
the RFC 8628 device-code flow as the only xAI Grok OAuth login path. The
flow works in headless/SSH/container sessions with no 127.0.0.1 listener,
shrinking the local attack surface.

- Poll the token endpoint with server-provided interval, honoring
  slow_down and expires_in; store tokens with auth_mode
  oauth_device_code.
- Adaptive proactive refresh skew for short-lived device-code JWTs;
  rotated tokens sync back to auth.json, the global root store, and the
  credential pool (no refresh-token replay).
- Clear source suppression on successful re-login (CLI + dashboard) and
  drop the duplicate dashboard pool entry so exactly one seeded
  device_code entry exists.
- Use the shared device_code source name for consistency with the
  nous/codex device-code providers.
- Desktop: remove the loopback OAuth flow states and dead type variants;
  pkce providers' sign-in URL selection is unchanged.
- Docs (EN + zh-Hans) rewritten for device-code login; drop the deleted
  --manual-paste flag from documented commands.

* fix(auth): remove stale loopback_pkce reference in xAI quarantine removal list

The terminal-refresh quarantine filtered in-memory entries on
source == "device_code" but built removed_ids from the deleted
"loopback_pkce" source name, so the revoked device-code entry was
never pruned from the persisted pool in auth.json. Also restores the
_print_loopback_ssh_hint test suite scoped to Spotify (the helper's
remaining caller) instead of deleting it wholesale.

* fix(desktop): skip ensureBackend after profile-delete teardown to prevent respawn loop

When the renderer sends a DELETE /api/profiles/{name} request, the IPC
handler tears down the profile's pool backend (or primary backend) via
prepareProfileDeleteRequest.  However, the very next line calls
ensureBackend(profile), which spawns a fresh pool backend for the just-
deleted profile.  The new backend's startup path calls ensure_hermes_home(),
which recreates the profile directory — defeating the deletion and leaving
the process as a zombie.

On the next Desktop restart the cycle repeats: the profile directory exists,
the Desktop spawns a backend, the backend recreates the directory after
deletion, and PIDs accumulate indefinitely.

Fix: make prepareProfileDeleteRequest return the torn-down profile name.
The IPC handler uses this to route the DELETE to the primary backend
instead of spawning a new pool backend for the deleted profile.

Fixes #52279

* fix(desktop): refresh profile rail after deletion (#49289)

* fix(desktop,tui-gateway,zai): stop thinking-off from reverting to medium

A Z.ai desktop user reported thinking reverting to medium after one turn,
burning ~200% of a week's credits in 4 days despite reasoning_effort: false
in config.yaml. Four compounding bugs:

- _session_info reported reasoning_effort "" for disabled reasoning,
  indistinguishable from unset — the desktop adopted it after the first
  turn, wiping its sticky "thinking off" pick so every later chat
  reverted to the default effort.
- config.set key=reasoning always wrote agent.reasoning_effort to global
  config.yaml, so every desktop model-menu selection (preset.effort ??
  'medium') clobbered the user's configured value. Now session-scoped
  like the messaging gateway's /reasoning, landing on
  create_reasoning_override so lazily-built sessions keep it too.
- YAML `reasoning_effort: false`/`off`/`no` (boolean False) was coerced
  to "" by every loader's `str(x or "")`, silently re-enabling thinking.
  parse_reasoning_effort now treats False/"false"/"disabled" as
  {"enabled": False}; loaders (tui gateway, gateway, cli, cron,
  delegate) pass the raw value through. The desktop config reader also
  crashed on the boolean (false.trim()), aborting voice/STT settings.
- The zai provider profile never sent thinking on the wire, and GLM-4.5+
  defaults to thinking ON server-side — so disabling reasoning was a
  silent no-op on direct Z.ai, the actual token burner. The profile now
  emits extra_body.thinking {"type": "enabled"|"disabled"} for
  thinking-capable GLM models, mirroring the DeepSeek profile.

Also: /new (session reset) now carries reasoning_config across the
rebuild like model_override; config.get reasoning prefers the session's
live value and maps a config False to "none"; Settings shows "Off"
instead of a blank select for hand-written false.

* fix(cli): stop profile-bound backends before deleting so rmtree converges

delete_profile stopped only the process named in gateway.pid, but a Desktop
app spawns a headless `serve`/`dashboard` backend per profile that holds the
profile's SQLite connection open and keeps writing sessions/WAL/sandbox files.
That backend is never in gateway.pid, so a CLI `hermes profile delete` run
while the Desktop app is up left it writing into the tree — rmtree's final
rmdir then failed with ENOTEMPTY (#47368 "Bug 2"), and pre-guard it also
resurrected the directory.

- _profile_bound_backend_pids(): find running Hermes backends bound to this
  profile via a `--profile <name>` selector or a HERMES_HOME env resolving to
  the profile dir. Tightly scoped — current-user only, backend subcommands
  (serve/dashboard/gateway) only so an interactive chat is never killed, and
  never this process or its ancestors.
- _stop_profile_backends(): terminate them (graceful, then force), best-effort
  so it can never make delete worse.
- _rmtree_with_retry(): a few spaced retries absorb the ENOTEMPTY / Windows
  file-lock race from a just-terminated writer's in-flight -wal/-shm/sandbox
  writes instead of failing the whole delete on a race the next attempt wins.

Complements the recreation guard (deleted profiles no longer reappear) and the
Desktop teardown-before-delete flow; this is the CLI-side convergence fix for a
delete run while a Desktop-managed backend is live.

Part of #47368.

* fix(tui_gateway): route setup.runtime_check and setup.status to RPC pool

setup.runtime_check and setup.status are polled by the Desktop frontend on
connect and periodically (use-status-snapshot → evaluateRuntimeReadiness), but
neither was in _LONG_HANDLERS — so dispatch() ran both inline on the WS reader
thread. Under GIL pressure from concurrent agent turns (terminal I/O, large
output, background-process completions) either can block for seconds:

- setup.runtime_check → resolve_runtime_provider() (config read, auth check,
  may probe the provider endpoint)
- setup.status → _has_any_provider_configured() (provider config + credential
  scan)

While either blocks the reader thread the WS read loop can't service later
requests; the frontend RPC timeout fires, the client drops the socket, and the
lost setup.runtime_check response reads as ready=false — a false "needs setup"
/ "Settings failed to load" even though the provider is configured.

Route both to the RPC pool (same precedent as #55545's session.list/pet.info/
process.list). The handlers are read-only and pool writes go through the
lock-guarded write_json, so there's no ordering or safety concern.

Test asserts all 5 frontend-polled RPCs are pool-routed.

Co-authored-by: izumi0uu <izumi0uu@gmail.com>

* chore(release): map yingliang-zhang in AUTHOR_MAP for #57335

* fix(usage): capture reasoning_tokens from completion_tokens_details on chat_completions (#57340)

normalize_usage only read output_tokens_details.reasoning_tokens (the
Responses API shape). Chat Completions providers — OpenAI, OpenRouter,
DeepSeek, and every OpenAI-compatible proxy — report it under
completion_tokens_details.reasoning_tokens, so reasoning_tokens was 0 for
every chat_completions reasoning model: hidden thinking was invisible in
session accounting, MoA traces, and the eval's per-task token columns.

Measured impact (HermesBench MoA run on deepseek-v4-flash, 4,828 advisor
calls): reasoning_tokens showed 0 everywhere while individual calls burned
up to 21.5K hidden thinking tokens to emit ~500 visible tokens. Verified
live against OpenRouter: deepseek-v4-flash returns
completion_tokens_details.reasoning_tokens=61 for a 74-completion-token
call; the field was simply never read.

Responses-shape reads are unchanged; the new read only fires when the
Responses shape yielded nothing.

* fix(slack): keep blank-line-separated ordered items in one rich_text_list

When a Markdown ordered list has blank lines between items (common in
LLM-authored content), the list run loop breaks on each blank line.
Slack numbers each rich_text_list independently, so N items produce N
lists each starting at 1.

Skip blank lines inside the list run as soft separators instead of
breaking, so ordered items stay in one rich_text_list and Slack renders
the correct numbering.

Fixes #57076

* fix(slack): guard blank-line list continuation on next-item lookahead

Refine the blank-line handling so a blank line only continues a list run
when the next non-blank line is another list item. This keeps a list ->
paragraph -> list sequence as three separate blocks and matches the
contiguous-list layout for mixed/nested lists (one rich_text block, split
into sub-lists by (indent, ordered)), rather than emitting a separate
block per item.

Adds regression tests for the mixed blank-separated layout and the
list->paragraph->list boundary.

* refactor(slack): extract _is_list_line helper for list-marker checks

Deduplicate the '_BULLET_RE.match or _ORDERED_RE.match' idiom used at the
list-run entry guard and the blank-line lookahead into a single helper, so
adding future marker types is a one-point change. Pure refactor, no
behavior change (22 block_kit tests still pass).

* fix: refresh NVIDIA featured models

* fix(agent): honor live vLLM context limits on local endpoints

Reconcile stale local disk cache against live vLLM/Ollama max_model_len
probes, probe local servers before the llama hardcoded default, parse
vLLM max_model_len overflow errors, and surface the non-agentic Hermes 3/4
warning at agent init on gateway/TUI.

Sub-64K live probes are returned for startup rejection but are not
persisted to the context cache — preserving the 64K minimum-context
contract instead of normalizing undersized windows as valid config.

(cherry picked from commit c3a02db4fd9d57b7b0eb2732de91f8334d311aa5)

* test(agent): cover local vLLM context-length resolution

Add regression tests for vLLM max_model_len error parsing, stale local
cache reconciliation, live probes over llama defaults, and the 64K minimum
guard on persistent cache writes.

(cherry picked from commit 1cb47ef437de7ce289cb358e8d6b89e9194b43ed)

* fix(gateway): close webhook sessions on delivery completion so prune can reap them

Webhook deliveries created a unique one-shot session (delivery_id baked into
the session key at gateway/platforms/webhook.py:668) but the adapter fired
handle_message via asyncio.create_task WITHOUT ever ending the session
(webhook.py:713, pre-fix). Nothing else closes it: the gateway caches/expires
the agent per session_key but never calls end_session for the webhook path,
and _end_session_on_close teardown doesn't run for these fire-and-forget tasks.

SessionDB.prune_sessions (hermes_state.py:4965) only deletes rows WHERE
ended_at IS NOT NULL. So every webhook session stayed with ended_at NULL ->
unprunable -> unbounded state.db growth. This was the primary driver of the
SQLite lock-contention gateway outage.

Fix: wrap the delivery in _run_delivery_and_close, which awaits
handle_message and then (in finally, so failures still reap) calls
_end_webhook_session -> SessionDB.end_session(session_id, 'webhook_complete').
This mirrors how cron closes its session with 'cron_complete'
(cron/scheduler.py:3065). end_session is first-reason-wins and no-ops on an
already-ended row, so it never clobbers a compression/agent_close reason.

Adds tests/gateway/test_webhook_session_close.py asserting the invariant
(a completed webhook session has ended_at set + is prunable), including the
error-path case, against a real SessionStore + SessionDB.

* chore: map gumclaw@gumroad.com in AUTHOR_MAP for PR #57322 salvage

* refactor(gateway): add SessionStore.peek_session_id public accessor for webhook close

Replace the webhook delivery-close path's direct reach into private
SessionStore._entries (which also bypassed the store lock) with a public,
lock-held peek_session_id(session_key) accessor. Mirrors the existing
lookup_by_session_id inverse helper. Keeps a getattr fallback for older
stores / test doubles. Adds a unit test for the accessor.

* fix(agent): resolve review findings on vLLM local-context salvage

Salvage review of #56431 surfaced one Critical + two Warning issues; fix
them on top of the contributor's cherry-picked commits:

1. Critical — duplicate non-agentic warning on the interactive CLI. The new
   agent_init warning fires on every platform, but cli.py show_banner()
   already warns on CLI (richer output + /model hint), so a CLI user saw the
   warning twice per startup. Guard the agent_init emit to skip platform=="cli"
   — it now fills exactly the gateway/TUI gap the PR intended, no duplication.

2. Warning — vLLM error-parse regex under-matched. The patterns required a
   literal space before the number, so "max_model_len: 32768", "=32768",
   "(32768)", and "... is 32768" all returned None. Broaden both patterns to
   accept :/=/(/ 'is' delimiters. Add a parametrized test over all delimiter
   variants.

3. Warning — per-call live probe latency on local endpoints. The new
   reconcile-on-hit + pre-defaults step-7 probe made every local resolution
   fire a synchronous network probe (banner + /model switch + compressor
   update_model each within one startup). Add a 30s in-process TTL cache
   keyed by (model, base_url) around _query_local_context_length so back-to-
   back resolutions reuse one round-trip; not persisted to disk, so the
   reconcile freshness contract (re-probe after restart) is preserved. Add an
   autouse fixture clearing the cache between tests + TTL coverage.

Tests: 148 passed (was 138). ruff clean.

* chore(release): add infinitycrew39 to AUTHOR_MAP (#56431 salvage)

* chore: add trismegistus-wanderer to AUTHOR_MAP for PR #31856 salvage

* fix(dashboard): disable ws keepalive ping on loopback to survive event-loop stalls

Desktop/dashboard WebSocket connections drop during long agent operations
(delegate_task subagents, large model outputs) when the uvicorn event loop is
GIL-starved for minutes. Root cause: uvicorn's ws keepalive ping runs on the
SAME event loop as agent turns. A single synchronous GIL-holding call on a
worker thread (a regex/scrub over a large output, or a long subagent turn)
freezes the loop, so it cannot process the incoming pong within ws_ping_timeout
and uvicorn closes an otherwise-healthy connection (#53773: 'event loop stalled
226.3s'; #48445/#50005). Loosening the timeout only raises the threshold — a
multi-minute stall sails past any finite window.

The keepalive ping exists to detect half-open connections (reverse-proxy 524,
dropped tunnels), which cannot happen on loopback: there is no network or proxy
in the path, and a dead local client tears the socket down with a real FIN/RST
that starlette surfaces as WebSocketDisconnect regardless of the ping. So on
loopback the ping provides ~no liveness value while actively killing
recoverable stalls — disable it entirely (ws_ping_interval/timeout=None).

Non-loopback (public) binds sit behind a Cloudflare Tunnel where half-open IS a
real failure mode, so the ping stays at 20/20 to detect it.

Empirically verified (real uvicorn + websockets peer): with ws_ping=None the
server never closes a silent peer during an 8s window; with the pre-fix 2s/2s
window uvicorn closes it. A genuinely-dead client still fires the
WebSocketDisconnect reap path regardless of the ping.

Note: this fixes the local Desktop case (the OP's scenario). A remote Desktop
over an authenticated public dashboard route (McCalebTheSecond's comment) keeps
the ping and needs the deeper GIL-hotspot fix — tracked separately.

Closes #53773

* fix(agent): self-review follow-ups on vLLM local-context salvage

Self-review (ruff+ty lint diff = 0 net-new; 2-agent deep review) surfaced one
Warning + comment-accuracy nits; no Critical:

- W1: the local-probe TTL cache memoized None (probe failure) for 30s, so a
  probe that failed during a startup race would suppress a legit retry once
  the server came up. Cache only positive results — still fully bounds the
  hot-path probe rate (reachable servers cache their value) while an
  unreachable one re-probes on the next call. Add a regression test asserting
  a None result is NOT cached (retry re-probes); mutation-verified.

- Tighten the platform-guard comment: gateway/TUI/cron already construct with
  quiet_mode=True (gated by `not agent.quiet_mode`), so the guard's active job
  is CLI dedup vs show_banner, not "filling the gateway/TUI gap" as originally
  worded.

Verified not-issues (per review): positive-value 30s cache does not break the
reconcile-after-restart freshness contract (restart = fresh process, empty
cache); cache key is collision-safe; platform guard is correct in both
directions (no runtime path leaves platform None on a non-CLI surface).

Tests: 149 passed. ruff clean; ty 0 net-new vs base.

* fix(gateway): keep idle cached agents alive until session actually expires

The idle-TTL sweep (_sweep_idle_cached_agents) was evicting agents
as soon as they passed _AGENT_CACHE_IDLE_TTL_SECS, even when the
session hadn't expired yet. In daily-reset mode the reset can fire
hours after the last user message — evicting the agent early means
the session-expiry watcher has no agent in cache to call
on_session_end() with, so memory providers miss the live transcript.

Now the sweep checks the session store before evicting: if the
session still exists and hasn't expired, the agent stays in cache
so the expiry watcher can tear it down properly later.
When the session store is unavailable or throws, falls back to the
original eviction behavior (safe default).

Fixes: #11205

* fix(gateway): complete on_session_end coverage across all eviction paths

Follow-up to the cherry-picked #31856 fix. The contributor's guard defers
idle-TTL eviction until the session store reports the session expired, so the
expiry watcher can tear the agent down and fire MemoryProvider.on_session_end()
with the live transcript. Two gaps remained:

1. Memory-leak regression for mode='none' sessions. _is_session_expired()
   returns False forever for the 'none' reset policy, so the naive guard would
   never idle-evict those agents — reopening the unbounded-cache leak the idle
   sweep (#11565) exists to relieve. Added SessionStore.is_session_finalizable()
   (a public predicate: will the expiry watcher EVER finalize this session?) and
   gate the deferral on it. mode='none' agents fall through to soft eviction as
   before.

2. on_session_end still dropped on the LRU-cap path. Both cache-pressure paths
   (_enforce_agent_cache_cap and _sweep_idle_cached_agents) soft-evict via
   _release_evicted_agent_soft, which by design does NOT fire on_session_end.
   If cache pressure evicts a finalizable-but-not-yet-expired agent before it
   expires, the watcher later finds no cached agent and the hook is skipped.
   Added _commit_memory_before_soft_evict(): at LRU eviction, if the session is
   finalizable and not yet expired, commit end-of-session extraction via the
   live agent's own (fully-scoped) memory manager using commit_memory_session()
   — extraction WITHOUT provider teardown, so the eviction stays soft and a
   resumed turn keeps working. Skipped for mode='none' (no missed boundary to
   compensate) and expired sessions (the watcher tears those down directly).

This closes #11205 for ALL eviction paths and reset policies, not just the
idle-sweep + finite-policy case, while preserving the soft-eviction
resumability contract (never calls close() on a live session).

Tests: 5 new cases in test_agent_cache.py (mode='none' still reaped, LRU-cap
commits for finalizable / skips for none, real is_session_finalizable
predicate); all mutation-checked. Contributor's original 2 tests updated to
assert the finalizable path explicitly.

* fix(providers): pass extra headers to model discovery

* refactor(providers): dedupe extra_headers normalizer + key picker groups by headers

Follow-up to @helix4u's #57336 salvage. Two review findings:

- W1: model-picker grouped custom-provider rows by
  (api_url, credential, api_mode) but NOT extra_headers. Entries sharing a
  URL+credential+api_mode yet declaring different headers (e.g. per-tenant
  routing behind one proxy) collapsed into one row and probed /models with
  whichever header set was seen first (order-dependent). Fold a canonical
  header identity into group_key so distinct header-authed endpoints stay
  separate; drops the now-dead first-non-empty merge branch.
- W2: the extra_headers stringify+None-filter comprehension existed in 5
  copies (config.py x2, runtime_provider.py, model_switch.py, models.py).
  Extract one shared hermes_cli.config.normalize_extra_headers primitive;
  all sites now call it.

Tests: +normalize_extra_headers unit tests, +regression test proving two
same-endpoint entries with different headers stay distinct and each probes
with its own headers. 223 targeted tests pass; ruff clean.

* fix(desktop): let settings content use full pane width

Remove the max-w-4xl wrapper from SettingsContent so every settings
page can use the available overlay width.

* fix(desktop): extend profile startup REST timeouts (#48504)

* fix(desktop): extend startup long-timeout to the whole boot data burst

Broadens Tranquil-Flow's profile-startup timeout fix (#48518) from getProfiles
+ refreshActiveProfile to the rest of the calls the desktop fires during
connect: /api/config, /api/config/defaults, /api/model/info, /api/model/options,
/api/cron/jobs. On a profile-heavy or remote install any of these can exceed
the 15s DEFAULT_FETCH_TIMEOUT_MS while the backend is alive-but-busy (e.g.
list_profiles walks the skill tree per profile), surfacing as the spurious
"Timed out connecting to Hermes backend after 15000ms" that hangs the UI
(#48504).

Uses the surgical per-call mechanism (renamed STARTUP_PROFILE_REQUEST_TIMEOUT_MS
→ STARTUP_REQUEST_TIMEOUT_MS) rather than raising the global default (the
alternative in #48526): the liveness poll /api/status and all interactive/
runtime calls keep the short default, so a genuinely-dead backend is still
detected fast and the boot readiness probe (waitForHermes) is untouched.

Supersedes #48518 (carried as the base commit) and #48526 (global-default
raise). Fixes #48504.

Co-authored-by: YapBi <129007007+HeLLGURD@users.noreply.github.com>
Co-authored-by: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com>

* fix(webhook): close per-delivery session at the true end of the run (#57423)

The merged webhook session-close fix (#57370, salvaging #57322) wrapped
handle_message in a try/finally — but BasePlatformAdapter.handle_message
is fire-and-forget: it spawns _process_message_background and returns
before the agent run starts. The finally-close therefore ran BEFORE
get_or_create_session created the session row, found no session_id, and
silently no-op'd — the ghost-session leak persisted on the real path.
(The shipped test masked this by stubbing handle_message with a fake
that created the row synchronously.)

Move the close to an on_processing_complete override — the lifecycle
hook the base class fires at the TRUE end of the run, on the success,
failure, and cancellation paths alike. Empirically verified through the
real fire-and-forget pipeline: before, ended_at stayed NULL; after,
ended_at is set with end_reason=webhook_complete and the row is
prunable.

Tests now stub only the runner-side _message_handler (the seam the live
gateway injects) so handle_message / _process_message_background /
on_processing_complete all run for real; adds an AsyncSessionDB-facade
coverage test for the coroutine-await branch.

* fix(dump): flag API keys visible only to the shell, not the managed backend

hermes debug share reads os.getenv — the invoking terminal's environment — but
launchd/systemd and the desktop-spawned `serve` backend load credentials from
~/.hermes/.env, not the login shell. A key exported in the shell but absent
from .env is invisible to the backend, yet the dump printed a bare "set",
sending support down a phantom "the key is configured" path.

This was the actual trap behind a "Desktop has no web_search / no tools"
report: FIRECRAWL_API_KEY was a shell export (so `debug share` in a terminal
read "firecrawl set") but not in .env, so the launchd backend's
check_web_api_key returned False and web_search was gated off — which a
contributor then misdiagnosed as a missing `desktop` platform registration.

The dump now annotates any key set in-process but missing from ~/.hermes/.env
with "(shell only — not in .env; managed/desktop backend may not see it)" so
the mismatch is obvious instead of hidden behind "set".

* feat(skills): add security/unbroker (autonomous data-broker removal)

unbroker finds where a consenting person's info is exposed across data
brokers and people-search sites and files the removals, running as far as
each site allows and handing only genuinely human-only steps (hard CAPTCHA,
gov-ID, phone, fax) back as an end-of-run digest.

- Deterministic stdlib CLI (scripts/pdd.py) owns config, dossiers+consent,
  the broker DB, tier planning, the ledger, email, and the autonomous
  action queue; the agent scans/submits with native tools (web_extract,
  browser_*, delegate_task, cronjob, terminal).
- Verify-before-disclose, least-disclosure (never volunteers SSN), consent
  gate, opaque ids, optional age-at-rest encryption, file-locked ledger.
- Jurisdiction-aware (CCPA/CPRA, GDPR, generic); CA DROP one-shot covers
  the state registry (~545) in a single request; BADBOOL + curated
  people-search coverage; scheduled re-scan for re-listing.
- No CAPTCHA-solving services or anti-bot bypass; browser email mode needs
  no stored password.
- 85 hermetic tests (tests/skills/test_unbroker_skill.py; SMTP/IMAP via
  injected fakes, registry via CSV fixtures). Ships placeholder data only.

Broker dataset adapted from BADBOOL (Yael Grauer, CC BY-NC-SA 4.0).

* docs(unbroker): point README image link at hermes-agent; sync test count (85)

* feat(desktop): cap overlay inner-page width at 75rem

Add a shared PAGE_MAX_W (1200px) and center OverlayMain within its pane
so settings and command center bodies stay readable instead of sprawling
on wide/ultrawide displays.

* fix(desktop): stop macOS Tahoe misplacing the traffic lights

On macOS Tahoe (Darwin 25+), a nonzero titleBarOverlay height makes
setWindowButtonPosition() miscalculate the native traffic-light position
(electron#49183), shoving the lights into the left titlebar tools. Pass
height 0 there so the lights land at the configured inset; the renderer
paints its own drag strips, so nothing is lost. Pre-Tahoe is unchanged.

Gate on the truthful Darwin kernel major (25 = Tahoe) rather than the
product version, which macOS reports as 16 or 26 depending on build SDK.

* fix(desktop): clear stale active todos on turn end AND on rehydration

A turn that ends without a final `todo` update left the composer "Tasks N/M"
panel pinned with its last item stuck pending/in_progress, and it survived
restarts because the panel is read back from stored session history.

Two coupled fixes (the first alone is undone by the second path):

- Turn end: clear a still-active todo list on `message.complete` and on a
  terminal `error` (new `clearActiveSessionTodos` — active lists only; a
  finished list keeps its short linger so the last checkmark still lands).
- Rehydration: `hydrateFromStoredSession` runs *after* a turn completes, so an
  "active" stored list is stale, not in-flight. It now restores only a
  *finished* list (via new `todosForHydration`) and drops anything still
  active — otherwise it re-pinned the panel right after the turn-end clear and
  resurrected it on every restart.

Salvages #52996 (@0disoft): the fix shape (clearActiveSessionTodos on turn
completion, preserving the finished-list linger) is carried forward and ported
onto the current use-message-stream/ folder split (gateway-event.ts), then
extended to the rehydration path per review.

Co-authored-by: 0disoft <rodisoft1@gmail.com>

* fix(unbroker): suppress-first for PeopleConnect (deletion undoes suppression)

PeopleConnect is the exception to deletion-beats-suppression: "DELETE MY
USER DATA" also deletes suppressions on file, and deletion does not stop the
people-search sites from showing you (public records re-list). Suppression
is the effective lever and must be maintained.

- intelius.json: deletion.prefer=false; playbook/quirks/notes rewritten with
  the verbatim privacy-center language; delete is the data-purge-only path.
- autopilot: honor deletion.prefer -> prefer_suppression when false.
- methods.md / SKILL.md / README: exception called out.
- tests updated + prefer-flag routing test (86 tests).

* fix(file-tools): preserve container paths for docker file ops (#56637)

* fix(config): preserve owner on atomic writes (#56644)

* fix(desktop): use gift codicon for update-available toast

Add optional notification icon override and use codicon-gift on the
update-ready toast so it reads as a present rather than generic info.

* fix(desktop): use symbol-namespace codicon for Model settings nav

* fix(desktop): cancel downloads triggered by link-title fetch window

The hidden BrowserWindow used by fetchLinkTitle to scrape page titles
had no will-download handler on its session.  When a link artifact URL
responds with Content-Disposition: attachment, Electron fires will-download
and the file is saved for real — explaining the spurious download on the
Artifacts page.

Add guardLinkTitleSession() (parallel to the existing audio-mute guard for
#49505) that installs a will-download handler which immediately cancels
every download item on the hermes:link-titles session.  Call it from
getLinkTitleSession() right after the request-type blocklist is wired up.

* fix(slack): MPIMs (group DMs) obey shared-surface mention gating + reaction guard

Group DMs (MPIMs) were classified as DMs and thereby exempted from every
operator control that shared surfaces are supposed to honor: allowed_channels,
require_mention, strict_mention, free_response_channels, and the reaction
guard. Symptom: the bot added :eyes:/:white_check_mark: to unmentioned MPIM
messages and still invoked the agent (which then returned NO_REPLY) instead of
the gateway dropping the event before model execution. Removing an MPIM from
allowed_channels did not disable it.

Root cause is the DM classification at adapter.py:
    is_dm = channel_type in {"im", "mpim"}
used for BOTH routing exemptions and reaction gating. An MPIM is a shared
surface (multiple humans can see and trigger the bot), not a private 1:1 DM,
so it must be gated like a channel.

This behavior was introduced/reinforced by a trail of Slack group-DM PRs:
- #4633  fix(slack): treat group DMs (mpim) like DMs + reaction guard
- #54632 fix(slack): subscribe to message.mpim + mpim scopes so group DMs work
- #54663 fix(slack): group DMs work OOTB + reinstall nudge
#54632/#54663 correctly made MPIM messages *reachable*; #4633 over-reached by
giving them the DM mention/reaction *exemptions*. This corrects only that
over-reach.

Fix (minimal): introduce `is_one_to_one_dm = channel_type == "im"` and key the
two EXEMPTION sites off it instead of `is_dm`:
- mention/allowlist gating block (`if not is_one_to_one_dm and bot_uid:`)
- reaction guard (`(is_one_to_one_dm or is_mentioned)`)
`is_dm` is intentionally retained for session/thread scoping and chat_type
labeling, where treating an MPIM as a persistent multi-party conversation is
correct — only the mention/reaction exemptions were wrong.

Docs: slack.md now distinguishes 1:1 DMs (mention-exempt) from group DMs
(shared surface; obey require_mention/strict_mention/allowed_channels/
free_response_channels; reactions only when @mentioned).

Tests: +7 in test_slack_mention.py (MPIM unmentioned dropped under
require_mention and strict_mention; MPIM mentioned processed; MPIM off
allowed_channels dropped; MPIM in free_response opted in; 1:1 IM still exempt;
reaction guard drops unmentioned MPIM). Updated _would_process to model the
is_one_to_one_dm gating + strict_mention. 72 passed.

* test(slack): give the MPIM reaction-guard test real teeth

The reaction-guard regression test defined a local _should_react lambda and
asserted it against itself — a tautology that would stay green even if the
production guard at _handle_slack_message reverted to (is_dm or is_mentioned),
re-introducing the unmentioned-MPIM reaction spam this PR fixes.

Replace it with a shared _reaction_guard helper plus a source-introspection
test that pins the production expression: asserts (is_one_to_one_dm or
is_mentioned) is present and (is_dm or is_mentioned) is absent. Mutation-checked
— reverting the adapter guard now fails the test.

Follow-up self-review finding on the salvage of #57339.

* fix(agent): strip _db_persisted when assembling rotation compression transcript (#57491)

Shallow messages[i].copy() during context compression propagated the
_db_persisted marker from cached gateway incremental flushes into the
post-rotation compressed list. _flush_messages_to_session_db then skipped
every row when writing to the new child session, so gateway restarts
lost the compacted transcript (severe amnesia).

Strip the marker in _fresh_compaction_message_copy() and add regression
tests for rotation flush + compressor assembly.

Fixes #57491

* fix(agent): enforce marker-strip invariant with a single terminal sweep (#57491)

Follow-up to the per-site strips from the review gate. The two copy-site
strips are correct but positional — a copy site added after the assembly
loops would re-leak _db_persisted into the child-session flush. Add a single
terminal sweep (_strip_persistence_markers) run once on the fully-assembled
compressed list so the invariant 'no compacted message leaves compress()
carrying a persistence marker' is structural, not dependent on copy-site order.

- agent/context_compressor.py: _strip_persistence_markers() called before
  compress() returns; helper docstring notes the sweep is the authoritative guard
- tests/agent/test_context_compressor.py: structural regression — neuter the
  per-site helper to a leaking copy, assert the terminal sweep still strips
- tests/run_agent/test_compression_persistence.py: pin the fixture assumption
  behind the exact-equality row-count assertion

* fix(moa): default temperatures to unset — provider default, like single-model agents (#57440)

A single-model Hermes agent never sends temperature; the provider default
applies. MoA hardcoded reference_temperature=0.6 / aggregator_temperature=0.4,
and the coercion float(preset.get(key, 0.6) or 0.6) made unset IMPOSSIBLE to
express: absent, null, empty, and even an explicit 0 all collapsed to the
baked-in default. Every MoA advisor and aggregator therefore ran at 0.6/0.4
while the same model running solo used the provider default — silently
skewing solo-vs-MoA comparisons and overriding provider-tuned defaults.

- moa_config normalization: temperatures coerce to None when absent/blank/
  invalid (new _coerce_float_or_none); explicit values incl. 0 honored.
- moa_loop: _preset_temperature() resolves preset values; None flows to
  call_llm, which already omits the parameter when None (same contract as
  max_tokens). Aggregator still inherits the acting agent's own configured
  temperature when the preset doesn't pin one.
- conversation_loop (context-mode MoA): same resolution, no more hardcoded
  0.6/0.4 at the call site.
- DEFAULT_CONFIG preset + web_server payload models + docs updated: unset
  is the default, pinning stays available.

* fix(opencode-go): heal stripped /v1 base_url so non-minimax models stop 404ing (#57585)

OpenCode Go serves minimax/qwen via Anthropic Messages (base URL without
/v1 — the SDK appends /v1/messages) and glm/kimi/deepseek/mimo via OpenAI
chat completions (base URL WITH /v1). The runtime stripped /v1 for
anthropic-routed models, and the TUI/desktop + gateway persisted that
stripped URL to model.base_url. Every later chat_completions model then
POSTed to https://opencode.ai/zen/go/chat/completions — a 404 (the
marketing site). Result: only minimax worked; glm/deepseek/kimi all 404ed.

- New normalize_opencode_base_url(): symmetric /v1 normalization —
  strip for anthropic_messages, re-append for chat_completions /
  codex_responses on opencode.ai hosts (heals persisted stripped URLs;
  custom proxy overrides untouched)
- Applied at all three former one-way strip sites (resolve_runtime_provider
  x2, switch_model)
- opencode_model_api_mode: all Qwen models on Go AND Zen now route via
  /v1/messages per current published endpoint tables (previously only
  qwen3.7-max on Go — qwen3.6-plus etc. would 404 the same way)
- Catalog refresh: Go gains deepseek-v4-pro/flash, glm-5.2,
  kimi-k2.7-code, minimax-m3, qwen3.7-plus; Zen gains glm-5.2,
  kimi-k2.7-code, minimax-m3, qwen3.7-plus

Reported by IndieSuperhuman on X: opencode-go 404s for any model other
than minimax.

* feat(moa): per-preset fanout cadence — user_turn runs advisors once per user turn (#57591)

New preset key 'fanout': 'per_iteration' (default, unchanged behavior)
re-runs the reference fan-out whenever the advisory view changes — every
tool iteration. 'user_turn' runs the advisors ONCE per user turn and lets
the aggregator act alone for the rest of the tool loop — the original MoA
shape (upfront multi-model synthesis, then a single acting model), and the
obvious lever on MoA's wall/cost multiplier (advisor generation dominates
per-turn latency).

Implementation reuses the existing turn-scoped reference cache: in
user_turn mode the cache signature hashes only the prefix up to the LAST
user message, so mid-turn advisory-view growth doesn't change the key and
iteration 2+ is a cache HIT (advice reused, zero advisor spend, no
re-trace). A new user message changes the prefix and re-triggers the
fan-out. Unknown fanout values normalize to per_iteration.

* feat(desktop): CLI/dashboard parity — skills hub, MCP test/toggle/catalog, maintenance ops, log filters (#57441)

* feat(desktop): CLI/dashboard parity — skills hub browser, MCP test/toggle/catalog, maintenance ops, log filters

Brings desktop GUI to parity with hermes skills/mcp/doctor/backup/debug-share/
curator/memory CLI commands and the dashboard's System + Skills-hub pages:

- Skills page: new Browse Hub tab (search official/GitHub/community sources,
  preview SKILL.md, security scan verdicts, install/update with live action log)
- MCP settings: connection test (tool listing), per-server enable/disable
  toggle, and a Catalog tab installing Nous-approved MCP servers with env prompts
- Command Center: new Maintenance section (doctor, security audit, backup,
  debug share links, curator status/pause/run, memory file status + reset)
- Command Center system logs: file (agent/errors/gateway/desktop), level, and
  substring filters instead of a fixed agent.log tail
- hermes.ts API client + types for all the above; en/zh locale strings (ja and
  zh-hant inherit via defineLocale)

* feat(desktop): backend model catalogs in toolset config — hermes tools parity

Completes the `hermes tools` parity gap: after picking an image/video
generation backend the CLI runs a model picker (e.g. FAL's multi-model
catalog with speed/strengths/price); the desktop toolset drawer now has the
same flow as a radio-card list.

- web_server: GET /api/tools/toolsets/{name}/models (catalog + current +
  default for the active or named provider row) and PUT .../model
  (validated write to image_gen.model / video_gen.model), reusing the CLI's
  plugin catalog helpers so GUI and `hermes tools` stay in lockstep
- desktop: ModelCatalogPicker in ToolsetConfigPanel — per-model cards with
  speed/strengths/price, in-use + default badges, disabled until the
  backend is the active one; provider selection now mirrors is_active
  locally so the catalog unlocks without a refetch
- tests: 3 backend endpoint tests (catalog shape invariants, persist +
  validation), 2 component tests, 2 API-contract tests; en/zh strings

* fix(browser): retry next candidate when debug launch exits early

* fix(browser): surface launch diagnostics when debug browser never opens the CDP port

Follow-up to the salvaged early-exit retry fix (#35617): the debug-browser
launch path was fire-and-forget (stderr to DEVNULL, no logging), so every
platform failure — Windows singleton forward to an existing instance, bad
profile dir, missing shared libraries, policy blocks — collapsed into the
same unactionable 'port 9222 isn't responding yet' message and debug
reports contained nothing.

- launch_chrome_debug() returns a structured ChromeDebugLaunch with
  per-candidate attempts (state, exit code, stderr tail)
- browser stderr is captured to <hermes_home>/chrome-debug/launch-stderr.log
- clean exit (code 0) without the port opening is detected as Chromium's
  single-instance forward and produces a targeted user hint to close all
  running instances of that browser
- crash exits surface the stderr tail (e.g. missing libnspr4.so)
- every spawn/exit is logged to agent.log so hermes debug share captures it
- CLI (/browser connect) and TUI/desktop (browser.manage) both print the hint

* fix(moa): user_turn fanout — synthetic advisory marker must not count as a user turn (#57598)

The advisory view appends a synthetic user marker when it ends on an
assistant turn (Anthropic end-on-user rule) — i.e. on every tool iteration
after the first. The user_turn prefix hash treated that marker as the last
user message, so the hashed prefix included the grown mid-turn context and
the signature changed every iteration: advisors re-ran per iteration,
silently defeating the once-per-turn cadence (live smoke test: 2 fan-outs
for a 2-iteration task; expected 1). Hoist the marker to a module constant
and skip it when locating the last REAL user message. Verified: iteration-2
signature now equals iteration-1 (cache HIT); a new real user message still
re-triggers the fan-out.

* fix(desktop): poll messaging sessions so platform traffic appears live

Inbound Telegram/WeChat/Discord messages are written by the background
gat…
teknium1 pushed a commit that referenced this pull request Jul 7, 2026
`_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).
habarmc1223-sudo pushed a commit to habarmc1223-sudo/hermes-agent-fluxmem that referenced this pull request Jul 8, 2026
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
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
`_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 NousResearch#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).
justemu pushed a commit to justemu/hermes-agent that referenced this pull request Jul 18, 2026
`_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 NousResearch#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).
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
`_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 NousResearch#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).
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
`_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 NousResearch#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).
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
`_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 NousResearch#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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles area/docker Docker image, Compose, packaging P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants