{
  "schemaVersion": 3,
  "dataset": {
    "version": 3,
    "date": "2026-08-13",
    "group": {
      "id": "coding-agent-infrastructure",
      "name": "Coding-Agent Infrastructure"
    },
    "repository": {
      "id": "hermes-agent",
      "repo": "NousResearch/hermes-agent",
      "name": "Hermes Agent",
      "keywords": [
        "Hermes Agent"
      ]
    },
    "context": {
      "repository": "NousResearch/hermes-agent",
      "url": "https://github.com/NousResearch/hermes-agent",
      "description": "The agent that grows with you",
      "homepage": "https://hermes-agent.nousresearch.com",
      "language": "Python",
      "topics": [
        "ai",
        "ai-agent",
        "ai-agents",
        "anthropic",
        "chatgpt",
        "claude",
        "claude-code",
        "codex",
        "hermes",
        "hermes-agent",
        "llm",
        "nous-research",
        "openai"
      ],
      "license": "MIT",
      "defaultBranch": "main",
      "stars": 230020,
      "forks": 45483,
      "openIssues": 31793,
      "archived": false,
      "collectedAt": "2026-08-13T18:02:15.811086+00:00"
    },
    "news": {
      "repository": "NousResearch/hermes-agent",
      "collectedAt": "2026-08-13T18:02:15.811086+00:00",
      "latestRelease": {
        "repository": "NousResearch/hermes-agent",
        "tag": "v2026.8.3",
        "title": "Hermes Agent v0.20.0 (2026.8.3)",
        "url": "https://github.com/NousResearch/hermes-agent/releases/tag/v2026.8.3",
        "publishedAt": "2026-08-03T16:57:52Z",
        "notes": "# Hermes Agent v0.20.0 (v2026.8.3)\n\n\n**Release Date:** August 3, 2026\n**Since v0.19.0:** ~3,650 commits · ~1,400 merged PRs · ~5,200 files changed · ~559,000 insertions · ~405,000 deletions · **~1,200 issues closed** · 650+ contributors\n\n> **The Herald Release.** Hermes is the herald of the gods, and this release makes him one in earnest: he **speaks** (real-time conversational voice with streaming TTS, barge-in, on-device wake words, and hands-free control across the CLI, desktop, and every audio-capable gateway platform), he **carries word to other agents** (A2A v1.0), he **announces events to your systems** (signed outbound webhooks), and he **cites his sources** (grounded research with verifiable citations and fact-checking). Around that spine: the desktop app became a platform (artifacts with live preview, a plugin SDK, quick-entry from anywhere, multiple windows), the CLI got a wave of power commands (`!` shell mode, `/init`, `/diff`, `/context`, `/focus`), compression got smarter and gentler, and the tools themselves now recover from their own failures instead of making the model guess. This release rolls up everything from the v0.19.1 infrastructure patch tag — that window is fully documented here.\n\n---\n\n## ✨ Highlights\n\n- **Talk to Hermes — streaming, conversational voice with barge-in** — Voice mode used to mean: speak, wait for the whole reply to generate, then listen to one long audio file. Now Hermes speaks clause-by-clause as the response streams, you can interrupt it mid-sentence by just talking (it stops, listens, and the model is told you cut in), and busy-aware silence detection means it doesn't talk over you. This works in CLI voice mode, on the desktop, and through gateway adapters. Talking to Hermes finally feels like a conversation, not a voicemail exchange. ([#69511](https://github.com/NousResearch/hermes-agent/pull/69511), [#73862](https://github.com/NousResearch/hermes-agent/pull/73862), [#74223](https://github.com/NousResearch/hermes-agent/pull/74223), [#74000](https://github.com/NousResearch/hermes-agent/pull/74000), [#69602](https://github.com/NousResearch/hermes-agent/pull/69602) — @teknium1, @OutThisLife)\n\n- **Wake words and hands-free control** — Say your own open-vocabulary wake phrase (\"hey Hermes\", or anything you pick) and Hermes starts listening — detection runs on-device, so no audio leaves your machine while it waits. Multi-profile voice routing means different wake words can reach different profiles, and saying \"stop\" ends the voice chat on every surface without touching the keyboard. Your terminal is now something you can talk to from across the room. ([#70509](https://github.com/NousResearch/hermes-agent/pull/70509), [#73106](https://github.com/NousResearch/hermes-agent/pull/73106), [#73933](https://github.com/NousResearch/hermes-agent/pull/73933) — @teknium1)\n\n- **Voice on every platform** — Send a voice note to Hermes on WhatsApp, Feishu, DingTalk, LINE, QQ, Photon, or Weixin and it's transcribed and answered; auto-TTS replies are delivered platform-aware (opus where platforms want opus, captions attached correctly). STT is now fully configurable — its own `hermes tools` category, GUI toggles, dashboard dropdowns, unified language resolution so transcripts stop coming back in the wrong language, and OpenAI's gpt-transcribe support. One unified spoken-text preprocessor cleans markdown, code, and URLs out of speech across all TTS providers. ([#73515](https://github.com/NousResearch/hermes-agent/pull/73515), [#73508](https://github.com/NousResearch/hermes-agent/pull/73508), [#73910](https://github.com/NousResearch/hermes-agent/pull/73910), [#73513](https://github.com/NousResearch/hermes-agent/pull/73513), [#73067](https://github.com/NousResearch/hermes-agent/pull/73067) — @teknium1)\n\n- **Research you can trust — grounded citations with fact-checking** — The new `grounded-citations` skill makes Hermes produce research where every claim is backed by a verifiable source: quotes are matched against the actual page text (not hallucinated), citations link to the exact evidence, and a fact-checking mode turns the same machinery on any document or claim you hand it — it tells you what checks out, what doesn't, and what couldn't be verified. If you use Hermes for research, this is the difference between \"sounds right\" and \"provably sourced.\" ([#71698](https://github.com/NousResearch/hermes-agent/pull/71698), [#77104](https://github.com/NousResearch/hermes-agent/pull/77104) — @teknium1)\n\n- **Outbound webhooks — Hermes pushes events to your systems** — Until now, integrating with Hermes meant polling or listening on a platform. Now Hermes pushes **signed lifecycle events** (session activity, turn completions, tool events) to any HTTP endpoint you register — with HMAC signatures so your receiver can verify authenticity. Wire Hermes into your CI, your home automation, your dashboards, or any service that speaks HTTP, with no polling loop. ([#69406](https://github.com/NousResearch/hermes-agent/pull/69406) — @teknium1)\n\n- **The desktop app becomes a platform — artifacts, plugin SDK, quick entry** — Hermes desktop now renders **artifacts**: versioned cards with sandboxed live preview in a right-rail viewer, so generated HTML/apps run safely next to the chat. A real **plugin SDK** landed with Kanban as its founding plugin, `ctx.download` for handing users files, floating pane placement, and multiple GUI windows. A global-hotkey **quick-entry window** captures a thought into any session from anywhere in your OS. The desktop stopped being a chat client and started being a workbench. ([#72345](https://github.com/NousResearch/hermes-agent/pull/72345), [#61173](https://github.com/NousResearch/hermes-agent/pull/61173), [#74413](https://github.com/NousResearch/hermes-agent/pull/74413), [#72315](https://github.com/NousResearch/hermes-agent/pull/72315), [#68259](https://github.com/NousResearch/hermes-agent/pull/68259), [#73143](https://github.com/NousResearch/hermes-agent/pull/73143) — @OutThisLife, @teknium1)\n\n- **Hermes speaks Agent-to-Agent — A2A v1.0** — A new bundled plugin implements the Agent-to-Agent protocol, so Hermes can discover, talk to, and be driven by other A2A-compatible agents. This closes issue #514 — one of the oldest open feature requests in the repo. If you're building multi-agent systems with heterogeneous stacks, Hermes now has a standard wire protocol for joining them. ([#77109](https://github.com/NousResearch/hermes-agent/pull/77109) — @teknium1)\n\n- **CLI power-user wave** — `!command` runs a shell command instantly without spending a model turn. `/init` scans your project and generates (or updates) an `AGENTS.md`. `/diff` shows staged/all/session changes from any surface, `/context` breaks down exactly what's filling your context window, `/focus` gives you a reduced-output view with hidden-line recovery, and Ctrl+S stashes a half-written prompt into a browsable panel. Plus `hermes import-agent` migrates your Claude Code or Codex CLI setup into Hermes in one command. ([#72257](https://github.com/NousResearch/hermes-agent/pull/72257), [#72178](https://github.com/NousResearch/hermes-agent/pull/72178), [#72240](https://github.com/NousResearch/hermes-agent/pull/72240), [#72242](https://github.com/NousResearch/hermes-agent/pull/72242), [#72302](https://github.com/NousResearch/hermes-agent/pull/72302), [#72262](https://github.com/NousResearch/hermes-agent/pull/72262), [#72190](https://github.com/NousResearch/hermes-agent/pull/72190) — @teknium1, several salvaging long-standing community PRs)\n\n- **Correct the agent mid-turn — redirects** — If Hermes is heading the wrong way, you no longer have to `/stop` and re-explain. Type a correction while it works and the active turn is redirected: work in flight is preserved, the original prompt is kept, and the agent course-corrects with your new guidance. Paired with double-ESC draft discard and a composer undo stack, steering feels like editing, not restarting. ([#63104](https://github.com/NousResearch/hermes-agent/pull/63104), [#72339](https://github.com/NousResearch/hermes-agent/pull/72339), [#74736](https://github.com/NousResearch/hermes-agent/pull/74736) — @OutThisLife)\n\n- **Tools that fix themselves** — A sweep of self-recovery upgrades means the agent wastes far fewer turns on tool friction: truncated terminal output spills to a file the agent can read back, `patch` detects already-applied edits and diagnoses whitespace mismatches, `write_file` verifies content on disk, searches that match nothing probe for near-misses and recover, and common failure classes come back with actionable hints. The default tool-calling iteration limit also jumped 90 → 500 — long autonomous runs stopped hitting an artificial wall. ([#77041](https://github.com/NousResearch/hermes-agent/pull/77041), [#76998](https://github.com/NousResearch/hermes-agent/pull/76998), [#77024](https://github.com/NousResearch/hermes-agent/pull/77024), [#77055](https://github.com/NousResearch/hermes-agent/pull/77055), [#77011](https://github.com/NousResearch/hermes-agent/pull/77011), [#76992](https://github.com/NousResearch/hermes-agent/pull/76992), [#72176](https://github.com/NousResearch/hermes-agent/pull/72176) — @teknium1)\n\n- **Compression that respects your conversation** — Context compression got a deep overhaul: proactive tool-result pruning for large-window models, per-turn micro-compaction that amortizes the cost instead of one giant pause, a guaranteed N-user-message tail so recent conversation always survives, progress-aware timeouts that stop punishing slow summary models, and ghost-skill defense so a pruned skill can never silently haunt a session. Thresholds are now configurable per-model and in absolute tokens. Long sessions stay coherent and stop stalling. ([#70254](https://github.com/NousResearch/hermes-agent/pull/70254), [#75345](https://github.com/NousResearch/hermes-agent/pull/75345), [#70250](https://github.com/NousResearch/hermes-agent/pull/70250), [#71508](https://github.com/NousResearch/hermes-agent/pull/71508), [#70275](https://github.com/NousResearch/hermes-agent/pull/70275) — @teknium1, @kshitijk4poor, salvaging multiple community PRs)\n\n- **Smart approvals grow up** — `hermes approvals suggest` mines your approval history into allowlist proposals, operators can customize the smart-approval policy, a consecutive-denial circuit breaker stops a misbehaving loop cold, and desktop pairing approvals are profile-correct with a proper surface to answer them from. Plus a new approval gate for docker/podman daemon-redirect commands. Less clicking \"approve\", without giving an inch of control. ([#72259](https://github.com/NousResearch/hermes-agent/pull/72259), [#72186](https://github.com/NousResearch/hermes-agent/pull/72186), [#72203](https://github.com/NousResearch/hermes-agent/pull/72203), [#74446](https://github.com/NousResearch/hermes-agent/pull/74446), [#71092](https://github.com/NousResearch/hermes-agent/pull/71092) — @teknium1, @OutThisLife)\n\n- **Faster everywhere, again** — Prompt caching now covers tool schemas on native Anthropic without history loss. `hermes -w` cold start dropped ~14s → ~1.8s, `hermes update` no-ops got 2–6s faster, heavy SDKs lazy-load off the import path, config reads stopped deep-copying (54× faster on the telemetry gate), and the desktop shipped a second 60fps wave — streaming cost independent of transcript length, drag at 60fps with five streaming tabs, idle CPU near zero in the background. ([#76032](https://github.com/NousResearch/hermes-agent/pull/76032), [#71637](https://github.com/NousResearch/hermes-agent/pull/71637), [#74218](https://github.com/NousResearch/hermes-agent/pull/74218), [#74204](https://github.com/NousResearch/hermes-agent/pull/74204), [#71835](https://github.com/NousResearch/hermes-agent/pull/71835), [#72346](https://github.com/NousResearch/hermes-agent/pull/72346), [#75218](https://github.com/NousResearch/hermes-agent/pull/75218) — @kshitijk4poor, @teknium1, @OutThisLife)\n\n- **New places to run and be reached** — Buzz lands as a bundled gateway platform (Block's Nostr-based messenger, with native WebSocket transport and NIP-42 auth), the Vercel AI Gateway provider and Vercel Sandbox terminal backend return modernized, desktop gains an SSH remote-backend connection mode, and the Relay shipped four phases of parity — media, interactive prompts, thread lifecycle, typing indicators — plus HSP personal + org skill sync. ([#73610](https://github.com/NousResearch/hermes-agent/pull/73610), [#73761](https://github.com/NousResearch/hermes-agent/pull/73761), [#74518](https://github.com/NousResearch/hermes-agent/pull/74518), [#68130](https://github.com/NousResearch/hermes-agent/pull/68130), [#71300](https://github.com/NousResearch/hermes-agent/pull/71300)–[#71624](https://github.com/NousResearch/hermes-agent/pull/71624), [#66730](https://github.com/NousResearch/hermes-agent/pull/66730) — @teknium1, @yoniebans, @benbarclay)\n\n---\n\n## 🎙️ Voice & Speech\n\n### Conversational voice\n- Streaming, conversational TTS with barge-in across all surfaces; clause-by-clause synthesis for CLI voice mode + gateway adapters ([#69511](https://github.com/NousResearch/hermes-agent/pull/69511), [#73862](https://github.com/NousResearch/hermes-agent/pull/73862) — @OutThisLife, @teknium1)\n- Voice chat UX polish — busy-aware silence, stop hint, thinking sounds, barge-in fix; full-duplex turn listener (interrupt by voice during generation AND playback) ([#74000](https://github.com/NousResearch/hermes-agent/pull/74000), [#74223](https://github.com/NousResearch/hermes-agent/pull/74223) — @teknium1)\n- On-device wake words with open-vocabulary phrases + multi-profile voice routing; say \"stop\" to end voice chat hands-free on every surface ([#70509](https://github.com/NousResearch/hermes-agent/pull/70509), [#73106](https://github.com/NousResearch/hermes-agent/pull/73106), [#73933](https://github.com/NousResearch/hermes-agent/pull/73933) — @teknium1)\n- The model is told when the user interrupts its spoken reply; desktop speaks the whole turn and idle-flushes held narration ([#69602](https://github.com/NousResearch/hermes-agent/pull/69602), [#69936](https://github.com/NousResearch/hermes-agent/pull/69936) — @OutThisLife, @teknium1)\n- 15-item CLI/TUI voice-mode UX and environment fix wave ([#73520](https://github.com/NousResearch/hermes-agent/pull/73520) — @teknium1)\n\n### TTS / STT infrastructure\n- Unified spoken-text preprocessing + speed/instructions/provider tool params; unified STT language resolution (fixes the wrong-language transcription class); global `stt.language` defaults to `en` ([#73513](https://github.com/NousResearch/hermes-agent/pull/73513), [#73067](https://github.com/NousResearch/hermes-agent/pull/73067), [#73100](https://github.com/NousResearch/hermes-agent/pull/73100) — @teknium1)\n- Fully configurable STT — `hermes tools` category, GUI toggle/matrix, dashboard dropdowns, setup status; OpenAI gpt-transcribe support ([#73910](https://github.com/NousResearch/hermes-agent/pull/73910), [#73853](https://github.com/NousResearch/hermes-agent/pull/73853) — @teknium1)\n- Platform-aware auto-TTS voice delivery (opus platforms, streamed/global gap, captions); inbound voice classification/routing for Feishu, DingTalk, LINE, QQ, Photon, WhatsApp, Weixin ([#73508](https://github.com/NousResearch/hermes-agent/pull/73508), [#73515](https://github.com/NousResearch/hermes-agent/pull/73515) — @teknium1)\n- Command TTS/STT provider hardening — idle timeouts, env scrubbing, no-shell, path guards ([#73514](https://github.com/NousResearch/hermes-agent/pull/73514) — @teknium1)\n- Sync per-sentence TTS synthesis pipelined with playback — the next sentence renders while the current one speaks ([#77355](https://github.com/NousResearch/hermes-agent/pull/77355) — @kshitijk4poor)\n- Discord voice PCM streams to ffmpeg stdin instead of a temp file ([#76970](https://github.com/NousResearch/hermes-agent/pull/76970) — @kshitijk4poor)\n\n## 🏗️ Core Agent & Architecture\n\n### Compression & context\n- Proactive tool-result pruning for large-window models; per-turn micro-compaction; N-user tail guarantee (`compression.min_tail_user_messages`); bounded summarizer input with head+tail retention ([#70254](https://github.com/NousResearch/hermes-agent/pull/70254), [#75345](https://github.com/NousResearch/hermes-agent/pull/75345), [#70250](https://github.com/NousResearch/hermes-agent/pull/70250), [#70249](https://github.com/NousResearch/hermes-agent/pull/70249) — @teknium1, @kshitijk4poor)\n- Ghost-skill defense — `[SKILL_PRUNED]` markers, protected prune, deterministic survival; progress-aware timeouts; lock-contended compression soft-defers instead of exhausting ([#70275](https://github.com/NousResearch/hermes-agent/pull/70275), [#71508](https://github.com/NousResearch/hermes-agent/pull/71508), [#70285](https://github.com/NousResearch/hermes-agent/pull/70285) — @teknium1)\n- Per-model threshold overrides; absolute token threshold (`compression.threshold_tokens`); opt-in idle-triggered compaction; opt-in progress notices; structured local logging for compression attempts ([#69339](https://github.com/NousResearch/hermes-agent/pull/69339), [#69335](https://github.com/NousResearch/hermes-agent/pull/69335), [#69360](https://github.com/NousResearch/hermes-agent/pull/69360), [#70457](https://github.com/NousResearch/hermes-agent/pull/70457), [#69338](https://github.com/NousResearch/hermes-agent/pull/69338) — @teknium1)\n- Context-engine ABC grows `select_context()` + `on_turn_complete()` verbs (salvage of @chaos-xxl's RFC work); engines can suppress or customize compaction status ([#70458](https://github.com/NousResearch/hermes-agent/pull/70458), [#69859](https://github.com/NousResearch/hermes-agent/pull/69859) — @teknium1)\n- Strict redaction applied at every compaction text boundary ([#69294](https://github.com/NousResearch/hermes-agent/pull/69294) — @teknium1)\n\n### Prompt caching & hot-path performance\n- Tool schemas cached on native Anthropic without history loss + consolidated cache-plan internals ([#76032](https://github.com/NousResearch/hermes-agent/pull/76032), [#76067](https://github.com/NousResearch/hermes-agent/pull/76067) — @kshitijk4poor)\n- DeepSeek prompt caching on OpenCode gateways; per-API-call token accounting off the turn thread; OpenAI wire client reused across sequential LLM calls; send-path tool-call canonicalization memoized ([#75886](https://github.com/NousResearch/hermes-agent/pull/75886), [#73359](https://github.com/NousResearch/hermes-agent/pull/73359), [#73375](https://github.com/NousResearch/hermes-agent/pull/73375), [#76880](https://github.com/NousResearch/hermes-agent/pull/76880) — @teknium1, @kshitijk4poor)\n- Readonly config loader at 29 call sites (28× cheaper reads); per-turn config deepcopies killed (telemetry gate 54×); one raw config.yaml parse per process; inter-tool delay removed ([#74322](https://github.com/NousResearch/hermes-agent/pull/74322), [#74211](https://github.com/NousResearch/hermes-agent/pull/74211), [#74228](https://github.com/NousResearch/hermes-agent/pull/74228), [#64172](https://github.com/NousResearch/hermes-agent/pull/64172) — @teknium1, @Soju06)\n- Lazy heavy-SDK imports (−8-10% import cost on top of the mcp/tool-discovery diet); streaming hot loop drops per-chunk repr() (~3× cheaper accounting); cursor/memo optimizations for per-iteration history walks ([#74204](https://github.com/NousResearch/hermes-agent/pull/74204), [#74194](https://github.com/NousResearch/hermes-agent/pull/74194), [#74221](https://github.com/NousResearch/hermes-agent/pull/74221), [#74231](https://github.com/NousResearch/hermes-agent/pull/74231) — @teknium1)\n- Cold-start ~14s GIL stall during backend init mitigated; turn flush batched into one SQLite transaction; provider-capability-gated prompt cache keys (implied for api.openai.com) ([#77814](https://github.com/NousResearch/hermes-agent/pull/77814), [#77619](https://github.com/NousResearch/hermes-agent/pull/77619), [#77609](https://github.com/NousResearch/hermes-agent/pull/77609) — @kshitijk4poor)\n- AIAgent hot-path salvage — prompt-cache copy, reasoning-timeout precompute, lazy compressor init ([#57229](https://github.com/NousResearch/hermes-agent/pull/57229) — @kshitijk4poor)\n\n### Approvals & the agent loop\n- `hermes approvals suggest` mines approval history into allowlist proposals; operator-customizable `approvals.smart_policy`; consecutive-denial circuit breaker; cross-surface approvals mode command ([#72259](https://github.com/NousResearch/hermes-agent/pull/72259), [#72186](https://github.com/NousResearch/hermes-agent/pull/72186), [#72203](https://github.com/NousResearch/hermes-agent/pull/72203), [#63517](https://github.com/NousResearch/hermes-agent/pull/63517) — @teknium1)\n- Docker/podman daemon-redirect commands require approval; session-wide runaway-loop caps for web_search + delegate_task (Claude Code-inspired) ([#71092](https://github.com/NousResearch/hermes-agent/pull/71092), [#66600](https://github.com/NousResearch/hermes-agent/pull/66600) — @teknium1)\n- Mid-turn redirects — user corrections steer the active turn, preserving in-flight work and the original prompt ([#63104](https://github.com/NousResearch/hermes-agent/pull/63104), [#72339](https://github.com/NousResearch/hermes-agent/pull/72339) — @OutThisLife)\n- Delegation: structured timeout/stall metadata + live per-child status in `/agents`; subagents can use `execute_code`; redacted child tool history exposed in `subagent_stop`; public subagent lifecycle API for plugins ([#72300](https://github.com/NousResearch/hermes-agent/pull/72300), [#69325](https://github.com/NousResearch/hermes-agent/pull/69325), [#72403](https://github.com/NousResearch/hermes-agent/pull/72403), [#72501](https://github.com/NousResearch/hermes-agent/pull/72501) — @teknium1)\n- Single-owner refactors for backend identity + failure-scoped skips, empty-content wire repair, call_id/reasoning sanitization, model-switch parsing ([#72505](https://github.com/NousResearch/hermes-agent/pull/72505), [#73071](https://github.com/NousResearch/hermes-agent/pull/73071), [#74319](https://github.com/NousResearch/hermes-agent/pull/74319), [#74229](https://github.com/NousResearch/hermes-agent/pull/74229) — @teknium1)\n- Labeled reasoning excerpt surfaced at the empty-response terminal; tool_search probe-validates blind tool_call args ([#65144](https://github.com/NousResearch/hermes-agent/pull/65144), [#59267](https://github.com/NousResearch/hermes-agent/pull/59267) — @teknium1)\n\n### Tool self-recovery wave\n- Terminal: recoverable truncation (full output spilled + pre-truncation size), cwd echoed when a command changes directory, output-pattern failure hints ([#77041](https://github.com/NousResearch/hermes-agent/pull/77041), [#77004](https://github.com/NousResearch/hermes-agent/pull/77004), [#76992](https://github.com/NousResearch/hermes-agent/pull/76992) — @teknium1)\n- Patch: already-applied edits return success no-op, whitespace-visualized no-match diagnosis, ambiguous-match locations listed ([#76998](https://github.com/NousResearch/hermes-agent/pull/76998), [#77024](https://github.com/NousResearch/hermes-agent/pull/77024), [#77001](https://github.com/NousResearch/hermes-agent/pull/77001) — @teknium1)\n- Search: zero-match probes + multi-path recovery, auto-multiline for newline patterns; read_file default limit 500 → 2000 lines; negative-result cache for read/search misses; write_file verifies on-disk content ([#77011](https://github.com/NousResearch/hermes-agent/pull/77011), [#77102](https://github.com/NousResearch/hermes-agent/pull/77102), [#76996](https://github.com/NousResearch/hermes-agent/pull/76996), [#76945](https://github.com/NousResearch/hermes-agent/pull/76945), [#77055](https://github.com/NousResearch/hermes-agent/pull/77055) — @teknium1, @kshitijk4poor)\n- execute_code recovery hints; skill_view dedup stub for unchanged re-reads; terminal/execute_code schema prose trimmed ~40%; tiered tool disclosure scales with catalog size; default iteration limit 90 → 500 ([#77106](https://github.com/NousResearch/hermes-agent/pull/77106), [#77095](https://github.com/NousResearch/hermes-agent/pull/77095), [#77023](https://github.com/NousResearch/hermes-agent/pull/77023), [#67034](https://github.com/NousResearch/hermes-agent/pull/67034), [#72176](https://github.com/NousResearch/hermes-agent/pull/72176) — @teknium1)\n\n### Providers & models\n- Vercel AI Gateway provider + Vercel Sandbox terminal backend return, modernized (SDK 0.7.2, telemetry off) ([#74518](https://github.com/NousResearch/hermes-agent/pull/74518) — @teknium1)\n- Gemini 3.1 Pro + 3.6 Flash in catalogs; Gemini salvage cluster (3.6-flash aux default, Vertex catalog, direct cost tracking); claude-opus-5 in OpenRouter + Nous Portal; deepseek-v4-flash-0731 ([#73479](https://github.com/NousResearch/hermes-agent/pull/73479), [#73516](https://github.com/NousResearch/hermes-agent/pull/73516), [#70946](https://github.com/NousResearch/hermes-agent/pull/70946), [#75501](https://github.com/NousResearch/hermes-agent/pull/75501) — @teknium1)\n- Bedrock Converse API prompt caching (cachePoint) ([#70231](https://github.com/NousResearch/hermes-agent/pull/70231) — @JoaoMarcos44)\n- OpenAI data-residency endpoints get declared transport + correct catalog; provider-aware API-server request routing; backend-acknowledged session model lock; Nous sticky routing via top-level session_id ([#74958](https://github.com/NousResearch/hermes-agent/pull/74958), [#70853](https://github.com/NousResearch/hermes-agent/pull/70853), [#70950](https://github.com/NousResearch/hermes-agent/pull/70950), [#69253](https://github.com/NousResearch/hermes-agent/pull/69253) — @victor-kyriazakos, @teknium1)\n- Model picker: curated defaults + collapsible providers + select-all; stale caches served instantly with background refresh; custom-endpoint probe capped at 1.5s; honcho OAuth device-code login ([#73172](https://github.com/NousResearch/hermes-agent/pull/73172), [#76430](https://github.com/NousResearch/hermes-agent/pull/76430), [#76922](https://github.com/NousResearch/hermes-agent/pull/76922), [#61608](https://github.com/NousResearch/hermes-agent/pull/61608) — @OutThisLife, @teknium1, @kshitijk4poor, @akattelu)\n- ACP: named custom providers in the model selector; authenticated cross-provider model choices; non-blocking startup via background MCP discovery ([#70082](https://github.com/NousResearch/hermes-agent/pull/70082), [#70404](https://github.com/NousResearch/hermes-agent/pull/70404), [#75985](https://github.com/NousResearch/hermes-agent/pull/75985) — @israellot, @amanning3390, @kshitijk4poor)\n\n### Secrets & config\n- Command-helper secret source (composes with all vaults); one-command token rotation + actionable startup errors; opt-in encrypted break-glass cache for Bitwarden; vault-injected keys scoped per profile home; orchestrator preserve_existing + profile aliasing ([#69266](https://github.com/NousResearch/hermes-agent/pull/69266), [#68605](https://github.com/NousResearch/hermes-agent/pull/68605), [#69251](https://github.com/NousResearch/hermes-agent/pull/69251), [#69250](https://github.com/NousResearch/hermes-agent/pull/69250), [#69058](https://github.com/NousResearch/hermes-agent/pull/69058) — @teknium1)\n- `${env:VAR}` SecretRef parity between config.yaml and MCP config; secret-source env vars reach stdio MCP servers ([#69267](https://github.com/NousResearch/hermes-agent/pull/69267), [#69053](https://github.com/NousResearch/hermes-agent/pull/69053) — @teknium1)\n- Canonical config loaders for behavioral reads; table-driven config migration registry; DEFAULT_CONFIG extracted to config_defaults.py; auto-migration support floor at v12 ([#74237](https://github.com/NousResearch/hermes-agent/pull/74237), [#74200](https://github.com/NousResearch/hermes-agent/pull/74200), [#74182](https://github.com/NousResearch/hermes-agent/pull/74182), [#74433](https://github.com/NousResearch/hermes-agent/pull/74433) — @teknium1)\n\n## 🌐 Gateway, Relay & Fleet\n- Session activity heartbeats, stall watchdog, and bounded compression waits — re-landed hardened after an in-window revert cycle (originally #72424 by @fangliquanflq) ([#76354](https://github.com/NousResearch/hermes-agent/pull/76354) — @teknium1)\n- SessionState consolidation (19 session-keyed dicts → one turn/conversation/persistent-scoped object); TurnContext/TurnRunner seam extraction; declarative busy_policy on CommandDef ([#74289](https://github.com/NousResearch/hermes-agent/pull/74289), [#74353](https://github.com/NousResearch/hermes-agent/pull/74353), [#74197](https://github.com/NousResearch/hermes-agent/pull/74197) — @teknium1)\n- Relay parity waves: Phase 1 (supported_ops discovery, identity fields, /handoff aliasing), Phase 2 media, Phase 3 interactive prompts, Phase 4 thread lifecycle; egress typing indicators ([#71300](https://github.com/NousResearch/hermes-agent/pull/71300), [#71363](https://github.com/NousResearch/hermes-agent/pull/71363), [#71404](https://github.com/NousResearch/hermes-agent/pull/71404), [#71624](https://github.com/NousResearch/hermes-agent/pull/71624), [#69721](https://github.com/NousResearch/hermes-agent/pull/69721) — @benbarclay)\n- HSP skill sync: personal client (M1) + org-skills client (M2) + org-skill namespace with token-gated discovery ([#66730](https://github.com/NousResearch/hermes-agent/pull/66730), [#70024](https://github.com/NousResearch/hermes-agent/pull/70024), [#70459](https://github.com/NousResearch/hermes-agent/pull/70459) — @benbarclay)\n- Buzz (Block/Nostr) platform adapter with native WebSocket inbound transport + NIP-42 auth ([#73610](https://github.com/NousResearch/hermes-agent/pull/73610), [#73761](https://github.com/NousResearch/hermes-agent/pull/73761) — @teknium1)\n- Photon: native polls, effects, clarify-as-poll, rich links (4-PR salvage) ([#73614](https://github.com/NousResearch/hermes-agent/pull/73614) — @teknium1)\n- Slack: native Block Kit clarify buttons; opt-in reaction triggers; outbound payload sanitization; thread-context lifecycle fixes ([#69318](https://github.com/NousResearch/hermes-agent/pull/69318), [#70195](https://github.com/NousResearch/hermes-agent/pull/70195), [#69317](https://github.com/NousResearch/hermes-agent/pull/69317), [#69320](https://github.com/NousResearch/hermes-agent/pull/69320) — @teknium1)\n- Discord auto-thread sessions keyed on prospective_thread_id; reply references built from ids (no fetch_message); WhatsApp configurable inbound read receipts ([#76513](https://github.com/NousResearch/hermes-agent/pull/76513), [#76875](https://github.com/NousResearch/hermes-agent/pull/76875), [#73322](https://github.com/NousResearch/hermes-agent/pull/73322) — @benbarclay, @kshitijk4poor)\n- Kanban wakes resume the creator's DM/thread session; kanban/delegate wake-ups reach api_server sessions; per-task model + thinking-depth from the board ([#72191](https://github.com/NousResearch/hermes-agent/pull/72191), [#70171](https://github.com/NousResearch/hermes-agent/pull/70171), [#69876](https://github.com/NousResearch/hermes-agent/pull/69876), [#76417](https://github.com/NousResearch/hermes-agent/pull/76417) — @teknium1, @OutThisLife)\n- Relay: Discord tool-progress routed into the auto-thread instead of the parent channel ([#77830](https://github.com/NousResearch/hermes-agent/pull/77830) — @benbarclay)\n- Outbound webhooks — push signed lifecycle events to external endpoints; simplex channel enumeration in `hermes send --list` ([#69406](https://github.com/NousResearch/hermes-agent/pull/69406), [#77110](https://github.com/NousResearch/hermes-agent/pull/77110) — @teknium1)\n\n## 🖥️ Hermes Desktop App\n\n### The platform wave\n- **Artifacts** — versioned cards, sandboxed live preview, right-rail viewer ([#72345](https://github.com/NousResearch/hermes-agent/pull/72345) — @teknium1)\n- **Plugin SDK** — Kanban as the founding desktop plugin; `ctx.download` hands the user a file; widget-app SDK (apps as state+reducer+render) with three reference apps; widget-grid layout engine + background-aware theme engine ([#61173](https://github.com/NousResearch/hermes-agent/pull/61173), [#74413](https://github.com/NousResearch/hermes-agent/pull/74413), [#68306](https://github.com/NousResearch/hermes-agent/pull/68306), [#20379](https://github.com/NousResearch/hermes-agent/pull/20379) — @OutThisLife)\n- Quick-entry window (global hotkey → any session); multiple GUI windows; floating pane placement; pane toggles anywhere + hidden header; ⌘O open-folder-as-project ([#72315](https://github.com/NousResearch/hermes-agent/pull/72315), [#68259](https://github.com/NousResearch/hermes-agent/pull/68259), [#73143](https://github.com/NousResearch/hermes-agent/pull/73143), [#75848](https://github.com/NousResearch/hermes-agent/pull/75848), [#74623](https://github.com/NousResearch/hermes-agent/pull/74623) — @teknium1, @OutThisLife)\n- SSH remote-backend connection mode; event-driven live sync replaces always-on polls; remote profile routing/sessions/pool lifecycle repaired ([#68130](https://github.com/NousResearch/hermes-agent/pull/68130), [#73673](https://github.com/NousResearch/hermes-agent/pull/73673), [#72835](https://github.com/NousResearch/hermes-agent/pull/72835) — @yoniebans, @OutThisLife)\n- Let the agent drive the shell (preview pane + pane focus) AND inspect the desktop app it's developing; find-in-page (Ctrl+F); GUI terminal copy/paste + font picker ([#69519](https://github.com/NousResearch/hermes-agent/pull/69519), [#73121](https://github.com/NousResearch/hermes-agent/pull/73121), [#72235](https://github.com/NousResearch/hermes-agent/pull/72235), [#73705](https://github.com/NousResearch/hermes-agent/pull/73705), [#76395](https://github.com/NousResearch/hermes-agent/pull/76395) — @OutThisLife, @teknium1)\n\n### Composer & UX\n- Attach files/folders/links via picker; composer chips for @ paths and pasted links; composer undo stack; double-ESC discards draft; double-Enter sends the queued turn; type-to-focus ([#74668](https://github.com/NousResearch/hermes-agent/pull/74668), [#73110](https://github.com/NousResearch/hermes-agent/pull/73110), [#72201](https://github.com/NousResearch/hermes-agent/pull/72201), [#72288](https://github.com/NousResearch/hermes-agent/pull/72288), [#74736](https://github.com/NousResearch/hermes-agent/pull/74736), [#73101](https://github.com/NousResearch/hermes-agent/pull/73101), [#68918](https://github.com/NousResearch/hermes-agent/pull/68918) — @OutThisLife)\n- 2-keypress model switching (⌘⇧M); YOLO in ⌘K with live toggle state; keyboard-first pickers; keyboard navigation for clarify choices; server-owned pins that follow you between apps ([#74545](https://github.com/NousResearch/hermes-agent/pull/74545), [#74674](https://github.com/NousResearch/hermes-agent/pull/74674), [#74602](https://github.com/NousResearch/hermes-agent/pull/74602), [#69799](https://github.com/NousResearch/hermes-agent/pull/69799), [#74234](https://github.com/NousResearch/hermes-agent/pull/74234) — @OutThisLife)\n- Grouped, live-ticking tool-activity line; improved tool call detail views; @session links resolve to clickable titles; brand icons on known-domain links; iMessage-style emoji reactions (opt-in, two-way); double-click to heart ([#72893](https://github.com/NousResearch/hermes-agent/pull/72893), [#69868](https://github.com/NousResearch/hermes-agent/pull/69868), [#71162](https://github.com/NousResearch/hermes-agent/pull/71162), [#73047](https://github.com/NousResearch/hermes-agent/pull/73047), [#74533](https://github.com/NousResearch/hermes-agent/pull/74533), [#74644](https://github.com/NousResearch/hermes-agent/pull/74644) — @OutThisLife, @teknium1)\n- Sidebar date dividers + pinned section + opt-in stale-session auto-archive; sessions stop lying about running state; credit-usage toasts; configurable attachment size limit; Cron Blueprints + Webhooks pages; searchable timezone picker ([#70822](https://github.com/NousResearch/hermes-agent/pull/70822), [#72303](https://github.com/NousResearch/hermes-agent/pull/72303), [#69828](https://github.com/NousResearch/hermes-agent/pull/69828), [#73221](https://github.com/NousResearch/hermes-agent/pull/73221), [#70066](https://github.com/NousResearch/hermes-agent/pull/70066), [#69687](https://github.com/NousResearch/hermes-agent/pull/69687), [#73505](https://github.com/NousResearch/hermes-agent/pull/73505) — @OutThisLife, @austinpickett, @Adolanium, @teknium1)\n- RFC 8252 native desktop sign-in (system browser + PKCE, no webview cookies); \"Connect to existing Hermes\" in first-run onboarding; profile-correct pairing approvals with a desktop surface ([#67920](https://github.com/NousResearch/hermes-agent/pull/67920), [#70907](https://github.com/NousResearch/hermes-agent/pull/70907), [#74446](https://github.com/NousResearch/hermes-agent/pull/74446) — @benbarclay, @OutThisLife)\n- Keep-computer-awake toggle + notch wake indicator; /battery status-bar toggle; UI zoom 90% default preset; status bar hideable ([#68140](https://github.com/NousResearch/hermes-agent/pull/68140), [#76396](https://github.com/NousResearch/hermes-agent/pull/76396), [#68860](https://github.com/NousResearch/hermes-agent/pull/68860), [#73161](https://github.com/NousResearch/hermes-agent/pull/73161), [#72960](https://github.com/NousResearch/hermes-agent/pull/72960) — @OutThisLife, @teknium1)\n\n### Desktop performance (60fps wave 2)\n- Streaming cost independent of transcript length; 60fps on real sessions (reflow-gated pins, adaptive flush); drag at 60fps with five streaming tabs; multitab streaming made fast ([#71835](https://github.com/NousResearch/hermes-agent/pull/71835), [#72504](https://github.com/NousResearch/hermes-agent/pull/72504), [#72346](https://github.com/NousResearch/hermes-agent/pull/72346), [#71780](https://github.com/NousResearch/hermes-agent/pull/71780) — @OutThisLife)\n- Hidden-pane timers paused (agents view, cron sidebar, floating pet), scroll/status loops stopped in busy sessions ([#77651](https://github.com/NousResearch/hermes-agent/pull/77651) — @kshitijk4poor); idle CPU near zero in the background; sidebar/overlay render churn killed; statusbar + transcript stop re-rendering per token/sash-drag/session-switch; ⌘K opens instantly; renderer cold start keeps shiki/mermaid off the boot path ([#75218](https://github.com/NousResearch/hermes-agent/pull/75218), [#73698](https://github.com/NousResearch/hermes-agent/pull/73698), [#72163](https://github.com/NousResearch/hermes-agent/pull/72163), [#72245](https://github.com/NousResearch/hermes-agent/pull/72245), [#72524](https://github.com/NousResearch/hermes-agent/pull/72524), [#74665](https://github.com/NousResearch/hermes-agent/pull/74665), [#73024](https://github.com/NousResearch/hermes-agent/pull/73024) — @OutThisLife)\n- State diagnostics (render + store churn counters) + a lint rule banning atom-mirrored refs so the stale-read bug class cannot return; Playwright E2E suite with visual regression diffs ([#71925](https://github.com/NousResearch/hermes-agent/pull/71925), [#71560](https://github.com/NousResearch/hermes-agent/pull/71560), [#65805](https://github.com/NousResearch/hermes-agent/pull/65805) — @OutThisLife, @teknium1, @ethernet8023)\n\n## 🖥️ CLI, TUI & Dashboard\n- `!` shell mode; `/init` AGENTS.md generation; `/diff` (staged/all/session, cross-surface); `/context` breakdown; `/focus` reduced-output view; Ctrl+S prompt stash; persistent `/goal` indicator; multi-select clarify (checkboxes) across CLI/gateway/TUI ([#72257](https://github.com/NousResearch/hermes-agent/pull/72257), [#72178](https://github.com/NousResearch/hermes-agent/pull/72178), [#72240](https://github.com/NousResearch/hermes-agent/pull/72240), [#72242](https://github.com/NousResearch/hermes-agent/pull/72242), [#72302](https://github.com/NousResearch/hermes-agent/pull/72302), [#72262](https://github.com/NousResearch/hermes-agent/pull/72262), [#72244](https://github.com/NousResearch/hermes-agent/pull/72244), [#72188](https://github.com/NousResearch/hermes-agent/pull/72188) — @teknium1, salvaging @SHL0MS, @iRonin, @gigi206 + more)\n- `hermes import-agent` — one-command migration from Claude Code / Codex CLI setups ([#72190](https://github.com/NousResearch/hermes-agent/pull/72190) — @teknium1)\n- Per-turn summary line + live token flow in the spinner; cross-surface theme SDK (one skin themes CLI, TUI, and desktop, live) ([#72246](https://github.com/NousResearch/hermes-agent/pull/72246), [#68857](https://github.com/NousResearch/hermes-agent/pull/68857) — @teknium1, @OutThisLife)\n- TUI: reach the model picker without wrecking your draft + mid-turn switching; slash menu leads with your most-used skills; attachments live in the composer; Arabic (ar) locale with RTL across desktop/dashboard/agent ([#74756](https://github.com/NousResearch/hermes-agent/pull/74756), [#75931](https://github.com/NousResearch/hermes-agent/pull/75931), [#75210](https://github.com/NousResearch/hermes-agent/pull/75210), [#70870](https://github.com/NousResearch/hermes-agent/pull/70870) — @OutThisLife)\n- `hermes -w` startup ~14s → ~1.8s; global `--version` fast path; banner update-check 6× faster; dashboard lazy-loads routes + GROUP BY session stats; session filtering tabs (Chats/Automation/All) ([#71637](https://github.com/NousResearch/hermes-agent/pull/71637), [#62096](https://github.com/NousResearch/hermes-agent/pull/62096), [#74188](https://github.com/NousResearch/hermes-agent/pull/74188), [#72294](https://github.com/NousResearch/hermes-agent/pull/72294), [#73362](https://github.com/NousResearch/hermes-agent/pull/73362), [#73865](https://github.com/NousResearch/hermes-agent/pull/73865) — @teknium1, @kshitijk4poor)\n- Runtime: Node 26 required across installers/heal/upgrade, managed Node/uv resolve before bare PATH, outdated managed trees heal to target major; brew + pip/PyPI wheel channels retired (shell installer / Docker / Nix are the supported channels) ([#76459](https://github.com/NousResearch/hermes-agent/pull/76459), [#68217](https://github.com/NousResearch/hermes-agent/pull/68217) — @ethernet8023)\n\n## 🧩 Skills, Plugins & MCP\n- **A2A v1.0** — Agent-to-Agent protocol plugin (closes #514) ([#77109](https://github.com/NousResearch/hermes-agent/pull/77109) — @teknium1)\n- Curator: surface unmanaged skills + `curator adopt`; skill-description truncation surfaced to authors; grounded-citations skill (+ fact-checking mode); simplify-code v1.1; tldraw-offline scripting skill ([#71648](https://github.com/NousResearch/hermes-agent/pull/71648), [#70519](https://github.com/NousResearch/hermes-agent/pull/70519), [#71698](https://github.com/NousResearch/hermes-agent/pull/71698), [#77104](https://github.com/NousResearch/hermes-agent/pull/77104), [#70440](https://github.com/NousResearch/hermes-agent/pull/70440), [#66896](https://github.com/NousResearch/hermes-agent/pull/66896) — @teknium1)\n- Office skills bundled: docx, xlsx, pdf + refreshed powerpoint; skills-tree debloat continues (yuanbao, segment-anything, jupyter, heartmula, audiocraft → optional-skills; claude-marketplace source removed; hub restructure absorbing themes/desktop-plugins/tui-widgets) ([#68595](https://github.com/NousResearch/hermes-agent/pull/68595), [#70452](https://github.com/NousResearch/hermes-agent/pull/70452)–[#70456](https://github.com/NousResearch/hermes-agent/pull/70456), [#73903](https://github.com/NousResearch/hermes-agent/pull/73903) — @teknium1)\n- MCP: Comfy Cloud catalog entry with curated 20-tool default; hidden-whitespace warnings in MCP config; pinecone-research optional skill ([#66112](https://github.com/NousResearch/hermes-agent/pull/66112), [#75736](https://github.com/NousResearch/hermes-agent/pull/75736), [#70512](https://github.com/NousResearch/hermes-agent/pull/70512) — @teknium1)\n- MCP lazy server startup from a fingerprint-keyed on-disk tool-schema cache — configured servers no longer all boot at session start (design from #56832) ([#77511](https://github.com/NousResearch/hermes-agent/pull/77511) — @kshitijk4poor)\n- NeMo Relay observability integration — re-landed after an in-window revert, on stable NeMo Relay 0.6 ([#67607](https://github.com/NousResearch/hermes-agent/pull/67607) — @afourniernv)\n- Gateway health & diagnostics OTLP export ([#64536](https://github.com/NousResearch/hermes-agent/pull/64536) — @victor-kyriazakos)\n\n## 🔒 Security & Reliability\n- Iron-proxy credential-injection egress firewall re-landed ([#70848](https://github.com/NousResearch/hermes-agent/pull/70848) — @teknium1)\n- DNS-pinned SSRF-safe fetches + Slack CDN allowlist; strict redaction at compaction boundaries; ReDoS eliminated in config-key redaction patterns; prose words embedding a secret keyword no longer masked ([#70193](https://github.com/NousResearch/hermes-agent/pull/70193), [#69294](https://github.com/NousResearch/hermes-agent/pull/69294), [#76083](https://github.com/NousResearch/hermes-agent/pull/76083), [#67776](https://github.com/NousResearch/hermes-agent/pull/67776) — @teknium1)\n- Tier-3 credential reads scoped (FAL/XAI/VERCEL/DAYTONA/GITHUB presence checks etc.); CVE dependency pins refreshed (cryptography, starlette, python-multipart); hindsight env file 0600; /model moved off the gateway event loop ([#75888](https://github.com/NousResearch/hermes-agent/pull/75888), [#72362](https://github.com/NousResearch/hermes-agent/pull/72362) — @teknium1)\n- Windows hardening wave: text-mode subprocess decode bug class closed repo-wide, console flashes hidden across daemons/env probes/LSP/installer paths, residual encoding gaps (MCP stdio, gateway update I/O, STT/TTS, desktop spawn) ([#70875](https://github.com/NousResearch/hermes-agent/pull/70875), [#70205](https://github.com/NousResearch/hermes-agent/pull/70205), [#70264](https://github.com/NousResearch/hermes-agent/pull/70264), [#71014](https://github.com/NousResearch/hermes-agent/pull/71014) — @teknium1, salvaging several community PRs)\n- State/session integrity: four session-state fixes (safe close tracking, flush-cursor class fix, row-retry, usage-PK healer); compact v23 FTS layout + `hermes sessions optimize` + CJK-bigram FTS; read-path split with per-thread read-only connections ([#75883](https://github.com/NousResearch/hermes-agent/pull/75883), [#65798](https://github.com/NousResearch/hermes-agent/pull/65798), [#69423](https://github.com/NousResearch/hermes-agent/pull/69423), [#73344](https://github.com/NousResearch/hermes-agent/pull/73344) — @teknium1, @kshitijk4poor)\n- OpenViking memory-provider hardening — fail closed on blocked endpoints, server verification before credentials are sent, config.yaml-first settings ([#77747](https://github.com/NousResearch/hermes-agent/pull/77747) — @kshitijk4poor)\n- Credential pool: reset-aware primary restore (stay on fallback until the rate-limit window resets) + deferred-refresh locking fixes; FTS UPDATE triggers narrowed with fail-closed CJK migration ([#77631](https://github.com/NousResearch/hermes-agent/pull/77631), [#77628](https://github.com/NousResearch/hermes-agent/pull/77628) — @kshitijk4poor)\n- Config-driven memory allocator trim with telemetry; holographic memory vectors stored float32; loop-invariant HRR encodes hoisted ([#76905](https://github.com/NousResearch/hermes-agent/pull/76905), [#76917](https://github.com/NousResearch/hermes-agent/pull/76917), [#76881](https://github.com/NousResearch/hermes-agent/pull/76881) — @kshitijk4poor)\n\n## 🐛 Notable Bug Fixes\n- Voice: full-duplex interruption during generation AND playback; whole-turn desktop speech; auto-TTS delivery gaps ([#74223](https://github.com/NousResearch/hermes-agent/pull/74223), [#69936](https://github.com/NousResearch/hermes-agent/pull/69936), [#73508](https://github.com/NousResearch/hermes-agent/pull/73508) — @teknium1)\n- Desktop: Stop parks the queue instead of firing the next queued prompt; branch-in-new-chat restart loss; false remote-gateway reauthentication; cross-session composer leaks ([#68725](https://github.com/NousResearch/hermes-agent/pull/68725), [#71960](https://github.com/NousResearch/hermes-agent/pull/71960), [#68250](https://github.com/NousResearch/hermes-agent/pull/68250), [#70986](https://github.com/NousResearch/hermes-agent/pull/70986) — @SHL0MS, @alelpoan, @helix4u, @OutThisLife)\n- Gateway: session lists scoped before limiting; relay-backed home delivery after restart; timeline display events persisted ([#65509](https://github.com/NousResearch/hermes-agent/pull/65509), [#70102](https://github.com/NousResearch/hermes-agent/pull/70102), [#69771](https://github.com/NousResearch/hermes-agent/pull/69771) — @GodsBoy, @victor-kyriazakos, @ethernet8023)\n- Agent: context-length fallback logging + batch trajectory durability; Codex OAuth context windows revalidated against the live catalog ([#76027](https://github.com/NousResearch/hermes-agent/pull/76027), [#68554](https://github.com/NousResearch/hermes-agent/pull/68554) — @kshitijk4poor, @teknium1)\n- ...plus roughly 770 more `fix:` PRs across every subsystem this window.\n\n## 👥 Contributors\n\n**647 contributors** shipped this release (commit authors, co-authors, and salvaged-PR credits).\n\n### Core\n@teknium1, @OutThisLife (desktop, voice, perf), @kshitijk4poor (perf, caching, salvage), @ethernet8023 (runtime, E2E, desktop), @benbarclay (relay, HSP, auth)\n\n### All Contributors (alphabetical)\n@02356abc, @0301chris, @0xAlcibiades, @0xDevNinja, @0xLeathery, @0xprincess, @0xr00tf3rr3t, @100yenadmin,\n@2001Y, @3ssiri, @55nx954gn6-debug, @686f6c61, @87degrees, @aaronlab, @abundantbeing, @Adolanium,\n@adriansotomora, @adurham, @afourniernv, @afurm, @AgenticSpark, @ahmadashfq, @AhmetArif0, @ai-ag2026,\n@AIalliAI, @aider4ryder, @airclear, @ajzrva-sys, @akattelu, @AKAZIK-py, @akb4q, @akshan-main, @AlanBurningsuit,\n@alelpoan, @AlexFucuson9, @AlexxRussell, @AllardQuek, @alt-glitch, @aman-merchant, @amanning3390, @amathxbt,\n@aml1973, @amoreno16003, @AndrewMoryakov, @andrexibiza, @andynguyendk, @andyylin, @aneym, @angelos,\n@aniruddhaadak80, @AnnasMazhar, @annguyenNous, @anoopmehendale-cue, @AnthonyFrancis, @arcabotai, @ArcherQAQ,\n@Ares4Tech, @arimu1, @arnoldfrancisca, @asimons81, @asorry75, @AtakanGs, @ATran28, @austinpickett,\n@Automata-intelligentsia, @awain7, @aweiker, @aydnOktay, @ayushere, @b, @baau, @baauzi, @baenregod,\n@bakhtiersizhaev, @Baophan00, @baoyu0, @Bartok9, @basilalshukaili, @BB-light, @bbopen, @Beandon13,\n@beardedeagle, @bedirhancode, @benbarclay, @benegessarit, @benjamin2026-dot, @bennybuoy, @BenSheridanEdwards,\n@BKStock, @BlackishGreen33, @bnikanjam, @bounce12340, @Bounty13, @bpross, @briandevans, @bricelb, @brunopirz,\n@bryanneva, @byshubham, @camaleonidas, @canorionen, @carbongotfound, @carljborg, @carlotestor, @carrion256,\n@caseyanthony, @cat-thats-fat, @Cdddo, @ceverson70, @chancelu, @chaos-xxl, @CharlesMcquade, @chazmaniandinkle,\n@chefboyrdave21, @chelsealong, @Christopher-Schulze, @chuenchen309, @ciabata-git, @cifangyiquan, @cipry0200,\n@ckaznocha, @ckorhonen, @CleanDev-Fix, @CocaKova, @coffee-the-dev, @colingreig, @Commander, @connorblack,\n@CoreyNoDream, @Cossackx, @crayfish-ai, @criptogus, @ctaylor86, @cucurigoo, @cypres0099, @d31tcjg, @Da7-Tech,\n@damiankluk, @danielblankhh, @DanielMaly, @Dannou, @davesecops, @davidgut1982, @DavidMetcalfe, @davidrobertson,\n@ddifa86, @ddy4633, @deacon-botdoctor, @deaneeth, @deepjia, @deltaahead, @DESXIE, @devsart95, @dhruvkej9,\n@dhruvraajeev, @DI404N, @diegomarino, @diffen77, @dirtyren, @dnth, @DocAwk, @dolphin-creator, @Dolverin,\n@dombejar, @doncazper, @dongjiang1989, @DonutsDelivery, @dorukardahan, @Doud-FR, @drafish, @Drexuxux,\n@drleadflow, @dsitmilis, @dskwe, @dso2ng, @dstkwll, @Dusk1e, @dyreckt, @eagle-nyp, @Eapwrk, @eason2026,\n@eazye19, @egilewski, @ehz0ah, @elcocoel, @eliemada, @eloklam, @elphamale, @embwl0x, @emozilla, @Enough1122,\n@enzo2, @erick713006, @ErnestHysa, @Esther-Zhu023, @ethernet8023, @evgyur, @f-trycua, @faikwo, @falkoro,\n@fangliquanflq, @fazerluga-creator, @fcavalcantirj, @ferminquant, @fesalfayed, @FixItFoundry, @flag0x369,\n@floatingrain, @FlorianVal, @flyingdoubleG, @FraserHum, @frizikk, @frohsinnllc, @Frowtek, @FvanW, @fyzanshaik,\n@ganzziani, @gercamjr, @giggling-ginger, @gigi206, @giladbau, @glesperance, @gnanam1990, @GodsBoy, @golldyck,\n@gonzalofrancoceballos, @gshall, @gumclaw, @Guoen0, @Gurud25, @gvago, @HaisamAbbas, @hansai-art, @hanyu1212,\n@happy5318, @haran2001, @hariNEzuMI928, @harjothkhara, @harrisonmedmedmetrics, @Harshkamdar67, @hdd69,\n@heathley, @helix4u, @HeLLGURD, @hellofrommorgan, @hereicq, @hermz580, @HexLab98, @hinablue, @HOYALIM, @hrnbld,\n@huntsyea, @iamwongeeeee, @ianks, @ibaldr89, @Icather, @ijevin, @ildunari, @Imgaojp, @imgyf, @immuhammadfurqan,\n@iniak, @iRonin, @isheng-eqi, @iso2kx, @israellot, @itsflownium, @IvanMiao, @iveywest, @izumi0uu, @Jaaneek,\n@JabberELF, @jackjin1997, @jakelongvu-bot, @Janig88, @jasoisjaso, @jbbottoms, @jeeaay, @jeeves-assistant,\n@jeff-mettel, @JeffStone69, @JeliTron, @jethac, @jfmusa2024-cyber, @Jiahui-Gu, @jinglun010-cpu, @JoaoMarcos44,\n@joelbrilliant, @John-Lussier, @johnkattenhorn, @Johnny-xuan, @johnnykor82, @joncaldwell90, @JonthanaHanh,\n@jordanhubbard, @JorkeyLiu, @jquesnelle, @jrfbch, @juanmartitegui, @Julientalbot, @juniperbevensee, @justemu,\n@kael-odin, @kaishi00, @kaiyisg, @kamonspecial, @kandotrun, @KCAYAAI, @keepConcentration, @kelsia14, @Kenmege,\n@kerpopule, @Kev-fs, @Kewe63, @kharitonov-ivan, @Kingdomwarrior23, @kingrubic, @Kinkoolino-Hermes, @kjames2001,\n@knoal, @kohoj, @Kolektori, @konsisumer, @koshaji, @kronexoi, @kshitijk4poor, @kuangmi-bit, @kudi88, @kylezh,\n@kyssta-exe, @Kyzcreig, @lanyusea, @LauraGPT, @LavyaTandel, @LeonSGP43, @leoprodz, @LevSky22, @lEWFkRAD,\n@lewis4x4, @lexgenius, @LFDMcore, @LiangYang666, @lidises, @light-merlin-dark, @lihengming, @Linux2010,\n@LionGateOS, @liuhao1024, @liusencomic-cyber, @ljsdut, @lkevincc0, @LLQWQ, @locker95, @logical-and,\n@LordNikon1983, @lost9999, @Love-JourneY, @LunarNexus, @luxiaolu4827, @lxman, @m4r13y, @MaartenDMT,\n@MacroAnarchy, @maff-t2b, @MahdiHedhli, @mahdiwafy, @malaiwah, @mannnrachman, @mapu-og, @markoub, @matarbot,\n@materemias, @matt-strawbridge, @mattezell, @MattMaximo, @mattmillerai, @mattshapsss, @MaxFreedomPollard,\n@maxmilian, @McHermes, @mehmetkr-31, @menhguin, @metamon-p, @mijanx, @MLcogTech, @moeadham, @mollusk,\n@monerostar, @MorAlekss, @morolab, @motoblurr, @MrAbsaroka, @mrzlab630, @ms-alan, @muctobi, @MustafaK99,\n@mwbrooks, @myk0la-b, @mzkarami, @namredips, @nanami7777777, @nanckh, @natebransc, @nateEc, @navahc09,\n@Ne0teric, @necoweb3, @nftpoetrist, @nickkarhan, @Ninso112, @nkreadly07, @nnnet, @NPFernando, @nrmjeremy,\n@nu476, @null-runner, @nullptr0807, @NYTEMODEONLY, @obelisk-complex, @OfficialDelta, @okalentiev,\n@oliviaaaa7788, @OmarB97, @omid3098, @ooiuuii, @oppenheimor, @oreoluwa, @ousiaresearch, @OutThisLife,\n@panDing19, @paralegalia, @patp, @PavelTajdus, @peacockesq, @Pebrd, @peterw, @phantom-instruction-set,\n@pierrenode, @PINKIIILQWQ, @piyushbag, @plainOldCode, @pnascimento9596, @pooyan6, @pprism13, @praneshnikhar,\n@PRATHAMESH75, @Prontsevich, @quantumbyte1617, @QuarkAssistant, @Que0x, @Qwinty, @rayjerrywoo, @rayjun,\n@Reaper-Forge, @RedClaus, @redsol-llc, @reinbeumer, @RelaxJonh, @RemyFevry, @replygirl, @rerdi92, @rhylryan21,\n@RichardHojunJang, @richkapp, @rkfshakti, @rlaope, @rob-coco, @rob-maron, @robbyczgw-cla, @robgfl45,\n@robzolkos, @rod-nxtlevel, @rodboev, @Roger--Han, @rsayar, @rudironsoni, @ruizanthony, @rungmc357, @s00rz,\n@s905060, @Sahil-SS9, @sakhnenkoff, @salimhamed, @samrusani, @sbe27, @ScaleLeanChris, @schattenan, @sealca,\n@seamusmore, @sebaorrego-koronet, @seppegadeyne, @sergioperezcheco, @sethmills21, @sg-shag, @sgtworkman,\n@shady2k, @shannonsands, @shellybotmoyer, @sherman-yang, @shivasymbl, @Shizoqua, @SHL0MS, @simonmmafs,\n@skyer-flyyy, @Skywind5487, @sl4m3, @Slopez2023, @SmallNew2003, @smfworks, @Soju06, @Solitud1nem,\n@solyanviktor-star, @somewheresy, @Sora-bluesky, @sowork-skills, @soynchux, @sparkeros, @spfcraze,\n@spiky02plateau, @spro-work, @SquabbyZ, @srojk34, @StanleyStetson, @StartupBros-com, @StellarisW,\n@stephenschoettler, @Stoltemberg, @stremtec, @Studio729, @stwith, @subhoya, @sunwz1115, @suparious,\n@supplefrog, @sycamoregroupltd, @szzhoujiarui, @tachyon-r, @Tamaz-sujashvili, @tandixit95, @tank321, @tavva,\n@tcconnally, @temalo, @th3wingman, @the3asic, @TheAngryPit, @TheEpTic, @theone139344, @TheSmokeDev,\n@thirstycrow, @tianma-if, @Tianworld, @tinetwork, @to-na, @tom-channel, @tomqiaozc, @toomij99, @trac3r00,\n@Tranquil-Flow, @Trantor-develops, @trevorgordon981, @trevornk, @trippyogi, @trymhaak, @tsuk1nose, @tusharui,\n@TutkuEroglu, @tw0316, @twe-cloud, @tymrtn, @UltraInstinct0x, @umi008, @unixwzrd, @upicat, @vaibhavjnf, @valda,\n@Vansh5632, @Variable85, @vb3, @VerbalChainsaw, @vexclawx31, @victor-kyriazakos, @vigilancetech-com,\n@virtuadex, @Vissirexa, @VittoriaLanzo, @VIVAAN-DHAWAN, @wangyunyou, @waroffchange, @wayne1992127, @web3blind,\n@webtecnica, @WeiYusc, @wen0531, @wernerhp, @wesleysimplicio, @westkite1201, @wgd753, @wgu9,\n@wjj1872744570-source, @wjq990112, @WojtekMR3, @WOLIKIMCHENG, @Wpnx330, @wreed4, @wuli666, @WXBR, @wz-heng,\n@x7peeps, @xcompass, @xd-Neji, @xenodmc, @Xipong, @xrazai, @Xue-1997, @xxxigm, @y0shua1ee, @yaleman,\n@yemi-lagosinternationalmarket, @ygd58, @yingliang-zhang, @yinkev, @yitang, @YLChen-007, @yoniebans,\n@youzi-1122, @ypwcharles, @yuexiongHNU, @yungchentang, @yuzilongleif-collab, @yyzquwu, @z23, @ZachariahChu,\n@zakhounet, @zapabob, @Zavianx, @zehuaw1, @zengzheqing, @Zeraphim, @zgzczzw, @zhangyang-crazy-one,\n@Zhekinmaksim, @Zioywishing, @zmlgit, @zombopanda, @ZundamonnoVRChatkaisetu\nAlso: @kyssta-exe 25470058+kyssta-exe.\n\nThank you to every one of the 647 people who contributed code, co-authored fixes, filed the ~1,200 issues this release closes, and had their PRs salvaged into main. Hermes ships this fast because of you.\n\n---\n\n**Full Changelog**: [v2026.7.20...v2026.8.3](https://github.com/NousResearch/hermes-agent/compare/v2026.7.20...v2026.8.3)\n",
        "highlights": [
          "✨ Highlights",
          "Talk to Hermes — streaming, conversational voice with barge-in — Voice mode used to mean: speak, wait for the whole reply to generate, then listen to one long audio file. Now Hermes speaks clause-by-clause as the response streams, you can i",
          "Wake words and hands-free control — Say your own open-vocabulary wake phrase (\"hey Hermes\", or anything you pick) and Hermes starts listening — detection runs on-device, so no audio leaves your machine while it waits. Multi-profile voice ro",
          "Voice on every platform — Send a voice note to Hermes on WhatsApp, Feishu, DingTalk, LINE, QQ, Photon, or Weixin and it's transcribed and answered; auto-TTS replies are delivered platform-aware (opus where platforms want opus, captions atta",
          "Research you can trust — grounded citations with fact-checking — The new grounded-citations skill makes Hermes produce research where every claim is backed by a verifiable source: quotes are matched against the actual page text (not halluci",
          "Outbound webhooks — Hermes pushes events to your systems — Until now, integrating with Hermes meant polling or listening on a platform. Now Hermes pushes signed lifecycle events (session activity, turn completions, tool events) to any HTTP "
        ],
        "prerelease": false
      },
      "upcoming": [],
      "communityDiscussions": []
    },
    "runs": [
      {
        "collectedAt": "2026-08-13T12:26:38.318Z",
        "since": "2026-08-12T12:26:38.318Z",
        "observedCount": 100,
        "changedCount": 100
      },
      {
        "collectedAt": "2026-08-13T13:48:00.446149Z",
        "since": "2026-08-12T13:48:00.446149Z",
        "observedCount": 100,
        "changedCount": 100
      },
      {
        "collectedAt": "2026-08-13T16:19:22.035158Z",
        "since": "2026-08-12T16:19:22.035158Z",
        "observedCount": 100,
        "changedCount": 100
      },
      {
        "collectedAt": "2026-08-13T17:43:20.785491Z",
        "since": "2026-08-12T17:43:20.785491Z",
        "observedCount": 100,
        "changedCount": 100
      },
      {
        "collectedAt": "2026-08-13T17:47:07.884300Z",
        "since": "2026-08-12T17:47:07.884300Z",
        "observedCount": 100,
        "changedCount": 20
      },
      {
        "collectedAt": "2026-08-13T18:01:55.420671Z",
        "since": "2026-08-12T18:01:55.420671Z",
        "observedCount": 100,
        "changedCount": 44
      }
    ],
    "signals": [
      {
        "id": "github:NousResearch/hermes-agent:issue:2513",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Custom providers lack context length auto-detection when saved via /model command",
        "text": "### Bug Description **Summary:** When users save a custom provider using the `/model` command without specifying a context length, the system doesn't automatically detect and display what context length was actually used. This leaves users uncertain about whether their custom provider has a short or long context window, which is critical for prompt caching behavior. **Impact:** - Users cannot easily verify if their custom provider has adequate context window - Prompt caching may be less effective than expected - No visual feedback distinguishes between auto-detected and default context lengths ### Steps to Reproduce run hermes model select a custom provider do not enter a context length ### Expected Behavior After saving a custom provider without a context length, the system should: 1. Attempt to auto-detect the context length based on the model name 2. Display the detected context length to the user 3. Fall back to a sensible default (128K tokens) if detection fails 4. Indicate visually when the default was used vs. when auto-detection succeeded ### Actual Behavior Users who save a custom provider without specifying context length have no feedback on what context length is being used. This can lead to unexpected prompt caching behavior and higher token costs. ### Affected Component CLI (interactive chat) ### Messaging Platform (if gateway-related) _No response_ ### Operating System Debian 13.4 ### Python Version 3.13.3 ### Hermes Version 0.4 ### Relevant Logs / Traceback ```shell ``` ### Root Cause Analysis (optional) _No response_ ### Proposed Fix (optional) _No response_ ### Are you willing to submit a PR for this? - [x] I'd like to fix this myself and submit a PR",
        "url": "https://github.com/NousResearch/hermes-agent/issues/2513",
        "createdAt": "2026-03-22T16:00:19Z",
        "updatedAt": "2026-08-13T17:52:22Z",
        "timestamp": "2026-08-13T17:52:22Z",
        "metrics": {
          "reactions": 1,
          "comments": 5
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "area/config",
          "P3",
          "sweeper:risk-compatibility"
        ],
        "author": "InB4DevOps",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:29482",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Langfuse plugin shows reasoning: None for reasoning_content models (DeepSeek/Qwen/LM Studio convention)",
        "text": "## Bug Description The bundled `observability/langfuse` plugin's `_serialize_assistant_message` reads only the top-level `reasoning` attribute: ```python # plugins/observability/langfuse/__init__.py (HEAD: 31a010010) def _serialize_assistant_message(message: Any) -> dict[str, Any]: return { \"content\": _safe_value(getattr(message, \"content\", None)), \"reasoning\": _safe_value(getattr(message, \"reasoning\", None)), \"tool_calls\": _serialize_tool_calls(getattr(message, \"tool_calls\", None)), } ``` Providers that emit chain-of-thought via `reasoning_content` (LM Studio, Moonshot, Qwen3 thinking models, DeepSeek) get silently dropped. The transport correctly stores them on `NormalizedResponse.reasoning_content` (a property reading from `provider_data[\"reasoning_content\"]`, see `transports/types.py:115`), but the plugin never reads that field. Every `LLM call N` observation shows `reasoning: None` even though Hermes captures the reasoning fine (visible in CLI/Telegram via `last_reasoning` in `run_agent.py`). Independent of #26320, which fixed the missing `assistant_message` kwarg on the hook payload. The serialization gap is still in upstream HEAD. ## Steps to Reproduce 1. Hermes with LM Studio backend on a Qwen3 thinking model (e.g. `qwen3.6-35b-a3b-uncensored`), `chat_template_kwargs.enable_thinking: true`. 2. Enable `observability/langfuse` + set `HERMES_LANGFUSE_*` env vars. 3. Run any turn that triggers reasoning. 4. Inspect the `LLM call N` generation in Langfuse. Direct LM Studio response confirming `reasoning_content` is the field actually used: ``` Message keys: ['content', 'reasoning_content', 'role', 'tool_calls'] [content] (110 chars): \\n\\nThinking: Adding two to two ... Answer: 4 [reasoning_content] (796 chars): Here's a thinking process:\\n\\n1. **Analyze User Input:** ... ``` ## Expected Behavior `output.reasoning` on each generation observation contains the chain-of-thought. The same text is already extracted by `_extract_reasoning` (`run_agent.py`) and `extract_content_or_reasoning` (`agent/auxiliary_client.py:4404`) for other consumers; Langfuse should get it too. ## Actual Behavior `output.reasoning` is `None` on every generation observation. Live trace `c2acb87a9ac6...` from my deployment, generation `LLM call 12`: ``` content: '[573 chars]' # placeholder; fixed by #26320 in HEAD reasoning: None ``` ## Affected Component Other (plugin: `observability/langfuse`) ## OS / Python / Hermes Version macOS 26.4.1, Python 3.11.15, Hermes v0.13.0. Verified bug is still present on `main` @ `31a010010`. ## Root Cause Analysis In `chat_completions.py:563-583`, the transport splits reasoning across two destinations: - top-level `NormalizedResponse.reasoning` ← `msg.reasoning` (OpenAI convention) - `provider_data[\"reasoning_content\"]` ← `msg.reasoning_content` (DeepSeek convention) `NormalizedResponse.reasoning_content` exposes the second via a property (`transports/types.py:115`). The Langfuse plugin's serializer only reads the first. ## Proposed Fix Extend `_serialize_assistant_message` to walk `reasoning`, `reasoning_content`, `reasoning_details` (deduplicating). Same precedence as `extract_content_or_reasoning` in `agent/auxiliary_client.py`. No inline `<think>` regex fallback in the plugin; that's owned by `_build_assistant_message`. I can put up a PR.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/29482",
        "createdAt": "2026-05-20T21:07:18Z",
        "updatedAt": "2026-08-13T17:40:18Z",
        "timestamp": "2026-08-13T17:40:18Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "P3"
        ],
        "author": "swanhtet1992",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:43121",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Desktop] Cron sessions render only user prompt — tool calls and LLM response invisible until gateway restart",
        "text": "**Severity: Critical — affects 100% of Desktop cron sessions on all platforms** ## Symptom Clicking a completed cron job session in the Desktop sidebar or cron tab shows **only the user prompt** — the message that the scheduler auto-submits to start the job. Everything below it — tool calls, tool results, reasoning text, and the LLM's final response — is **invisible until a full gateway restart**. This is not a cosmetic issue. It breaks the core \"schedule, review, act\" workflow that cron automation is built for. ## Steps to Reproduce 1. Create or trigger a cron job with tool use (e.g. a health check running `terminal` commands) 2. Wait for the job to complete — the session appears in the sidebar once `ended_at` is set 3. Click the session 4. **Observe:** only the auto-submitted user prompt renders — no tool calls, tool results, reasoning, or final LLM response **Expected:** Full transcript — user prompt, tool call cards with results, reasoning blocks, final response **Actual:** Only user prompt visible. Everything else requires a gateway restart to appear. ## Why This Matters Cron is Hermes' primary autonomous agent capability. Users write prompts, schedule automated runs, and expect to inspect results with one click — tool call chains, intermediate reasoning, and final outputs. Instead they get a blank session and a restart ritual. For a platform positioning cron as a first-class Desktop feature, this is a critical UX failure across **five stacked layers of bugs**, each independently capable of losing data, and together making cron sessions completely unviewable. ## Root Cause Analysis Five independent bugs form a pipeline where each alone loses data, and together they erase everything: **Bug 1 — macOS WAL race (`hermes_state.py`):** SQLite WAL mode with `synchronous=NORMAL` (the default on macOS) delays committed page flushes to disk. A concurrent reader opening a second connection sees a stale DB snapshot missing the most recent writes. Fixed with `PRAGMA synchronous=FULL`. **Bug 2 — Premature agent shutdown (`cron/scheduler.py`):** `pool.shutdown(wait=False, cancel_futures=True)` returns immediately without waiting for the agent thread to finish its current `add_message` call. The subsequent `end_session()` fires while the agent is still writing, producing a session with `ended_at` set but the last several tool-call and response messages irrecoverably missing. Fixed with `pool.shutdown(wait=True)`. **Bug 3 — Silent message deletion (`tui_gateway/server.py` ~line 3191):** The `session.resume` handler calls `_history_to_messages` to build the message payload sent to the frontend. This function contained a guard — `if not content_text.strip(): continue` — that silently skipped **every assistant message with `tool_calls` but empty `content` text**. This is the exact shape of every tool-invocation frame the LLM produces. The frontend received a payload missing 30–50% of the conversation, including the entire tool call chain that produced the final result, with no error or warning. Fixed by removing the `continue` and preserving tool-call-only assistant frames. **Bug 4 — Session ID namespace collision (`cron/scheduler.py` + `tui_gateway/server.py`):** The cron agent's TUI gateway callbacks emitted WebSocket events tagged with the stored session ID (`cron_xxx`). But the Desktop frontend tracks the gateway's runtime session ID (`abcd1234`) — a completely separate namespace assigned when `session.resume` creates a live gateway session. All streaming events — `tool.start`, `tool.complete`, `message.delta`, `reasoning.delta` — arrived at the frontend with the wrong `session_id`, updated the wrong state key, and were silently discarded without ever rendering. Fixed by a shared `_cron_runtime_id_map` dict populated on `session.resume` that the cron agent reads at callback time, plus a client-side fallback translation in the event handler. **Bug 5 — Stale cache wins over fresh data (`use-session-actions.ts`):** The `resumeSession` function calls `getSessionMessages()` (REST API) for an initial snapshot, then `requestGateway('session.resume')` (WebSocket RPC) for the authoritative transcript. If the REST call returns during the cron agent's execution — when only the user prompt is in the DB — that partial array is pinned as `localSnapshot`. The subsequent full response from the gateway is discarded because `localSnapshot.length > 0` always wins the preference ternary. The stale partial array is set and never replaced. Fixed by changing `> 0` to `> resumedMessages.length` — preferring whichever source has MORE messages. ## Impact Every cron user on every platform is affected. The bug is not intermittent — it hits 100% of cron sessions. There is no known workaround other than restarting the gateway after every cron job, which also discards any live streaming events that may have been emitted mid-run. ## Data Is NOT Lost The bug is purely in the rendering pipeline — the data is safe. A gateway restart or direct sqlite3 query shows the complete session data intact. ## Platform - **macOS:** Confirmed (primary test environment) - **Linux:** Affected — `synchronous=FULL` and thread pool fixes are platform-agnostic; WAL behavior difference may reduce severity but not eliminate it - **Windows:** Affected — all changed code paths are cross-platform; `scripts/check-windows-footguns.py` passes clean ## Workaround Restart the Hermes gateway (`Cmd+Q` → relaunch Desktop app). After restart, the session renders correctly on click because `getSessionMessages` loads from the now-complete DB, and no live streaming events race against the initial paint. ## Fix The companion PR fixing all five bugs is open alongside this issue. See the PR description for a detailed breakdown of each fix. Fixes applied in: `hermes_state.py`, `cron/scheduler.py`, `tui_gateway/server.py`, `use-session-actions.ts`, `use-message-stream.ts`, `desktop-controller.tsx`, `chat-messages.ts`",
        "url": "https://github.com/NousResearch/hermes-agent/issues/43121",
        "timestamp": "2026-08-13T12:05:45Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "comp/cron",
          "P3",
          "area/sessions"
        ],
        "author": "ValentinSergief",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:4335",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Feature Request: Cross-platform session context sharing (CLI ↔ Telegram)",
        "text": "## Summary Hermes Agent supports multiple messaging platforms (CLI, Telegram, Discord, etc.) through the gateway architecture. Each platform maintains isolated session stores, meaning an agent handling Telegram messages has no knowledge of conversations the user had via CLI, and vice versa. This feature request proposes a mechanism to share conversation context across platforms so users experience a continuous, unified agent relationship regardless of how they reach Hermes. --- ## Problem Statement **Current behavior:** - CLI sessions are stored locally in `~/.hermes/sessions/` and can be searched via `session_search` - Telegram sessions are stored in the gateway's session store (`telegram:chat_id` keys) - Honcho is configured and active, but sessions from each platform are written to separate Honcho session IDs - A user messaging `@Philgram_bot` on Telegram asking \"what did we discuss on my PC?\" receives no useful answer because the Telegram agent has no access to CLI session history **User impact:** - Users who switch between Telegram and CLI mid-conversation lose continuity - Context built up in one platform doesn't transfer to another - Cross-platform workflows (e.g., \"ask Hermes on Telegram to start a task, continue debugging in CLI\") require the user to re-explain everything --- ## Proposed Solution ### Approach 1: Shared Honcho session layer (preferred) Each platform session (`cli`, `telegram`, `discord`) writes to the **same Honcho session** rather than separate ones. This requires: 1. Deriving a **common user identifier** across platforms (e.g., from `honcho_config.peer_name` or `TELEGRAM_HOME_CHANNEL`) 2. Using a **shared session key pattern** like `agent:main:user:{user_id}` instead of `agent:main:{platform}:dm:{chat_id}` 3. Modifying `build_session_key()` in `gateway/session.py` to accept an optional user-level override 4. Honcho context prefetch on each message already supports this — the infrastructure is in place, it just needs the key generation logic to unify **Benefits:** - No per-message overhead — context already prefetched via Honcho's daemon thread pattern - No duplication of conversation history - Works with existing Honcho profile/memory layer ### Approach 2: Context injection hook A gateway hook that, on each incoming Telegram message, fetches recent CLI session history via FTS5 search and injects it into the Telegram agent's context prompt. **Benefits:** - Doesn't require changing session key architecture - Can be implemented as a custom hook without modifying core gateway code **Drawbacks:** - Adds latency on every message (Honcho fetch + LLM context window) - Duplicates history into each platform's session (storage bloat) - Less elegant --- ## Expected Behavior After implementation, the following should work: ``` User (CLI): \"Hermes, update my Nexus dashboard\" Hermes (CLI): [does the work] User (Telegram): \"@bot what was the last thing I asked Hermes to do?\" Hermes (Telegram): \"You asked Hermes to update your Nexus dashboard...\" ``` --- ## Technical Context - Hermes Agent repo: `NousResearch/hermes-agent` - Honcho integration: `honcho_integration/` (client.py, session.py) - Gateway session management: `gateway/session.py` (`build_session_key()`, `SessionStore`) - Gateway message handling: `gateway/run.py` (`_handle_message_with_agent()`) - Existing Honcho config at `~/.hermes/honcho.json` or `~/.honcho/config.json` --- ## Prior Art - Honcho already supports cross-session context via `session.context()` and `peer.chat()` - The CLI session search tool (`session_search`) already demonstrates FTS5 cross-session recall - Gateway hooks architecture (`gateway/hooks.py`) provides a plugin point for custom behavior --- ## Questions for Maintainers 1. Is there an existing design for cross-platform session unification? 2. Would a shared session key approach be acceptable architecturally? 3. Is there interest in a proof-of-concept PR implementing Approach 2 as a gateway hook?",
        "url": "https://github.com/NousResearch/hermes-agent/issues/4335",
        "createdAt": "2026-03-31T20:10:53Z",
        "updatedAt": "2026-08-13T13:38:54Z",
        "timestamp": "2026-08-13T13:38:54Z",
        "metrics": {
          "reactions": 3,
          "comments": 14
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/gateway",
          "P3",
          "needs-decision",
          "sweeper:risk-session-state",
          "area/sessions"
        ],
        "author": "Logi4k",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:4438",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Feature]: Rich Spreadsheet Skill (xlsx / csv)",
        "text": "## Current behavior hermes-agent can read and write files with its terminal tool, but Excel/spreadsheet handling relies on raw Python (`openpyxl`, `pandas`) with no structured abstraction. The agent has to figure out the library, the sheet structure, and error handling from scratch every time. This leads to inconsistent results — formatting is lost on round-trips, the wrong library is chosen for the task, and CSV vs. XLSX differences are handled ad hoc. ## Desired behavior A dedicated `spreadsheet` skill in `~/.hermes/skills/` that gives the agent a consistent, high-level vocabulary for spreadsheet operations: ``` read_sheet(path, sheet=None) → structured JSON rows write_sheet(path, rows, sheet=None) → creates/overwrites sheet apply_formula(path, cell, formula) → sets a formula and recalculates pivot(path, rows, cols, values, agg) → builds a pivot table chart(path, type, data_range, title) → embeds a chart into the workbook diff(path_a, path_b) → highlights cell-level changes ``` A CSV fallback path should handle plain CSVs without requiring Excel dependencies. This mirrors the pattern used by the existing `pdf` and `browser` skills. ## Implementation sketch - **Core wrapper** around `openpyxl` (read/write `.xlsx` with formatting preservation) + `xlsxwriter` (chart embedding, new workbook creation) + `pandas` (pivot tables, aggregation, CSV fallback). - **Skill manifest** registers the above functions as tool-callable operations, matching the existing skill infrastructure. - **Formatting preservation**: `read_sheet` → `write_sheet` round-trips should not destroy cell styles, merged cells, or conditional formatting. This is the main pain point with naive `pandas` usage. - **Error handling**: clear messages for corrupt files, missing sheets, formula errors, and unsupported chart types. - **Dependencies**: `openpyxl`, `xlsxwriter`, `pandas` added as optional extras (`pip install hermes-agent[spreadsheet]`). ## Why this matters Spreadsheets are the universal data format — finance, HR, operations, research. No other mainstream agent framework ships a first-class spreadsheet skill. This would be a strong differentiator and covers one of the most common real-world automation use cases. ## Related issues - #4381 — `skills create` / `skills edit` configurable target directory (the spreadsheet skill would benefit from the `--dir` flag and `default_write_dir` config proposed there)",
        "url": "https://github.com/NousResearch/hermes-agent/issues/4438",
        "createdAt": "2026-04-01T11:09:18Z",
        "updatedAt": "2026-08-13T17:57:40Z",
        "timestamp": "2026-08-13T17:57:40Z",
        "metrics": {
          "reactions": 0,
          "comments": 7
        },
        "labels": [
          "type/feature",
          "tool/skills",
          "P3"
        ],
        "author": "raphael-solace",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:4667",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Feature]: Auto-discover project-local skills from working directory",
        "text": "### Problem or Use Case When working in a project that has skills installed via `npx skills experimental_install` (Vercel Labs' [agent skills CLI](https://github.com/vercel-labs/skills)), those skills land in `.claude/skills/` within the repo. These skills contain project-specific best practices (e.g., Langfuse instrumentation patterns, code review guidelines) that are only relevant when working in that project. Currently, the only way for Hermes to discover these is via `external_dirs` in `~/.hermes/config.yaml` — but that's **global**. Adding a project's `.claude/skills/` directory globally means those skills pollute every other project's context. There's no project-scoped skill discovery. This is a growing ecosystem problem. The [agentskills.io](https://agentskills.io/specification) spec standardized the skill FORMAT (SKILL.md + frontmatter), but each agent uses a different discovery path (`.claude/skills/`, `.codex/skills/`, etc.). The community has identified this fragmentation — see [this Reddit thread](https://www.reddit.com/r/ClaudeCode/comments/1qcigma/agent_skills_the_spec_unified_us_the_paths/): *\"The spec unified us. The paths divided us.\"* Hermes already auto-discovers `.hermes.md` / `HERMES.md` from the project root (#681). The same pattern should work for skills. ### Proposed Solution Auto-discover skills from the working directory (project root) at session start, using progressive disclosure like global skills. Suggested discovery paths, checked in order: 1. `<project_root>/.hermes/skills/` — Hermes-native, project-scoped 2. `<project_root>/.agents/skills/` — Agent-agnostic convention (forward-looking) 3. `<project_root>/.claude/skills/` — Where `npx skills` currently installs (interop with Claude Code, Codex, Cursor, 41+ agents) These would be **read-only** (same as `external_dirs`) — `skill_manage` still writes to `~/.hermes/skills/`. Local skills shadow project skills of the same name, same precedence rules as today. **Behavior:** - On session start, Hermes checks if the cwd (or nearest git root) contains any of the above paths - Discovered skills appear in `skills_list()` alongside global skills, tagged with their source (e.g., `[project]`) - Progressive disclosure unchanged: Level 0 lists them, Level 1 loads full content, Level 2 loads references - When switching projects (different cwd), different project skills are discovered - No config needed — zero setup, just works **Optional enhancement:** Allow `.hermes.md` or a `.hermes/config.yaml` in the project root to specify custom skill paths: ```yaml # .hermes/config.yaml (project-level) skills: local_dirs: - .claude/skills - custom/skills ``` ### Alternatives Considered 1. **Global `external_dirs` in `~/.hermes/config.yaml`** — Works today but pollutes all projects with project-specific skills. Requires manual config per project. 2. **Symlinks** — Fragile, doesn't solve the discovery problem, requires manual setup per project. 3. **Pre-fetching skill content into sub-agent context** — Workaround for `delegate_task`, but loses progressive disclosure benefits and bloats context. ### Feature Type Configuration option ### Scope Medium (<300 lines) ### Additional Context Related issues: - #681 — `.hermes.md` project config (merged, establishes the per-project discovery pattern) - #4381 — `external_dirs` write path configuration (open, related but different — about write path, not auto-discovery) The `npx skills` ecosystem is growing fast (41+ agents supported). Making Hermes auto-discover project skills would close a real interop gap without requiring any changes to the skills CLI itself.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/4667",
        "createdAt": "2026-04-02T23:01:02Z",
        "updatedAt": "2026-08-13T13:45:59Z",
        "timestamp": "2026-08-13T13:45:59Z",
        "metrics": {
          "reactions": 13,
          "comments": 4
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "tool/skills",
          "P3"
        ],
        "author": "leighton-tidwell",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:49567",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Docker example with HERMES_DASHBOARD=1 starts dashboard on 0.0.0.0, which is then rejected by the auth gate",
        "text": "### Bug Description Following the official Docker documentation: ```bash docker run -d \\ --name hermes \\ --restart unless-stopped \\ -v ~/.hermes:/opt/data \\ -p 8642:8642 \\ -p 9119:9119 \\ -e HERMES_DASHBOARD=1 \\ nousresearch/hermes-agent gateway run ``` same issue with: ```bash docker run -d \\ --name hermes \\ --restart unless-stopped \\ -v ~/.hermes:/opt/data \\ -p 127.0.0.1:8642:8642 \\ -p 127.0.0.1:9119:9119 \\ -e HERMES_DASHBOARD=1 \\ nousresearch/hermes-agent gateway run ``` The dashboard fails to start and logs: Refusing to bind dashboard to 0.0.0.0 — the OAuth auth gate engages on non-loopback binds, but no auth providers are registered. Inside the container: ```bash root@931e7bba51a9:/opt/hermes# ps auxww | grep dashboard root 31 0.0 0.0 336 0 ? S 09:30 0:00 s6-supervise dashboard hermes 399 99.0 0.6 68416 54800 ? Rs 09:34 0:01 /opt/hermes/.venv/bin/python3 /opt/hermes/.venv/bin/hermes dashboard --host 0.0.0.0 --port 9119 --no-open root@931e7bba51a9:/opt/hermes# env | grep -i dashboard HERMES_DASHBOARD=1 root@931e7bba51a9:/opt/hermes# env | grep -i host HOSTNAME=931e7bba51a9 # However: root@931e7bba51a9:/opt/hermes# hermes dashboard --help # ... options: --host HOST Host (default 127.0.0.1) ``` ### Steps to Reproduce docker run -d \\ --name hermes \\ --restart unless-stopped \\ -v ~/.hermes:/opt/data \\ -p 127.0.0.1:8642:8642 \\ -p 127.0.0.1:9119:9119 \\ -e HERMES_DASHBOARD=1 \\ nousresearch/hermes-agent gateway run ### Expected Behavior dashboard should use default value(127.0.0.1) ### Actual Behavior dashboard using 0.0.0.0 ### Affected Component Tools (terminal, file ops, web, code execution, etc.) ### Messaging Platform (if gateway-related) _No response_ ### Debug Report ```shell root@931e7bba51a9:/opt/hermes# ps auxww | grep dashboard root 31 0.0 0.0 336 0 ? S 09:30 0:00 s6-supervise dashboard hermes 399 99.0 0.6 68416 54800 ? Rs 09:34 0:01 /opt/hermes/.venv/bin/python3 /opt/hermes/.venv/bin/hermes dashboard --host 0.0.0.0 --port 9119 --no-open root@931e7bba51a9:/opt/hermes# env | grep -i dashboard HERMES_DASHBOARD=1 root@931e7bba51a9:/opt/hermes# env | grep -i host HOSTNAME=931e7bba51a9 ``` ### Operating System Debian 12 ### Python Version _No response_ ### Hermes Version _No response_ ### Additional Logs / Traceback (optional) ```shell ``` ### Root Cause Analysis (optional) _No response_ ### Proposed Fix (optional) _No response_ ### Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
        "url": "https://github.com/NousResearch/hermes-agent/issues/49567",
        "timestamp": "2026-08-13T12:25:39Z",
        "metrics": {
          "reactions": 1,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "area/auth",
          "area/docker",
          "P2",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "comp/dashboard"
        ],
        "author": "chic1018",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:53021",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "feat(approval): session-scoped allowlist mode (deny-by-default terminal) for unattended/untrusted agents",
        "text": "## Problem Hermes agents are increasingly run as **unattended, monetized services** (e.g. sold per-turn over [x402](https://www.x402.org) micropayments), where the *prompt comes from an untrusted buyer*. In that threat model the agent should be able to run **only its own skill scripts** (e.g. `python3 .../skills/<skill>/scripts/foo.py ...`) and nothing else — but today there's no way to express that. The terminal is **denylist-based**: `DANGEROUS_PATTERNS` + an approval gate (`approvals.mode: manual|smart|off`). Consequences for an unattended/untrusted session: - Any command that **doesn't** match a dangerous pattern runs freely. A prompt-injected buyer turn can read pod secrets, exfiltrate data, or `curl` an in-cluster signer/credential endpoint — none of which are in `DANGEROUS_PATTERNS`. - `command_allowlist` is **additive approval only** — `_command_matches_permanent_allowlist` is consulted to *approve* matching dangerous commands, never to *deny* non-matching ones. It can't be used to restrict a session to \"only these commands.\" - Disabling the `code_execution`/`terminal` toolsets entirely isn't viable, because skills legitimately shell out to their own scripts. So there is currently **no deny-by-default terminal mode** for a session. ## Proposal Generalize the deny-by-default pattern that **#20984 (`cron_mode: allowlist`, attempt PR #21034)** already establishes for cron jobs into a **session-scoped** approval mode: ```yaml approvals: mode: allowlist # deny-by-default: only command_allowlist globs run; everything else is blocked command_allowlist: - \"python3 */skills/*/scripts/*.py *\" ``` Semantics: - In `allowlist` mode, a command runs **only** if it matches a `command_allowlist` glob; any other command is blocked (not prompted), regardless of whether it's \"dangerous.\" - Reuse the existing `_command_matches_permanent_allowlist` plumbing — it already does glob matching **and** rejects compound/shell-operator commands (`;`, `&&`, `|`, `$( )`, backticks), so `python3 ok.py; rm -rf /` won't slip through. - Orthogonal to `manual`/`smart`/`off`; it's the strictest point on the same axis. This gives operators of unattended/untrusted agents a small, well-precedented switch to scope an agent to exactly its skill surface. ## Related - **#20984** / PR **#21034** — `cron_mode: allowlist`: the same deny-by-default→`command_allowlist` pattern, scoped to cron. This proposal generalizes it to sessions. - **#50072** — declarative terminal routing for mixed-trust sessions: an untrusted paid-buyer turn is exactly the \"mixed-trust\" case. - **#35358** — `[Security] No atomic shell-disable mode`: a complementary durability concern — a deny-by-default mode is only trustworthy if a later toolset/config update can't silently re-expose the shell. - **#33905** — per-tool/per-toolset approval policies: related but coarser (toolset granularity, not command granularity). Happy to put up a PR generalizing #21034's approach if maintainers are open to this direction.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/53021",
        "createdAt": "2026-06-26T10:17:47Z",
        "updatedAt": "2026-08-13T13:08:26Z",
        "timestamp": "2026-08-13T13:08:26Z",
        "metrics": {
          "reactions": 1,
          "comments": 2
        },
        "labels": [
          "type/feature",
          "comp/tools",
          "tool/terminal",
          "area/auth",
          "P3",
          "area/sessions"
        ],
        "author": "bussyjd",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:57955",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "terminal tool lacks protected-file path validation (bypasses SOUL.md write-protection)",
        "text": "## Bug Description The `terminal` tool has no path validation for protected system files. An agent can bypass the SOUL.md write-protection rules (which block `write_file`/`patch` on protected paths) by executing shell commands like `sed -i \"\" \"s/old/new/\" ~/.hermes/SOUL.md`. ## Steps to Reproduce 1. SOUL.md lists protected files: SOUL.md, AGENTS.md, config.yaml, vault/ files, .env, cron/jobs.json, skills/ SKILL.md 2. `write_file(\"~/.hermes/SOUL.md\")` → correctly blocked 3. `patch(\"~/.hermes/SOUL.md\", ...)` → correctly blocked 4. `terminal(\"sed -i \\\"\\\" ... ~/.hermes/SOUL.md\")` → **executed without any path check** ## Expected Behavior The `terminal` tool should parse file-path arguments in shell commands and reject writes to the same protected-file list used by `write_file`/`patch`. At minimum, warn when a protected path is detected as a write target. ## Environment - Hermes Agent (latest as of 2026-07-04) - macOS ## Current Mitigation (in our config) SOUL.md has a text-level ban (\"禁止用 terminal(sed/awk/perl/tee) 绕过标准入口直改受保护文件\"), but this relies on agent compliance — there is no tool-level enforcement.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/57955",
        "createdAt": "2026-07-03T22:06:09Z",
        "updatedAt": "2026-08-13T17:37:03Z",
        "timestamp": "2026-08-13T17:37:03Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/security",
          "tool/terminal",
          "P3",
          "sweeper:cannot-reproduce",
          "sweeper:risk-security-boundary"
        ],
        "author": "colorcn",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:5820",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "feat(memory): Allow synchronous recall for current turn as option for honcho and hindsight",
        "text": "IIUC hermes currently queues a background recall for next turn instead of synchronously recalling based on the current user query (for honcho and hindsight at least). `prefetch` right now totally ignores the given query. This means that the recalled information may be completely irrelevant when it is actually injected. If one query is `fix linting`, and the next is `fix tests`, or even if the topic changes slightly, the recalled information may be irrelevant or even cause worse results. Hindsight recall is already supposed to be fast, so the cost of this for a slight latency improvement does not seem worth it to me. I want memory results relevant to the current message.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/5820",
        "createdAt": "2026-04-07T11:14:21Z",
        "updatedAt": "2026-08-13T17:45:28Z",
        "timestamp": "2026-08-13T17:45:28Z",
        "metrics": {
          "reactions": 0,
          "comments": 8
        },
        "labels": [
          "type/feature",
          "comp/plugins",
          "tool/memory",
          "P3",
          "sweeper:risk-session-state",
          "area/memory"
        ],
        "author": "noctuid",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:58596",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "DaemonThreadPoolExecutor crashes on Python 3.14: `_initializer` attribute removed",
        "text": "## Bug Description `DaemonThreadPoolExecutor._adjust_thread_count()` references `self._initializer` and `self._initargs`, which no longer exist in Python 3.14. This breaks **all** concurrent features: `delegate_task`, async delegation, skills hub fan-out, and memory sync. ``` Error during OpenAI-compatible API call: 'DaemonThreadPoolExecutor' object has no attribute '_initializer' ``` ## Root Cause Python 3.14 changed `ThreadPoolExecutor` internals: | | Python ≤ 3.13 | Python ≥ 3.14 | |---|---|---| | Initializer storage | `self._initializer`, `self._initargs` (instance attrs) | `self._create_worker_context()` returns a `WorkerContext` | | `_worker()` signature | `_worker(executor_ref, work_queue, initializer, initargs)` (4 params) | `_worker(executor_ref, ctx, work_queue)` (3 params) | | Context management | None | `WorkerContext.initialize()` / `ctx.finalize()` lifecycle | The code at `tools/daemon_pool.py:58-59` directly accesses the removed attributes. ## Affected Code File: `tools/daemon_pool.py`, method `_adjust_thread_count`, lines 55-60: ```python args=( weakref.ref(self, weakref_cb), self._work_queue, self._initializer, # AttributeError in 3.14 self._initargs, # AttributeError in 3.14 ), ``` ## Reproduction ``` Python 3.14.6 from tools.daemon_pool import DaemonThreadPoolExecutor e = DaemonThreadPoolExecutor(max_workers=1) e.submit(lambda: 42) # -> AttributeError: 'DaemonThreadPoolExecutor' object has no attribute '_initializer' ``` ## Proposed Fix Detect `_worker` signature at import time and branch accordingly: ```python import inspect from concurrent.futures.thread import _worker _worker_uses_ctx = len(inspect.signature(_worker).parameters) == 3 # In _adjust_thread_count: if _worker_uses_ctx: # Python >= 3.14: _worker(executor_ref, ctx, work_queue) args = ( weakref.ref(self, weakref_cb), self._create_worker_context(), self._work_queue, ) else: # Python <= 3.13: _worker(executor_ref, work_queue, initializer, initargs) args = ( weakref.ref(self, weakref_cb), self._work_queue, self._initializer, self._initargs, ) ``` This fix: - Uses `inspect.signature(_worker)` at import time (zero overhead per call) - Passes `self._create_worker_context()` for 3.14 (returns a `WorkerContext` with `initialize()`/`finalize()` lifecycle) - Falls back to old `_initializer`/`_initargs` for <= 3.13 - Preserves the original semantics (daemon=True, no `_threads_queues` registration) ## Environment - Hermes Agent: v0.18.0 (2026.7.1) - Python: 3.14.6 - Platform: aarch64-linux-android (Termux) - Verified: patch applied locally, `DaemonThreadPoolExecutor` creates, submits tasks, and shuts down correctly ## Impact **Critical** — on Python 3.14, every feature using `DaemonThreadPoolExecutor` crashes: `delegate_task`, `async_delegation`, `skills_hub`, `memory_manager`, `tool_executor`.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/58596",
        "createdAt": "2026-07-05T01:29:33Z",
        "updatedAt": "2026-08-13T17:52:19Z",
        "timestamp": "2026-08-13T17:52:19Z",
        "metrics": {
          "reactions": 3,
          "comments": 7
        },
        "labels": [
          "type/bug",
          "comp/tools",
          "tool/delegate",
          "P3",
          "sweeper:risk-compatibility"
        ],
        "author": "iamzulx",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:64231",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "chore(plugins): lifecycle-event catalog, hook taxonomy, and batch disposition of pending hook PRs",
        "text": "## Summary Define a coherent lifecycle-event catalog and hook-acceptance standard, then triage the entire pending observer-hook PR cluster against it in one pass — instead of merging (or letting rot) a dozen unrelated one-off `VALID_HOOKS` additions. Output: a documented taxonomy + a batch salvage/disposition of the queued hook PRs. Part of the plugin-interface expansion tracking issue #64182. Added in round-2 review. ## Motivation The PR queue holds a large cluster of small, mostly-reasonable observer-hook proposals, each adding one event: - compression lifecycle — PR #53806 (@ledfoot631) - memory write hooks — PR #55765 (@alienfrenZyNo1) - goal lifecycle — PR #27790 (@clicheman) - session mode change (ACP) — PR #53087 (@alanjds) - session title — PR #41752 (@draplater) - gateway startup — PR #23771 (@im-kris) - slash-command pre/post — PR #46581 (@nuffin) - cron run lifecycle — PR #41833 (@mathmonstergo), issue #57971 - curator run — PR #43780 (@Sahil-SS9) - turn_failed — PR #56720 (@brandonedley) - cli_queue_drained — PR #54307 (@0xAdamFortuna) - kanban dispatch tick — PR #56066 (@laboratoiresonore); kanban observability RFC #58548 (@thebizfixer) - classify_api_error — PR #58524 (@webdevtodayjason) - session context propagation — PR #42416 (@Gerkinfeltser) Merging these piecemeal grows `VALID_HOOKS` without design coherence (naming drift, inconsistent payloads, no privacy review per event). Rejecting them piecemeal wastes exactly the contributor energy this expansion is meant to capture. ## Proposed work ### 1. Hook taxonomy + acceptance checklist (docs page, enforced in review) - **Naming grammar:** `<subsystem>_<noun>_<verb-past>` for observers (`session_title_changed`, `cron_run_finished`); `transform_*` for output transforms; middleware stays its own registry. - **Payload rules:** keyword-only, schema-versioned (extend `telemetry_schema_version`), fields documented at the VALID_HOOKS entry, additive-only evolution (per #64179). - **Classification required at proposal time:** observer / transform / middleware — observers get the low bar, anything mutating needs the middleware contract. - **Per-event privacy note:** what user content the payload carries, and any config gate needed (the reasoning-delta precedent from #64161). - **Cost rule:** `has_hook()` short-circuit mandatory at hot-path call sites. - **Bus relationship:** which core events also mirror onto the inter-plugin event bus (#64164) as `hermes:`-namespaced events, so plugins get one subscription surface. ### 2. Catalog v1 Table of every existing hook + the accepted additions from the cluster: name, fire point (file:site), payload schema, classification, privacy note. Lives in docs; VALID_HOOKS comments point at it. ### 3. Batch disposition of the cluster Review each PR above against the taxonomy: salvage-with-credit (rename/reshape payload to the grammar as follow-up commits on top of the contributor's work), fold into an already-filed sub-issue (e.g. cron hooks vs the cron section of the catalog), or close-with-credit where superseded. Every disposition recorded in a table on this issue. ## Backward compatibility Existing hook names/payloads are grandfathered verbatim — the grammar applies to new additions. No renames of shipped hooks. ## Dependencies - #64179 (payload versioning rules), #64164 (bus mirroring decision per event). ## Acceptance criteria - Taxonomy doc merged; VALID_HOOKS docstring references it. - Catalog covers 100% of shipped hooks. - Every PR in the cluster above has a recorded disposition and a comment; salvaged ones merged with contributor authorship preserved.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/64231",
        "createdAt": "2026-07-14T06:38:45Z",
        "updatedAt": "2026-08-13T12:58:01Z",
        "timestamp": "2026-08-13T12:58:01Z",
        "metrics": {
          "reactions": 0,
          "comments": 26
        },
        "labels": [
          "type/feature",
          "comp/plugins",
          "P3",
          "needs-decision"
        ],
        "author": "teknium1",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:64335",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Bug: sanitize_api_messages dedup pass writes tool_calls: [] — HTTP 400 on DeepSeek/OpenAI",
        "text": "## Summary In `sanitize_api_messages()`, the deduplication pass that removes duplicate `tool_call_id`s (#58327) can produce an empty `tool_calls: []` array on an assistant message when ALL of its tool calls are deduped. Strict providers (DeepSeek v4, newer OpenAI) reject this with HTTP 400: ``` Invalid 'messages[N].tool_calls': empty array. Expected an array with minimum length 1, but got an empty array instead. ``` ## Root cause The existing first-pass fix for empty arrays (#58755, #5737) covers messages that already *store* `tool_calls: []` on disk, but it runs **before** the dedup pass. When the dedup pass (`agent/agent_runtime_helpers.py` ~line 2600) removes every tool_call from a message because all IDs were already seen earlier in the same request, it writes: ```python msg = {**msg, \"tool_calls\": kept_tcs} # kept_tcs == [] → tool_calls: [] ``` This newly-created `tool_calls: []` is never caught by any later sanitizer stage. ## Reproduction conditions - Long sessions (200+ messages) where the same `tool_call_id` string appears on multiple assistant messages across different turns - The agent sends the full history as `conversation_history` in one shot (WebUI pattern; TUI accumulates incrementally, so it is unaffected) - Affects both direct DeepSeek and opencode-go providers ## Fix When `kept_tcs` is empty after dedup, drop the `tool_calls` key entirely instead of writing an empty list: ```python if kept_tcs: msg = {**msg, \"tool_calls\": kept_tcs} else: msg = {k: v for k, v in msg.items() if k != \"tool_calls\"} ``` ## Regression scope Introduced by #58327 (tool_call_id dedup). The earlier fixes #58755 / #5737 were insufficient because they only sanitize the *input* to sanitize_api_messages, not the intermediate state produced by the dedup pass itself.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/64335",
        "createdAt": "2026-07-14T10:35:17Z",
        "updatedAt": "2026-08-13T13:25:51Z",
        "timestamp": "2026-08-13T13:25:51Z",
        "metrics": {
          "reactions": 0,
          "comments": 5
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/openai",
          "provider/deepseek",
          "provider/qwen",
          "P2",
          "sweeper:risk-session-state"
        ],
        "author": "twunji",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:65387",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Codex weekly-only primary_window is mislabeled as Session",
        "text": "## Summary `agent/account_usage.py::_fetch_codex_account_usage()` assigns Codex quota labels by response position: ```python for key, label in ((\"primary_window\", \"Session\"), (\"secondary_window\", \"Weekly\")): ``` The live Codex usage API does not always preserve that semantic ordering. When only the weekly limit is returned, it can occupy `primary_window`. Hermes then reports the weekly quota as `Session` and reports Weekly as unavailable downstream. ## Live sanitized reproduction Requesting the existing Codex account-usage endpoint produced: ```text primary_window: used_percent: 1 limit_window_seconds: 604800 secondary_window: null ``` `604800` seconds is the 7-day / weekly window. There is no 5-hour/session window in this payload. Current Hermes snapshot: ```text Session: 1.0% used Weekly: unavailable ``` Expected snapshot: ```text Session: unavailable Weekly: 1.0% used ``` The exact percentage can naturally change between calls; the stable reproduction is `primary_window.limit_window_seconds == 604800` with no `secondary_window`. ## Root cause At current `main`, `_fetch_codex_account_usage()` ignores `limit_window_seconds` and hard-codes: - `primary_window` → `Session` - `secondary_window` → `Weekly` This assumption is also present in some existing feature discussions, but it no longer matches the live API when only one limit window is returned. ## Suggested fix Classify known Codex windows by duration rather than slot: - `18000` seconds → 5-hour / Session - `604800` seconds → 7-day / Weekly For unknown or missing durations, preserve a conservative fallback without assigning a false semantic label. ## Regression coverage Please add tests for at least: 1. `primary_window` is 18,000 seconds and `secondary_window` is 604,800 seconds → Session + Weekly. 2. `primary_window` is 604,800 seconds and `secondary_window` is absent → Weekly only. 3. Missing/unknown `limit_window_seconds` does not silently mislabel a window. ## Scope and related issues This is a parsing/correctness bug, distinct from quota visibility requests: - #57476 — expose Codex subscription quota in status/gateway - #9085 — surface Codex plan-limit headers - #15167 — credential-pool lookup prevents quota fetching No credentials, account IDs, or raw payload beyond the sanitized window fields are included here.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/65387",
        "timestamp": "2026-08-13T11:58:07Z",
        "metrics": {
          "reactions": 0,
          "comments": 5
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/openai",
          "P3",
          "codex",
          "needs-decision",
          "area/billing",
          "area/usage-cost"
        ],
        "author": "wang2",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:66616",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[skills-index-watchdog] Skills index is stale or degraded (degraded)",
        "text": "Automated freshness probe failed. **Status:** `degraded` **Detail:** Index is 29.8h old (limit 26h) The Skills Hub at /docs/skills depends on `/docs/api/skills-index.json`. The unified index is rebuilt by `.github/workflows/skills-index.yml` (cron 6/18 UTC) and `.github/workflows/deploy-site.yml` (on every push affecting website/skills). If this issue keeps reopening, check the latest runs: - https://github.com/NousResearch/hermes-agent/actions/workflows/skills-index.yml - https://github.com/NousResearch/hermes-agent/actions/workflows/deploy-site.yml This issue was opened by `.github/workflows/skills-index-freshness.yml`. Close it once the underlying problem is fixed; the next probe will reopen if it's still broken.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/66616",
        "createdAt": "2026-07-18T00:32:23Z",
        "updatedAt": "2026-08-13T16:18:53Z",
        "timestamp": "2026-08-13T16:18:53Z",
        "metrics": {
          "reactions": 0,
          "comments": 23
        },
        "labels": [
          "type/bug",
          "tool/skills",
          "P3",
          "sweeper:risk-automation"
        ],
        "author": "nousbot-eng",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:69371",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Windows: TestGitBashCoreutilsOnPath layout tests compare native-separator paths against forward-slash literals",
        "text": "**Version:** v2026.7.20 (also present on current `main`) **Platform:** Windows 11, Python 3.11, pytest run from a native Windows checkout ## Summary `tests/tools/test_local_env_windows_msys.py::TestGitBashCoreutilsOnPath::test_derives_dirs_from_portablegit_layout` and `::test_derives_dirs_from_mingit_usr_bin_layout` fail on any Windows checkout. Both tests assert forward-slash string literals (e.g. `\"/pg/usr/bin\"`) against the output of `_git_bash_bin_dirs()`, but that function builds its candidate dirs with `os.path.join`, which emits native separators. On Windows the derived dirs come back as mixed-separator strings like `/pg\\usr\\bin`, so the `in` assertions fail: ``` AssertionError: assert '/pg/usr/bin' in ['\\\\pg\\\\mingw64\\\\bin', '\\\\pg\\\\usr\\\\bin', '\\\\pg\\\\bin'] ``` Note the irony: these are the *Windows MSYS* tests, but they only pass on POSIX hosts. The test class already anticipates this problem for `isdir` — its own `_fake_isdir` helper normalizes separators before comparing (`existing = {e.replace(\"\\\\\", \"/\") for e in existing}`). The assertions just need the same treatment on the output side. ## Reproduction On any Windows machine: ``` python -m pytest tests/tools/test_local_env_windows_msys.py::TestGitBashCoreutilsOnPath -x ``` ## Suggested fix Mirror `_fake_isdir`'s normalization with a small helper and wrap the two calls: ```diff --- a/tests/tools/test_local_env_windows_msys.py +++ b/tests/tools/test_local_env_windows_msys.py @@ -341,6 +341,13 @@ class TestGitBashCoreutilsOnPath: existing = {e.replace(\"\\\\\", \"/\") for e in existing} return lambda p: p.replace(\"\\\\\", \"/\") in existing + @staticmethod + def _norm(dirs): + # os.path.join emits native separators, so on Windows the derived + # dirs mix the fixture's \"/\" root with \"\\\" joins; compare + # separator-agnostically, like _fake_isdir. + return [d.replace(\"\\\\\", \"/\") for d in dirs] + def test_derives_dirs_from_portablegit_layout(self, monkeypatch): monkeypatch.setattr(local_mod, \"_IS_WINDOWS\", True) monkeypatch.setattr(local_mod, \"_git_bash_bin_dirs_cache\", None) @@ -348,7 +355,7 @@ class TestGitBashCoreutilsOnPath: existing = {\"/pg/mingw64/bin\", \"/pg/usr/bin\", \"/pg/bin\"} monkeypatch.setattr(local_mod.os.path, \"isdir\", self._fake_isdir(existing)) - dirs = _git_bash_bin_dirs() + dirs = self._norm(_git_bash_bin_dirs()) # usr/bin is the load-bearing coreutils dir; mingw64 precedes it. assert \"/pg/usr/bin\" in dirs @@ -363,7 +370,7 @@ class TestGitBashCoreutilsOnPath: existing = {\"/mg/usr/bin\", \"/mg/mingw64/bin\"} monkeypatch.setattr(local_mod.os.path, \"isdir\", self._fake_isdir(existing)) - dirs = _git_bash_bin_dirs() + dirs = self._norm(_git_bash_bin_dirs()) # MinGit ships bash under usr\\bin; root must still resolve to /mg. assert \"/mg/usr/bin\" in dirs ``` Verified locally on Windows 11: both tests pass with this change, and the change is a no-op on POSIX.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/69371",
        "createdAt": "2026-07-22T13:43:36Z",
        "updatedAt": "2026-08-13T17:43:06Z",
        "timestamp": "2026-08-13T17:43:06Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/test",
          "comp/tools",
          "backend/local",
          "P3",
          "sweeper:risk-platform-windows",
          "platform/windows"
        ],
        "author": "daragao3",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:69778",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: intent_ack_continuation never fires in established sessions with local models",
        "text": "## Bug Description Local models on the gateway announce an action and end the turn without doing it. A reply like \"I'll delegate this to a sub-agent, then bring it up\" stops there, with no tool call and no work. Hermes already ships the guard for this, `intent_ack_continuation` (added in #27881), but it does not fire for local models in established sessions. The detector `looks_like_codex_intermediate_ack` has three gaps. Relationship to #55664: #55664 reports a different failure in the same function. The cause and the fix do not overlap. #55664 is about language: the future-ack regex and action markers are English-only, so a non-English ack (its example is Traditional Chinese, \"我先載入 skill…\") fails the first guard and returns early; its fix adds CJK matching, and a Chinese ack never fires on turn one regardless of session length. This issue is about English acks that still do not fire, for three reasons unrelated to language: the prior-tool gate scans the full message history and returns early in any session that has run a tool (the dominant cause), the verb list omits build/ops verbs, and short colon-ended announcements are missed. An English \"I'll delegate this, then bring it up\" fails today even though every word is English. The two are complementary: fixing #55664 does nothing for an English ack that dies on the full-history gate, and fixing this one does nothing for a Chinese ack that dies on the English regex. They touch adjacent code (both widen `has_future_ack` and `action_markers`), so whichever merges first, the other rebases those two lists. No logical conflict. ## Steps to Reproduce 1. Run Hermes on a local model over the Telegram gateway (Ollama, `chat_completions` api-mode). Set `agent.intent_ack_continuation: true`. 2. Use the session until the model has called at least one tool, so the store holds a `role=\"tool\"` row. 3. Send a build/ops task, for example \"Set up a small web service in ~/svc and run it.\" 4. The model replies with an announcement (\"I'll set that up...\") and stops. No tool call. Turn ends. ## Expected Behavior The guard fires, injects the \"continue, execute the required tool calls\" nudge, and the model runs the tools in the same turn. ## Actual Behavior The guard never fires. The turn ends on the announcement (`finish_reason=stop`, zero tool calls). The user has to prod it to continue. ## Affected Component Agent Core (conversation loop, context compression, memory) ## Messaging Platform Telegram ## Debug Report Full `hermes debug share` bundle withheld: it embeds hundreds of personal references (name, Telegram chat IDs) pulled from included log lines, and is not needed to reproduce a deterministic detector bug. Minimal environment below; specific redacted logs available on request. ``` Hermes Agent v0.18.2 (2026.7.7.2) OS: Fedora Linux 44 (Workstation Edition), kernel 7.0.12-201.fc44.x86_64 Python: 3.11.15 Backend: local Ollama (custom provider), chat_completions api-mode Model: 35B-class local model (reproduced with ornith-35b, qwen3.5-35b, qwen3.6-35b) Surface: Telegram gateway Config: agent.intent_ack_continuation = true (opt-in) ``` ## Operating System Fedora Linux 44 (Workstation Edition), kernel 7.0.12-201.fc44 ## Python Version 3.11.15 ## Hermes Version v0.18.2 (2026.7.7.2) ## Additional Logs (optional) ``` looks_like_codex_intermediate_ack vs real stall messages, opted in: MISSED \"I'll delegate the FastAPI app creation ... then bring it up.\" MISSED \"Now let me verify what was created and bring it all up properly.\" CAUGHT \"I'll run the tests now.\" (matches 'run') CAUGHT \"Let me inspect the project.\" (matches 'inspect') ``` ## Root Cause Analysis (optional) Three gaps in `looks_like_codex_intermediate_ack`. First, the verb list is inspection-oriented (inspect, scan, review, run, test, read) and lacks the build/ops verbs local models use (delegate, deploy, install, build, bring up, verify), so those acks match no marker. Second, and this is the main cause, the prior-tool gate scans the full history: `if any(msg.get(\"role\") == \"tool\" for msg in messages): return False`. The store keeps a `role=\"tool\"` row for every past tool call, so any session that ran one tool short-circuits the detector before it reads the ack. One session here held 74 such rows across 294 messages. Third, detection keys on narrow phrasing, so \"Going straight to execution now:\" slips through. It is a turn-lifecycle effect, not model capability: the same qwen3.6-35b that stalls in the gateway completes the identical task in a one-shot `hermes chat -q` run. ## Proposed Fix (optional) Scope the prior-tool gate to the current turn (messages after the last user message) instead of full history, in codex_only mode; in opted-in `all` mode drop the after-tool suppression so a mid-task ack is caught, bounded by the existing max-2 continuation cap. Widen the action vocabulary to build/ops/teardown verbs. To keep the wider matching from false-positiving on ordinary prose, fire (in `all` mode) only on a *governed announcement* — a first-person lead directly governing an action verb in the same clause (\"I'll deploy it\") — or a colon-truncated ack, with word-boundaried verbs, phrasal-idiom exclusions, and a shared sign-off/offer guard. Reproduced with ornith-35b, qwen3.5-35b, qwen3.6-35b. ## Willing to submit a PR? [x] I'd like to fix this myself and submit a PR.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/69778",
        "timestamp": "2026-08-13T12:19:37Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/ollama",
          "P2",
          "area/sessions"
        ],
        "author": "ClaySecAI",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:71760",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "hermes cron run reports 'Ran now: failed.' for a run whose agent session completed cleanly",
        "text": "**Version:** Hermes Agent v0.19.0 (2026.7.20), upstream 0ce9022e, macOS. **What happened** Created a one-shot agent job and triggered it manually: ``` hermes cron add \"2027-01-01T00:00:00Z\" \"<prompt>\" --name probe --skill my-local-skill --deliver local --repeat 1 hermes cron run <job-id> # => Triggered job: <job-id> (<job-id>) # => Ran now: failed. ``` But the run actually succeeded. `agent.log` for the spawned cron session shows a clean single-turn completion: ``` INFO [cron_<id>_…] agent.conversation_loop: API call #1: … in=14342 out=213 … INFO [cron_<id>_…] agent.conversation_loop: Turn ended: reason=text_response(finish_reason=stop) … response_len=947 … ``` and the session export contains the full, correct assistant answer. The one-shot job then removed itself as expected. **Guesses** Either the 'failed' verdict conflates delivery status with run status for `--deliver local` manual triggers, or the one-shot job's self-removal races the status read after the run. Both fit the observed 'failed-but-actually-fine' output. **Expected** `cron run` should report the run's real outcome (and, if delivery is what failed, say 'run ok, delivery failed: <reason>'). A false 'failed.' sends the operator hunting a healthy pipeline.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/71760",
        "createdAt": "2026-07-26T04:36:34Z",
        "updatedAt": "2026-08-13T17:23:38Z",
        "timestamp": "2026-08-13T17:23:38Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/cron",
          "P2"
        ],
        "author": "optionshift",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:72662",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Feature: Apply DeepSeek peak-hour rate multiplier (2x during 01:00-04:00 and 06:00-10:00 UTC)",
        "text": "This issue requests implementation of DeepSeek peak-hour pricing multiplier announced by DeepSeek on 2026-07-26. ## Feature Request DeepSeek announced peak-hour pricing with 2x rate multiplier during: - **01:00 - 04:00 UTC** (daily) - **06:00 - 10:00 UTC** (daily) All other hours: standard rate. ## Current State Hermes has a usage_pricing.py that tracks model costs per call. It does not currently account for DeepSeek's time-based pricing variation. ## Proposed Implementation 1. When pricing a DeepSeek API call, check the current UTC hour 2. If hour in [1, 2, 3, 4] or [6, 7, 8, 9]: - Multiply the calculated cost by 2x 3. Otherwise: - Use standard rate ## Example **Standard rate:** DeepSeek call costs .00 **During peak hour (02:00 UTC):** Same call costs .00 (1.00 * 2x multiplier) **During off-peak (08:30 UTC):** Still .00 (08 is in the 06:00-10:00 window) **During off-peak (11:00 UTC):** Back to .00 ## Impact For orchestration systems running 24/7: - Accurate cost tracking during peak hours - Ability to detect and analyze peak-vs-off-peak spend patterns - Proper billing allocation This is important for systems that autoscale or shift heavy workloads to off-peak hours. ## Reference DeepSeek pricing announcement (July 2026): Peak-hour rates are 2x standard. Would be happy to provide more implementation details if helpful.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/72662",
        "createdAt": "2026-07-27T13:00:16Z",
        "updatedAt": "2026-08-13T15:31:42Z",
        "timestamp": "2026-08-13T15:31:42Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "provider/deepseek",
          "P3",
          "area/usage-cost"
        ],
        "author": "dominicelayda",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:72884",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "file-mutation verifier falsely claims target was unchanged after terminal mutation",
        "text": "## Bug Description The turn-end file-mutation verifier can make a demonstrably false claim when a failed `write_file` or `patch` attempt is followed by a successful mutation through `terminal` in the same turn. The verifier currently says the target **\"was NOT modified this turn\"** and instructs the user to trust that footer over the assistant's summary. However, Hermes intentionally allows approved terminal commands to modify files and does not include `terminal` in the verifier's mutating-tool set. A successful terminal mutation therefore leaves the earlier failed file-tool record unresolved, causing the footer to contradict actual filesystem state. This is distinct from #70719, which covers argument-validation failures that never attempted I/O. Here, a separate tool really does modify the target before the incorrect footer is emitted. ## Steps to Reproduce 1. Enable the default verifier: ```yaml display: file_mutation_verifier: true ``` 2. In one agent turn, call `patch` against a protected path so the direct file tool fails, for example: ```text Write denied: '/home/user/.ssh/config' is a protected system/credential file. ``` 3. With explicit user approval, call `terminal` and modify the same path successfully, for example with a Python script using an atomic replacement. 4. Verify through `terminal`, `read_file`, or `stat` that the target now contains the intended change. 5. Finish the turn. A minimal state-level reproduction against `AIAgent` is: ```python agent = object.__new__(AIAgent) agent._turn_failed_file_mutations = {} agent._turn_file_mutation_paths = set() agent._record_file_mutation_result( tool_name=\"patch\", arguments={\"mode\": \"replace\", \"path\": \"/tmp/example\"}, tool_result='{\"success\": false, \"error\": \"write denied\"}', ) # `terminal` is intentionally not classified as a file-mutating tool, so a # later successful terminal rewrite does not clear the outstanding record. agent._record_file_mutation_result( tool_name=\"terminal\", arguments={\"command\": \"...writes /tmp/example...\"}, tool_result='{\"exit_code\": 0}', ) print(agent._format_file_mutation_failure_footer( agent._outstanding_file_mutation_failures() )) ``` ## Actual Behavior The final response can include: ```text ⚠️ File-mutation verifier: 1 file(s) were NOT modified this turn despite any wording above implying otherwise. ``` That assertion is false: the file was modified successfully through `terminal` and its new state was verified. ## Expected Behavior The verifier should not claim that a target was unchanged unless Hermes can establish that across all write-capable tool surfaces. At minimum, narrow the footer to the fact Hermes actually knows, for example: ```text File-mutation verifier: 1 target still has an unresolved failed write_file/patch attempt. This does not prove the target was unchanged; terminal or another tool may have modified it. Verify current state. ``` Alternatively, represent later opaque write-capable tools as uncertainty and suppress the absolute unchanged claim. ## Root Cause Analysis `agent/tool_result_classification.py` restricts mutation tracking to: ```python _FILE_MUTATING_TOOLS = frozenset({\"write_file\", \"patch\"}) ``` `AIAgent._record_file_mutation_result()` consequently ignores `terminal`. The existing verifier tests explicitly treat terminal as a non-mutating/untracked tool. This is consistent with the verifier's narrow implementation, but inconsistent with the absolute wording of `_format_file_mutation_failure_footer()` and the documentation's instruction to trust that footer. Hermes' security documentation separately and correctly explains that the protected-path guard covers direct file tools while terminal remains an approval-controlled escape path. The verifier copy needs to preserve that distinction. ## Proposed Regression Test Add a test that records: 1. a failed `patch` for a path; 2. a successful `terminal` call capable of changing that same path; and 3. the final verifier footer. Assert that the footer: - still reports the unresolved direct file-tool failure if desired; - does **not** contain `were NOT modified` or any equivalent assertion of unchanged state; and - clearly notes that other tools may have modified the target and current state should be verified. ## Impact This is a trust and safety issue rather than a cosmetic discrepancy. The footer presents itself as authoritative and can override correctly verified operational reporting, causing users to believe an approved configuration change did not happen when it did. ## Related Issues - #70719 — verifier footer fires on argument-missing patch calls; adjacent wording issue, but does not cover successful mutation by another tool. - The ACP alternate-write approval issue is also adjacent, but concerns whether a denied edit may be retried through terminal. This report concerns the verifier's false final statement after an authorized terminal mutation. ## Environment - Hermes Agent: `v0.19.0 (2026.7.20)` - Upstream reported by install: `2b0fb72a` - Local checkout: `0fa5e41c86f022bba147797849f0b44865721476` (`+1 carried commit`) - Python: `3.11.15` - OS: Linux `6.8.0-136-generic` x86_64 - Surface: Discord gateway, though the bug is in shared `AIAgent` turn-finalization logic",
        "url": "https://github.com/NousResearch/hermes-agent/issues/72884",
        "createdAt": "2026-07-27T20:19:26Z",
        "updatedAt": "2026-08-13T17:37:11Z",
        "timestamp": "2026-08-13T17:37:11Z",
        "metrics": {
          "reactions": 0,
          "comments": 5
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "tool/file",
          "P3"
        ],
        "author": "kaitwalla",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:73030",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug] Child Kanban subscriptions drop chat_type and delivery_metadata during inheritance",
        "text": "### Summary At commit `373632e33813c86b1b9b024168a5892af1926b83`, `hermes_cli/kanban_db.py::_inherit_notify_subs()` copies a parent task’s notification subscription to a child but omits the `chat_type` and `delivery_metadata` columns. The same omission remained on `main` when rechecked on 2026-07-27. Subscriptions created by gateway `/kanban create` and tool-driven `kanban_create` can carry both fields. The explicit CLI subscription path can carry `chat_type`. `create_task(..., parents=...)`, `link_tasks()`, and `decompose_triage_task()` all call the inheritance helper, so inherited child rows silently lose available routing metadata. ### Impact The recipient identifiers (`chat_id`, `user_id`, and `thread_id`) are preserved, so this is not a cross-recipient delivery issue. However: - creator-wake reconstruction falls back to `chat_type=\"group\"`, causing a DM-originated child completion to wake a fresh group-scoped session instead of the originating DM session; - Telegram DM-topic subscriptions lose their persisted reply-fallback and topic metadata, while `message_thread_id` alone can render outside the intended visible lane. ### Reproduction 1. Add a parent notification subscription with `chat_type=\"dm\"` and non-empty `delivery_metadata`. 2. Create a child with that parent, link an existing child with `link_tasks()`, or decompose a triage task. 3. Read the inherited child subscription. 4. `chat_type` and `delivery_metadata` are `NULL`, while the other routing fields are copied. ### Expected behavior Inherited subscriptions preserve `chat_type` and `delivery_metadata`. ### Suggested fix Include both columns in the helper’s `INSERT … SELECT`. Extend `_assert_inherited_notify_sub()` in `tests/hermes_cli/test_kanban_notify.py` to assert them; the existing tests already exercise parent-linked creation, `link_tasks()`, and triage decomposition. Related work: #56580, #60600, #72191, #72223, and #72232.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/73030",
        "createdAt": "2026-07-28T01:33:54Z",
        "updatedAt": "2026-08-13T17:58:46Z",
        "timestamp": "2026-08-13T17:58:46Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/cron",
          "platform/telegram",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery"
        ],
        "author": "bmetcalf21",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:73082",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Desktop client renderer/GPU processes spin at 100%+ CPU at idle — constant re-render loop, high energy usage",
        "text": "## Summary The Hermes desktop (Electron) client pins the Renderer and GPU helper processes at 50–90% CPU even when idle (no active generation, no user input). The macOS battery/ power menu reports Hermes as the highest energy consumer and the machine gets noticeably hot. Quitting and reopening the app does NOT resolve it — the spin returns within seconds of launch. ## Environment - Hermes Agent (Python core) version: **0.19.0** - Desktop app (Electron, affected layer) version: **0.18.0** (from `Hermes.app/Contents/Info.plist` → CFBundleShortVersionString) - Platform: macOS 26.5.2 (Apple Silicon, arm64) - Install: `/Users/<user>/.hermes/hermes-agent/apps/desktop/release/mac-arm64/Hermes.app` - image_gen config (for context, not the cause): ```yaml image_gen: use_gateway: true model: fal-ai/gpt-image-2 provider: fal ``` ## Reproduction 1. Launch the Hermes desktop client. 2. Do nothing (no chat, no image generation). 3. Open Activity Monitor and sort by %CPU, or run `top` / `ps aux`. 4. Observe `Hermes Helper (Renderer)` and `Hermes Helper` (GPU) processes holding 50–90% CPU continuously. The loop is present from startup (PID elapsed time ~10s already shows 70%+) and is reproducible across restarts. ## Evidence Real-time process CPU (measured twice ~10s apart, both readings high): | Process | CPU #1 | CPU #2 | |---|---|---| | Hermes Helper (Renderer) | 67.4% | 87.4% | | Hermes Helper (GPU) | 56.6% | 53.9% | | Hermes main process | 0.4% | 0.1% | | **Total Hermes** | ~109% | | `sample` of the Renderer process main thread (2s) shows the main thread stuck inside V8 continuous JIT/compile + IPC, never yielding — a classic front-end render loop: ``` 1577 Thread_...: com.apple.main-thread (serial) + 1577 ElectronMain + v8::internal::compiler::CompilationDependencies::DependOnContextCell + v8::Value::IsFunction() + mach_msg (blocked on IPC, repeated 1203x in 2s sample) ``` The GPU helper is pinned by `CVDisplayLink` (per-display-refresh compositing) with a continuously queued Metal command stream — i.e. the UI re-composites every frame even while idle. Note: the backend (`hermes serve` / `hermes gateway`) stays at ~0.1% CPU, so this is purely a front-end (Electron renderer) issue, not model inference. Image generation itself runs in the fal.ai cloud (`use_gateway: true`); the local cost is only decoding/rendering the returned image, which is amplified by the pre-existing idle spin. ## Expected When idle, the desktop client should consume near-0% CPU and not appear as a top energy user in the macOS power menu. ## Actual Renderer + GPU helpers sustain 50–90% CPU indefinitely from launch, causing constant fan/heat and high energy impact. ## Suspected cause (for triage) A self-triggering re-render loop in the front-end (e.g. a component that mutates its own state during render, or a polling/heartbeat subscription that fires whole-page re-renders with `--disable-background-timer-throttling` enabled). Happy to provide full `sample` output or spindump if helpful.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/73082",
        "timestamp": "2026-08-13T11:57:07Z",
        "metrics": {
          "reactions": 1,
          "comments": 12
        },
        "labels": [
          "type/perf",
          "P2",
          "comp/desktop",
          "area/usage-cost"
        ],
        "author": "Heybinshao",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:75992",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": ".update-incomplete left after every update: uv exclude-newer filters packages with no upload date (setuptools/pillow/mcp)",
        "text": "**Summary** Every `hermes update` (including the desktop in-app update path) leaves `~/.hermes/hermes-agent/.update-incomplete` behind. The next launch prints: > A previous `hermes update` was interrupted mid-install — finishing dependency installation now... and then fails with `✗ Could not auto-recover the interrupted install.` The marker never clears because startup recovery runs the same filtered resolution and hits the same wall. **Root cause** `pyproject.toml` (`[tool.uv]`) sets `exclude-newer = \"14 days\"`. During dependency install, uv filters packages whose PyPI metadata is missing an upload date (or whose upload date falls inside the window). Observed filtered packages: `setuptools-82.0.0` / `82.0.1`, `pillow 12.3.0`, `mcp-1.28.1`. The resolution can't complete → the update writes `.update-incomplete` → every later launch's recovery retries with the same filter and fails again. Log evidence (`~/.hermes/logs/desktop.log`): ``` [hermes] warning: setuptools-82.0.0.tar.gz is missing an upload date, but user provided: 2026-07-18T07:33:43.69822Z [hermes] warning: setuptools-82.0.1-py3-none-any.whl is missing an upload date, but user provided: 2026-07-18T07:33:43.69822Z [hermes] hint: `setuptools` was filtered by `exclude-newer` to only include packages uploaded before 2026-07-18T07:33:43.705738Z. Consider using `exclude-newer-package` to override the cutoff for this package. ``` **Why it recurs on every update** The filter applies on every dependency install. `exclude-newer-package` already whitelists `vercel`, `nemo-relay`, `huggingface_hub`, but not the packages the resolver currently needs (`setuptools`, `pillow`, `mcp`), so the same failure reproduces after every update. **Suggested fix** Add the affected packages to `exclude-newer-package` (uv's own hint suggests exactly this): ```toml exclude-newer-package = { vercel = false, nemo-relay = false, huggingface_hub = false, setuptools = false, pillow = false, mcp = false } ``` Alternatively pin an absolute cutoff timestamp or widen the window. **Impact** Low but noisy: the core venv is actually healthy (manual `pip install -e '.[all]'` succeeds, and the marker can then be removed); every launch prints the recovery banner + a failure. Users on the GUI/in-app update path hit this repeatedly after each update. **Environment** - macOS 27 (arm64), git install - Reproduced on `4be138eb0` and `57b1eb8c4` (v0.19.1) - Desktop in-app update path (`hermes update` + `hermes desktop --build-only`)",
        "url": "https://github.com/NousResearch/hermes-agent/issues/75992",
        "createdAt": "2026-08-01T07:36:29Z",
        "updatedAt": "2026-08-13T15:53:46Z",
        "timestamp": "2026-08-13T15:53:46Z",
        "metrics": {
          "reactions": 0,
          "comments": 5
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "area/config",
          "P2",
          "sweeper:risk-compatibility",
          "area/install-update"
        ],
        "author": "zhiang34",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:76312",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Playwright Chromium installation hangs indefinitely at \"extracting archive\" on Node 26 (Playwright 1.58.2, CachyOS)",
        "text": "### Bug Description ## Bug Description Fresh installation of Hermes Agent on CachyOS consistently hangs during the Playwright Chromium installation. The Chromium ZIP downloads successfully and passes integrity verification, but the installer never progresses beyond: ``` pw:install extracting archive ``` The process remains alive indefinitely but makes no further progress. I believe this may be related to the current upstream Playwright/Node extraction issue, but I wanted to provide a fully reproducible Hermes installation report. --- ## Environment * Hermes Agent: latest `main` (as of 2026-08-01) * OS: CachyOS (Arch-based) * Filesystem: btrfs * Shell: fish * Python: 3.11.15 * Node.js: 26.4.0 (CachyOS package) * npm: 12.0.1 * Playwright: 1.58.2 * Playwright Core: 1.58.2 --- ## Installation Installed using the official installer: ```bash curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` The installer initially failed because the repository `uv.lock` required regeneration while `uv sync --locked` was being used. Running: ```bash cd ~/.hermes/hermes-agent ~/.hermes/bin/uv sync --extra all --locked ``` completed successfully. After that, the repository was fully up to date (`git pull`) and `npm install` completed successfully. --- ## Reproduction Running: ```bash npx playwright install chromium ``` always hangs after: ``` Downloading Chrome for Testing ... 100% pw:install extracting archive ``` --- ## Diagnostics ### ZIP integrity The downloaded archive validates successfully: ```bash unzip -t /tmp/playwright-download-*/playwright-download-chromium-ubuntu24.04-x64-1208.zip ``` Result: ``` No errors detected in compressed data. ``` --- ### Process state The Playwright process remains alive indefinitely. CPU usage stays around: ``` 0.1–0.2% ``` for more than 10 minutes. No meaningful CPU activity occurs. --- ### Files being accessed `lsof` shows: * ZIP file still open for reading. * Output file opened for writing: ``` WidevineCdm/_platform_specific/linux_x64/libwidevinecdm.so ``` --- ### Extraction never progresses Directory remains permanently at approximately: ``` 18 MB 14 files ``` No additional files are created. --- ### Disk space Sufficient free space exists on both: * /tmp * home filesystem Filesystem is btrfs. --- ## Additional observations Updating Hermes to the latest `main` does not change the Playwright version. Current pinned versions remain: ``` playwright 1.58.2 playwright-core 1.58.2 @playwright/test 1.58.2 ``` --- ## Possible upstream relation This appears similar to recent Playwright browser installation hangs reported with newer Node releases where the download completes but extraction never finishes. If useful, I can provide additional diagnostics or test a proposed fix. ### Steps to Reproduce ## Reproduction Running: ```bash npx playwright install chromium ``` always hangs after: ``` Downloading Chrome for Testing ... 100% pw:install extracting archive ``` ### Expected Behavior ### Expected Behaviour The Playwright browser installation should complete successfully after downloading Chromium. After running: ```bash npx playwright install chromium ``` the installer should: * Download the Chromium archive. * Extract the archive. * Install Chromium into `~/.cache/ms-playwright`. * Exit successfully, allowing Hermes browser tools to be used. ### Actual Behavior ### Actual Behaviour The Chromium archive downloads successfully to 100%, but the installation hangs indefinitely during the extraction phase. The final output is: ```text Downloading Chrome for Testing ... 100% pw:install extracting archive ``` The process remains running but makes no observable progress: * CPU usage remains approximately 0.1–0.2%. * The extracted directory remains at approximately 18 MB (14 files) and does not grow. * The downloaded ZIP passes integrity verification (`unzip -t` reports no errors). * The process continues indefinitely until manually terminated with `Ctrl+C`. ### Affected Component Setup / Installation ### Messaging Platform (if gateway-related) N/A (CLI only) ### Debug Report ```shell Debug report uploaded: Report https://paste.rs/gCUXd agent.log https://paste.rs/D1rwA ``` ### Operating System Linux 7.1.5-1-cachyos x86_64 ### Python Version 3.11.15 ### Hermes Version 0.19.1 ### Additional Logs / Traceback (optional) ```shell ``` ### Root Cause Analysis (optional) _No response_ ### Proposed Fix (optional) _No response_ ### Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
        "url": "https://github.com/NousResearch/hermes-agent/issues/76312",
        "createdAt": "2026-08-01T17:15:50Z",
        "updatedAt": "2026-08-13T13:02:34Z",
        "timestamp": "2026-08-13T13:02:34Z",
        "metrics": {
          "reactions": 2,
          "comments": 5
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "tool/browser",
          "P2",
          "area/install-update"
        ],
        "author": "InforesightConsulting",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:76508",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Add parent-scoped list, cancel, and steer controls for live async delegations",
        "text": "## Problem A parent agent can dispatch background work with `delegate_task` and receives a complete `deleg_...` handle, but it has no model-facing way to enumerate, cancel, or redirect that live delegation afterward. Operator surfaces such as `/agents` and the TUI can provide visibility or controls, but a user speaking to the parent through Telegram, another gateway, or the CLI cannot simply ask the parent to list, steer, or cancel its own live work through an authorized in-process path. A terminal command or skill cannot safely bridge this gap: cancellation and steering depend on in-memory callbacks owned by the running Hermes process. Exposing those callbacks without a strict parent authority boundary would create a confused-deputy control surface. ## Proposed contract Add one deferred model-facing control surface for live asynchronous delegations: ```text delegate_control(action=\"list\") delegate_control(action=\"cancel\", id=\"deleg_...\") delegate_control(action=\"steer\", id=\"deleg_...\", message=\"...\") ``` List, cancel, and steer belong together because they share authority resolution, target semantics, lifecycle races, and delegation-ID handling. ### Authority - Capture ownership only from trusted runtime provenance at dispatch. - Define an owner as `(canonical profile namespace, compression-only parent-conversation root)`. - Preserve authority across genuine context-compression continuations, while rejecting branches, delegate/subagent/tool sessions, siblings, ambiguous ancestry, cycles, and foreign profiles. - Never accept profile, owner, session, lineage, or cursor values from model arguments as authority. - Treat a complete native `deleg_<8 lowercase hex>` ID as an actionable handle, not authorization. - Return the same opaque `not_found` response for malformed, foreign, missing, terminal, and otherwise inaccessible IDs. ### List - Return only live delegations owned by the current parent. - Reuse the existing async-delegation registry rather than creating a parallel lifecycle store. - Paginate explicitly and report `total_live`, `truncated`, and `next_cursor`. - Allowlist only the complete delegation ID, live status, batch flag, dispatch time, and bounded goal/role previews. - Exclude prompts, context, results, errors, routes, models, owner/session fields, callbacks, credentials, and delivery internals. - Never claim, consume, suppress, replay, or duplicate terminal completion delivery. ### Cancel - Request cooperative interruption; do not imply hard thread termination. - Serialize cancellation and steering per live delegation. - Report `pending` while a competing control callback is in flight. - Report `accepted` only after interruption was successfully requested. - If a callback may have applied an irreversible partial interruption before raising, report a stable non-retriable `indeterminate` state rather than retrying blindly or claiming success. - Ensure session shutdown, `/stop`, and stale-lifecycle interruption cannot miss a child temporarily executing a steering callback. ### Steer - Queue bounded guidance through the existing safe steering seam. - Enforce both per-message and cumulative per-live-delegation limits at runtime. - For batches, target only active children and report `accepted` only when at least one child actually accepts while the delegation remains steerable. - Let lifecycle cancellation supersede an overlapping steer so both operations cannot report acceptance. - Preserve native completion behavior when guidance arrives too late to be consumed. ### Footprint This capability requires structured access to live in-process callbacks and cannot be implemented safely through `terminal` plus a skill. To keep the core narrow, register it in the existing `delegation` toolset while keeping it outside `_HERMES_CORE_TOOLS`, allowing normal progressive tool disclosure through `tool_search`. Parent/orchestrator conversations may receive the capability according to existing delegation-toolset policy. Leaf agents must not inherit parent control authority. ## Related work - #70899 adds TUI Mission Control visibility and live steering. This proposal is adjacent rather than duplicative: it provides a model-facing, parent-authorized surface usable from Telegram and other agent conversations. Shared accepted steering seams should be reused where practical. - #76390 is a Telegram-specific, read-only activity-panel proposal. Informational visibility remains separate from authority-bearing lifecycle control. - Existing `/agents`, subagent interruption, `/stop`, shutdown, and native terminal-result delivery remain intact. ## Acceptance criteria 1. A parent can list only its own live asynchronous delegations and receives complete native delegation IDs. 2. Authority uses trusted canonical profile provenance plus a proven compression-only parent-conversation root. 3. Genuine compression preserves authority; branches, delegate/subagent/tool sessions, siblings, cycles, ambiguous lineage, and foreign profiles do not inherit it. 4. Malformed, foreign, missing, terminal, and inaccessible targets are indistinguishable through the control API. 5. Listing is bounded, allowlisted, and explicitly paginated without exposing sensitive internals. 6. Cancellation and steering are serialized without invoking callbacks under global registry or child-lifecycle locks. 7. Cancellation callback failures cannot trigger blind retries or false success after potentially partial interruption. 8. Session/global lifecycle interruption supersedes overlapping steering and cannot leave an active child orphaned. 9. Steering enforces runtime message and cumulative budgets and targets only active batch children. 10. Terminal completion delivery remains native and independent of listing/control. 11. The tool is deferred in the existing `delegation` toolset, absent from `_HERMES_CORE_TOOLS`, and unavailable to leaf agents. 12. Focused race, authority, pagination, disclosure, lifecycle, and real temporary-`HERMES_HOME` integration tests cover the complete contract. ## Non-goals - No TUI panel, Telegram-specific command, or transport-specific control implementation. - No model-facing internal `subagent_id` API. - No hard thread termination. - No pause/resume scheduler or global spawn manager. - No terminal-result retrieval or history API. - No persistence or restart resurrection of live callbacks. - No new configuration key, environment variable, dependency, database migration, or parallel delegation registry.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/76508",
        "createdAt": "2026-08-02T01:31:31Z",
        "updatedAt": "2026-08-13T17:15:31Z",
        "timestamp": "2026-08-13T17:15:31Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/feature",
          "comp/tools",
          "tool/delegate",
          "P3",
          "needs-decision"
        ],
        "author": "0xbWy",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:78193",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: issue starting latest container image - v2026.8.3",
        "text": "### Bug Description Starting the latest build of the docker container result on error > Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: \"/opt/hermes/docker/entrypoint-dispatch.sh\": stat /opt/hermes/docker/entrypoint-dispatch.sh: no such file or directory ### Steps to Reproduce 1. Pull latest build of docker container (v2026.8.30) 2. Start the container ### Expected Behavior Run / Start successfully without error ### Actual Behavior Error / Can't start the container ### Affected Component Setup / Installation ### Messaging Platform (if gateway-related) _No response_ ### Debug Report ```shell Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: \"/opt/hermes/docker/entrypoint-dispatch.sh\": stat /opt/hermes/docker/entrypoint-dispatch.sh: no such file or directory ``` ### Operating System Ubuntu 24.04 ### Python Version _No response_ ### Hermes Version 0.20.0 ### Additional Logs / Traceback (optional) ```shell ``` ### Root Cause Analysis (optional) _No response_ ### Proposed Fix (optional) _No response_ ### Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
        "url": "https://github.com/NousResearch/hermes-agent/issues/78193",
        "timestamp": "2026-08-13T11:47:43Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "backend/docker",
          "area/docker",
          "P2",
          "bug"
        ],
        "author": "agustinusnathaniel",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:78449",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Buzz gateway",
        "text": "### Bug Description When I mention Hermes in a Buzz channel, it correctly creates a new thread for the conversation. However, subsequent messages in that thread are ignored unless I mention Hermes again. Mentioning Hermes again causes it to create yet another nested thread instead of continuing the existing conversation. This results in one additional thread level for every message, making conversations extremely difficult to follow. I expected Hermes to continue responding within the existing thread without requiring additional mentions. ### Steps to Reproduce There is currently no workaround to mitigate this issue. ### Expected Behavior Hermes should continue responding within the existing thread without requiring additional mentions or creating nested threads. ### Actual Behavior Hermes ignores messages in the thread unless it is mentioned again. Each new mention creates another nested thread instead of continuing the existing conversation. ### Affected Component Gateway (Telegram/Discord/Slack/WhatsApp) ### Messaging Platform (if gateway-related) _No response_ ### Debug Report ```shell The debug report could not be fully generated because the debug uploader also appears to be broken. Only the main report was uploaded successfully. Uploaded report: * https://dpaste.com/B3PXX4UTS The following log uploads failed: * `agent.log` * `gateway.log` * `gui.log` * `desktop.log` Errors: * `paste.rs`: HTTP 500 (Internal Server Error) * `dpaste.com`: HTTP 400 (Bad Request) ``` ### Operating System Windows 10 Pro ### Python Version Python 3.11.15 ### Hermes Version Hermes Agent v0.20.0 (2026.8.3) · upstream f5be9236 · local fb4e17b1 (+10 carried commits) ### Additional Logs / Traceback (optional) ```shell ``` ### Root Cause Analysis (optional) _No response_ ### Proposed Fix (optional) _No response_ ### Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
        "url": "https://github.com/NousResearch/hermes-agent/issues/78449",
        "timestamp": "2026-08-13T11:21:13Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "P3",
          "needs-decision",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery"
        ],
        "author": "skappafrost",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:81484",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Desktop sidebar: 'Load more' (⋯) never appears when pinned sessions occupy the recency window — older sessions unreachable",
        "text": "## Bug Description In the desktop sidebar, the \"Load more\" (`⋯`) row **never appears** when pinned sessions happen to sit *inside* the most-recent recency window — even when dozens of older sessions exist beyond the page. Those older sessions become unreachable from the list UI entirely (FTS search still finds them, but the sidebar list cannot load them). ## Environment - Hermes Desktop (latest, 2026-08-08) + runtime at current `main` - Windows 10, single profile (`default`) - Reproduced against a live state.db ## Steps to Reproduce 1. Have more than 50 non-archived sessions (139 in my case, oldest from 2026-05-22). 2. Pin 3 sessions that are **recent enough to fall inside the most-recent-50 recency window** (my 3 pins all do). 3. Open the sidebar. Backend returns a full window of 50 rows — but 3 of them are the pinned ones. 4. The `⋯` load-more row does **not** render. Clicking is impossible; the 92 sessions older than the window (May–July) can never be reached from the list. ## Root Cause The `profiles_truncated` flag discounts pinned rows, but pinned rows still **consume LIMIT slots** inside the window: `hermes_cli/web_routers/profiles.py:334-335` (batched `/api/profiles/sessions/sidebar`): ```python unpinned_count = sum(1 for s in profile_rows if not s.get(\"pinned\")) recents_truncated[name] = unpinned_count >= recents_cap ``` Verified live: `list_sessions_rich(limit=50, min_message_count=1, order_by_last_active=True, include_pinned=True)` returns **50 rows: 3 pinned + 47 unpinned**. `47 >= 50` → `False` → `profiles_truncated = false` → frontend `hasMoreSessions=false` → the load-more row (`apps/desktop/src/app/chat/sidebar/load-more-row.tsx`, rendered only when `hasMoreSessions`) never mounts. The legacy per-slice fallback (`apps/desktop/src/hermes.ts` `profilesTruncatedFrom`) has the same discount logic, so both paths are affected. The pinned discount was introduced to stop back-filled pins *past* the LIMIT faking a full page on a short list — but discounting pins that occupy *in-window* slots hides real data instead. ## Expected Behavior \"Load more\" should appear whenever the window is full of rows, i.e. the flag should answer \"is the window full?\" rather than \"are there ≥ cap unpinned rows?\". Suggested fix: ```python recents_truncated[name] = len(profile_rows) >= recents_cap ``` (same idea as the `globalTruncated` proposal in #72494, applied per-profile in the batched endpoint). The degenerate case — window exactly full with nothing more on disk — resolves gracefully: load-more returns an empty page and the frontend already suppresses the button when no new rows arrive. ## Related - #44009 / #44015 (closed, unmerged): the *opposite* failure of the same discount logic — \"Load N more\" never disappears. Still reproducible at current `main`. - #72492 / #72494 (open): multi-profile legacy-path truncation miscount — related but distinct (batched endpoint is affected here, single profile).",
        "url": "https://github.com/NousResearch/hermes-agent/issues/81484",
        "createdAt": "2026-08-08T03:21:07Z",
        "updatedAt": "2026-08-13T13:00:52Z",
        "timestamp": "2026-08-13T13:00:52Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P2",
          "sweeper:risk-session-state",
          "comp/desktop",
          "area/sessions",
          "area/profiles"
        ],
        "author": "kiwiwyl",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:83390",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Auxiliary title_generation fails on DeepSeek: HTTP 400 \"This response_format type is unavailable now\"",
        "text": "# Auxiliary title_generation fails on DeepSeek: HTTP 400 \"This response_format type is unavailable now\" ## Summary When `auxiliary.title_generation` is configured with `provider: auto` (or explicitly routed to DeepSeek as the main model), the auxiliary title generation task fails with an HTTP 400 error from the DeepSeek API: ``` This response_format type is unavailable now ``` Hermes Agent v0.20.0. Environment: WSL, DeepSeek as primary model provider. ## Root cause 1. `agent/title_generator.py` (lines ~81–93) hardcodes a `json_schema` response format for title generation: ```python _TITLE_RESPONSE_FORMAT = { \"type\": \"json_schema\", \"json_schema\": {...}, ... } ``` 2. At line ~378, `response_format` is unconditionally passed to the underlying chat completions call: ```python extra_body={\"response_format\": ...} ``` 3. The DeepSeek API does **not** support the `json_schema` response format type — it only supports `text` and `json_object`. Sending `{\"type\": \"json_schema\", ...}` returns HTTP 400 `This response_format type is unavailable now`. ## Why the existing fallback logic does not save us - The exception-handling chain in `auxiliary_client.py` only retries/degrades for: HTTP 402 (insufficient balance), transient errors, `temperature` / `max_tokens` parameter issues. - A `400 invalid_request_error` caused by the unsupported `response_format` is **not** handled there, so it propagates straight up and the title-generation task fails outright. - The \"loose JSON scan\" fallback only runs **after** a successful HTTP response; it cannot help when the request itself is rejected with 400. ## Reproduction ```bash curl https://api.deepseek.com/v1/chat/completions \\ -H \"Authorization: Bearer $DEEPSEEK_API_KEY\" \\ -H \"Content-Type: application/json\" \\ -d '{ \"model\": \"deepseek-chat\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}], \"response_format\": { \"type\": \"json_schema\", \"json_schema\": {\"name\": \"x\", \"schema\": {\"type\": \"object\", \"properties\": {}}} } }' ``` Response: `400 Bad Request` — `This response_format type is unavailable now`. ## Expected behavior / suggested fix - Detect (or allow configuration of) providers that do not support `json_schema` and degrade gracefully: - Retry the request **without** `response_format`, or - Fall back to `{\"type\": \"json_object\"}` **and** ensure the prompt contains the literal word `json` (DeepSeek requires the prompt to mention \"json\" for `json_object` mode). - Apply this capability detection/degradation to **all** auxiliary tasks that currently hardcode `json_schema` (title generation, summarization, etc.), not just title generation. - Alternatively, catch `400 invalid_request_error` mentioning `response_format` in `auxiliary_client.py`'s retry chain and retry with a degraded format. ## Environment - Hermes Agent v0.20.0 - Provider: DeepSeek (`deepseek-chat` / `deepseek-reasoner`) - OS: WSL (Windows Subsystem for Linux) Thanks for the great project!",
        "url": "https://github.com/NousResearch/hermes-agent/issues/83390",
        "createdAt": "2026-08-10T18:47:39Z",
        "updatedAt": "2026-08-13T15:47:03Z",
        "timestamp": "2026-08-13T15:47:03Z",
        "metrics": {
          "reactions": 2,
          "comments": 7
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/deepseek",
          "P3"
        ],
        "author": "scottchan60-netizen",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:83683",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Desktop restart reaps the live gateway but never relaunches it (WeChat/QQ go silent) — regression",
        "text": "## Summary On Windows (Hermes 0.20.0, desktop app), **every desktop-app restart force-kills the running messaging gateway and never relaunches it**. WeChat (iLink), QQ bot and Telegram go completely silent until the gateway is manually restarted. This is a regression: before this version, the old gateway survived desktop restarts (reparented), so messaging stayed online. ## Root cause In `hermes_cli/web_server.py`, when the desktop backend starts (`HERMES_DESKTOP=1`), it calls `_reap_unsupervised_gateway_orphans()` before boot, with the comment *\"Before forking a fresh gateway, reap any orphan left by a previous serve session... (#77276)\"*. However: 1. `_reap_unsupervised_gateway_orphans()` → `find_gateway_pids()` → `_scan_gateway_pids()` matches **any** process whose command line looks like `hermes_cli.main gateway run` (`looks_like_gateway_command_line`), including gateways started by the Windows scheduled task (`Hermes_Gateway`) or manually via `hermes gateway run`. It writes the planned-stop marker, sends SIGTERM, then SIGKILLs survivors after 5s. 2. The comment says \"before **forking** a fresh gateway\", but `hermes serve` does **not** spawn a gateway at all (per the same file: *\"The Desktop backend is spawned as `hermes serve` — it runs neither the interactive CLI nor the messaging gateway\"*). Gateway startup is delegated to external mechanisms (the login scheduled task, the update flow, or a manual `hermes gateway run`). 3. Result: **every desktop restart reaps the live gateway with no successor**. The gateway exits uncleanly (`gateway-exit-diag.log`: *\"exited UNCLEANLY (no exit path ran — SIGKILL / OOM / VM death)\"*), and `.gateway-planned-stop.json` is left behind. ## Steps to reproduce 1. Start the gateway (login scheduled task or `hermes gateway run`). WeChat/QQ/Telegram connect fine. 2. Close and reopen the Hermes desktop app. 3. The gateway process is gone (no exit logs; killed externally). Messages sent via WeChat/QQ get no reply until the gateway is manually restarted. Secondary symptom: the third-party skin plugin (`skin-studio`) re-asserts the configured skin on boot but only retries for ~2.5 s, while the desktop backend takes longer to become ready — so after a desktop restart the skin silently falls back to default until manually re-applied. Same class of \"desktop restart breaks things that depend on the gateway/backend\". ## Suggested fixes - **Option A**: when the desktop reap kills a live gateway, the desktop backend (or Electron main) should spawn a replacement immediately after reap (complete the \"fork a fresh gateway\" the comment promises). - **Option B**: only reap gateways that are truly unsupervised (no `gateway.pid` record / not the login scheduled-task instance), instead of every process matching `gateway run`. - **Option C**: add a config switch (e.g. `gateway.reap_on_desktop_start: false`) so users who manage the gateway externally (scheduled task / systemd / manual) can opt out. ## Workaround (user side) A 2-minute Hermes-internal cron job that checks for the gateway process and relaunches it detached with the full environment. Works, but leaves a gap and depends on the desktop backend's own cron scheduler. ## Environment - Windows 11, Hermes 0.20.0 (commit 33f8e96a72), desktop app, Python 3.12 - Platforms: WeChat (iLink), QQ Bot, Telegram (via local proxy) - `HERMES_DESKTOP=1` serve backend; gateway launched via `python.exe -m hermes_cli.main gateway run`",
        "url": "https://github.com/NousResearch/hermes-agent/issues/83683",
        "createdAt": "2026-08-11T04:56:35Z",
        "updatedAt": "2026-08-13T13:32:31Z",
        "timestamp": "2026-08-13T13:32:31Z",
        "metrics": {
          "reactions": 0,
          "comments": 16
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/gateway",
          "P1",
          "sweeper:risk-message-delivery",
          "sweeper:risk-platform-windows",
          "comp/desktop",
          "platform/windows"
        ],
        "author": "zuowen7",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:84047",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Triage: the seven mechanisms behind the 77 open stall/hang reports",
        "text": "I read every open issue whose title says timeout/stall/hang/stuck/freeze/deadlock — 77 of them as of yesterday. Most are not 77 bugs. The runtime ones collapse into seven mechanisms, and about a third of the pile isn't a runtime stall at all (it's the installer). Classification below, with what's covered by an in-flight PR and what has nothing. Method: title regex over open issues, read all 77 bodies, grouped by mechanism rather than surface (a Telegram hang and a cron hang from the same cause are one row). I read the code for the anchor of each family; the ones I didn't verify are marked. Corrections welcome — some of these classifications are judgment calls. ## A. Something synchronous runs on the asyncio loop thread The gateway/dashboard event loop gets blocked by sync work, and everything downstream misreads the freeze as its own bug. This family had a closed P1 canonical — #72707 (@stalker-cod), closed 2026-07-29 — but three of the members below were filed after that closure. The class outlived its fix. | issue | what blocks | fix in flight | |---|---|---| | #82874 (@imapotato123) | `shutdown_mcp_servers()` blocks on `future.result(timeout=15)` at SIGTERM | #82892, #83054 | | #82324 (@Sam-dancing-nu1) | compression LLM call blocks the gateway, TUI freezes silently | #82292 | | #81642 (@sts-change, P1) | stalled loop means `hermes update` waits 180s then deadlocks | #81652, #82039, #82574 | | #83208 (@dw361) | 83.9s dashboard loop stall on a headless N100 | none | | #81123 (@Shizuku1648) | unreachable provider endpoint stalls the loop 10s+, `ws write slow` | none | | #81410 (@xiejin77) | OAuth refresh returns invalid_grant after a loop stall (victim, not cause) | none | ## B. Adapter init/retry gates that never close Telegram connect hangs at \"attempt 1/8\" forever. Main already ships a real defense here — `_await_with_thread_deadline` (plugins/platforms/telegram/adapter.py:97) uses a `threading.Timer` so the deadline doesn't depend on loop timers, and abandons cancellation-shielded init tasks. #80632 (@alexdantas-oliveira, P1) reports the wrapper itself never returning; I didn't manage to reconcile that with the current code, so it needs a version check against the reporter's build. #82558 (@wtfbinga, no-global-IPv6 hosts) and #80632 share fix PR #82626. #82627 (@acorzor, P1, macOS 26, both polling and webhook modes) has no PR. Adjacent: #80336 (@datalek) is the inverse failure — Matrix sync latches a transient TLS error as permanent and stops forever. ## C. stdio child processes (MCP, LSP) dying or wedging silently | issue | mechanism | fix in flight | |---|---|---| | #81995 (@menhguin) | stalled MCP cold-spawn leaves the in-flight tool call attached to a dead subprocess | #82071, #82219 | | #80405 (@lecherfrog) | Windows: anyio pipe I/O deadlock hangs every stdio MCP server, empty error | none | | #81051 (@tveheim) | OAuth MCP connection permanently \"parked\" after a teardown lock race | none | | #82602 (@fangliquanflq) | LSP client stays cached after the server loses stdout; every later call times out | #82604 | ## D. The timeout machinery itself is wrong at the edges - #82846 (@naveeda11): smart-approval auxiliary LLM call has no enforced timeout. One stalled provider response wedges the session indefinitely. No PR. - #83220 (@CaseyTso): large `approvals.timeout` overflows `time_t` in `threading.Lock.acquire(timeout=...)` on macOS and kills every parallel tool batch. Fixes: #83225, #83298. - #81048 (@Red-MPL): approval timeout is recorded as explicit user denial. Security framing is theirs and it's correct — silence is not a decision. No PR. - #80521 (@SeanGearin): dashboard MCP OAuth consent slower than the connect timeout lands on a dead flow. No PR. Didn't verify this one against code. - #83807 (@kouhe3): cosmetic but confusing — a millisecond timeout arg renders as `40000s` so users think the tool ran 11 hours. ## E. Clarify/approval replies never reach the waiting turn — zero PRs on the whole family This is the family I'd flag for attention: three issues, all reproduce, none has a fix in flight. - #83889 (multiplexed Feishu groups): clarify `wait_for_response` deadlock plus a session-key mismatch that breaks the clarify intercept under multiplexing. The reporter's line refs check out. The key mismatch overlaps the profile-binding class tracked in #82936 (row: clarify keys, #83346). - #83319 (@cycocyco): Desktop HUD mode, clarify dialog appears, vanishes, turn never resumes. - #83443 (@DimaRodnoy): remote Desktop terminal approvals time out with no visible prompt. Regression of #46526. ## F. Frontend stream lease / half-dead renderer sockets - #83420 (@marcatmadu, P1): Windows Desktop freezes when the window is minimized or occluded — renderer WebSocket goes half-dead, backend sees `ws_orphan_reap`. Fix in flight: #83540. - #83716 (@CharlesR-sudo): pop-out window steals the session's event stream; original window never re-binds. No PR. - #81286 (@ANDYPENG09): Web Dashboard + Desktop on one session, last-to-type captures the lease, the other side looks frozen. #81395, #81568. - #83291 (@ltx15382710886-png): image pre-analysis blocks turn dispatch. Could belong in D (it's a missing timeout on the vision call). Judgment call. ## G. Provider stream stalls wedge the CLI - #81521 (@fivves, P1): provider returns HTTP 200 with zero chunks and no finish_reason; CLI pegs a core and deadlocks, TUI corrupts. Regression in 2026.8.3. Fixes: #81601, #81750. - #83828 (@Leon0555): removing a provider from config leaves bound sessions spinning in the model picker forever. No PR. ## H. Windows updater vs locked files — one mechanism, five issues The updater doesn't handle Windows file locking. Every one of these is that: #83149 (@bgexpert, P1 — failed venv rename falls back to a destructive in-place delete, no rollback, install left at 2/212 packages; fixes #83187/#83191/#83194), #83211 (@O0oHuX — no pre-check for a running hermes.exe, os error 32 mid-update), #81594 (@b31978 — locked `_cffi_backend.pyd`, os error 5, infinite repair loop on every boot), #84005 (@esadsadikovic — postinstall exit 124), #83674 (@pronzcw — updater window \"Not Responding\", topmost, unhideable). If one PR added lock detection + stop-processes-first + rollback, most of these close together. ## I. lazy_deps pins and refresh Mostly install-time noise, but one real runtime member: #80468 (@pittosporum-seu) — STT lazy-install has no negative cache, so on a host where the install can't succeed, every incoming voice message re-runs the full pip/uv ladder. That's a self-inflicted freeze. Fix in flight: #80558. ## Didn't fit - #81625 (@Miznov-git, P1): gateway grows to ~60GB RSS in 2h cycles and OOM-freezes the machine. Real and severe, but it's a leak, not a stall family. - #81518 (@aboooo12): half-dead pooled connections behind a transparent proxy. Didn't title-match my sweep (found it via an older scan). Fix in flight: #81545. Arguably family-A-adjacent. - #80471 (@uraniumchonk): null byte in a script path raises through lifecycle_guard into an SSE stream deadlock. Single member, real bug. - The remaining ~25 are installer/platform/deps/feature items that matched the regex but aren't runtime stalls. I have the full classification list if anyone wants it posted here. ## Summary counts 7 runtime families, 28 runtime-stall issues, 13 with no fix in flight. The two gaps I'd rank first: family E (clarify/approval, zero coverage, all reproducible) and #82627 (P1, no PR, two siblings already have a shared fix). I'll keep the tables current as PRs land or new members show up. If a maintainer would rather this live somewhere else (labels, a project board), say so and I'll move it.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/84047",
        "timestamp": "2026-08-13T12:23:19Z",
        "metrics": {
          "reactions": 0,
          "comments": 7
        },
        "labels": [
          "type/refactor",
          "comp/agent",
          "comp/gateway",
          "P3",
          "needs-decision"
        ],
        "author": "ayushnangia",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:84058",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Desktop: composer caret lost when a tool call starts streaming (silent focus/remount, no exception)",
        "text": "## Symptom In the macOS desktop app, while the user is typing in the main composer input box, the text caret disappears the instant an agent tool call begins (the \"Ran … Nms\" tool row renders / the model starts executing a command). Typing resumes only after re-clicking the input. This happens on essentially every assistant turn. It reads to the user as an app \"refresh\" — the input loses focus and the in-progress text/caret is gone — but nothing actually reloads. ## What we ruled out (forensic evidence, not theory) - **Not the `Edit already in progress` crash.** That throw (`beginEdit` re-entrant on edit-composer remount, #3652/#4051 family) WAS previously firing — 17 occurrences in `desktop.log` from older bundles. A guard `IncrementalExternalStoreThreadRuntimeCore.beginEdit` returning early when `getEditComposer(messageId)` is open was added and is confirmed present in the running asar (`index-BRhtfo9X.js`, shipped in the 15:58 bundle). After that, `desktop.log` shows **zero** `Edit already in progress` lines. - **Not a renderer crash / reload.** `window-renderer-lifecycle` logs `render-process-gone`/`unresponsive`/`did-fail-load`. Across the active instance (since 17:43 local) there are **0** such lines. The app does not `boot`/reload at the moment of the symptom. - **Not an error-boundary remount with a thrown error.** The `[renderer console]` capture in `renderer-log.ts` only logs level-3 (error). No level-3 console line appears at tool-call time in the current bundle. So whatever drops the caret is **silent** — no exception, no crash, no reload — which is why it survives the guard fix. ## Likely areas (for a maintainer who can run the app) The composer lives in `app/chat/composer/` and is intentionally rendered as a **sibling** of the thread (outside the `contain:[layout paint]` wrapper in `app/chat/index.tsx` ~line 595) so dock⇄float never remounts the editor. `focusComposerInput` (`composer/focus.ts`) correctly skips `el.focus()` when `document.activeElement === el`, and `use-composer-draft.ts:165` only re-focuses on `focusKey`/`focusRequestId`/`inputDisabled` changes (`focusKey` = `activeSessionId`, stable per session). Suspects: 1. A React reconciliation at tool-call arrival that replaces the contentEditable DOM node (caret lost even without an explicit `blur`). 2. A focus steal from elsewhere on the `thread.runStart` / message-arrival path. 3. The agent message render path (`components/assistant-ui/thread/*`, `message-parts.tsx`, `tool/*`) causing the composer subtree to remount or the editor node to be re-keyed. ## Repro 1. Open the macOS desktop app, start a fresh session. 2. Begin typing in the main composer input (do NOT submit). 3. Send any prompt that triggers a tool call (e.g. a `terminal` command), OR observe mid-typing when the agent starts executing. 4. Caret is lost the moment the tool row / streaming starts. User must click back into the box. ## Environment - macOS 26.x (desktop packaged build, asar `index-BRhtfo9X.js`, built 2026-08-11 15:58). - Main model: deepseek/deepseek-v4-flash-0731 via Nous. - auxiliary.compression: nous D0731; auxiliary.vision: openrouter nemotron-3-nano-omni:free. ## Ask Please add instrumentation / a fix so the composer caret is preserved across assistant-tool transitions (focus should only move when the user explicitly navigates away, and the contentEditable node must not be replaced during normal streaming). Happy to provide `desktop.log` + a screen recording if helpful.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/84058",
        "createdAt": "2026-08-11T18:54:18Z",
        "updatedAt": "2026-08-13T13:29:58Z",
        "timestamp": "2026-08-13T13:29:58Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "P3",
          "needs-repro",
          "comp/desktop"
        ],
        "author": "sanyamjain2014",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:84185",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Windows: gateway cold-started after 'hermes update' dies silently (no logs, no PID file, no exit record) — offline until manual restart",
        "text": "### Bug Description On Windows, after a successful `hermes update` (triggered by the Desktop app's update hand-off), the updater prints `✓ Starting Windows gateway after update (PID <n>)` — but the spawned gateway process **dies immediately and silently**: it never writes to `gateway.log`, never creates `gateway.pid`, and leaves no exit record in the gateway diagnostics. Telegram stays offline until the gateway is started manually. ### Observed - Gateway installed as a Scheduled Task (`Hermes_Gateway`, logon trigger) with hidden-console launcher scripts (`Hermes_Gateway.cmd` / `.vbs`); the gateway is a deliberate standalone installation (Telegram bot, cron). - Reproduced on 3 consecutive updates (2026-08-11 13:55, 2026-08-12 00:12, 2026-08-12 08:53, local time), each with the same fingerprint: - updater log: `✓ Starting Windows gateway after update (PID 17192)` etc. - gateway diagnostic log: a `gateway.start` entry for the new PID, **no exit entry follows** - `gateway.log`: no `Starting Hermes Gateway...` line at all — the process dies before logging is initialized - `gateway.pid`: never created - Manual recovery works every time: `hermes gateway start` spawns through the same `gateway_windows._spawn_detached()` path and the gateway runs normally (Telegram connects, logs are written, `gateway.pid` is created). ### Expected behavior A gateway cold-started after an update should actually survive, or the updater should report a failure instead of printing `✓ Starting ...`. ### Possible cause (unverified) `_cold_start_windows_gateway_after_update()` calls `_spawn_detached()` from inside the update hand-off process. The spawned process appears to be reaped when the updater process exits (job object / console lifetime). `_spawn_detached()` uses `CREATE_BREAKAWAY_FROM_JOB` but falls back to a non-breakaway spawn on `OSError`; if breakaway is denied, the child stays in the parent's job and is killed during updater teardown. The pre-logging death and the absent exit record are consistent with a hard kill. ### Environment - Windows 10 - hermes-agent 0.20.0 (`e9579a9`), uv-managed venv - Gateway via Scheduled Task (logon trigger), Telegram platform ### Related - #76129 — opposite failure of the same cold-start path: an *unwanted* standalone gateway is spawned and survives when the desktop backend (`hermes serve`) is the runtime. Here the gateway is a deliberate installation and the spawned process does **not** survive. Related but distinct symptoms; likely worth fixing together.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/84185",
        "createdAt": "2026-08-12T01:10:26Z",
        "updatedAt": "2026-08-13T17:34:17Z",
        "timestamp": "2026-08-13T17:34:17Z",
        "metrics": {
          "reactions": 0,
          "comments": 7
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/gateway",
          "P1",
          "sweeper:risk-message-delivery",
          "sweeper:risk-compatibility",
          "sweeper:risk-platform-windows",
          "comp/desktop",
          "platform/windows",
          "area/install-update"
        ],
        "author": "linfeng961",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:84741",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": ".",
        "text": ".",
        "url": "https://github.com/NousResearch/hermes-agent/issues/84741",
        "timestamp": "2026-08-13T12:04:04Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/security",
          "comp/cli",
          "comp/gateway",
          "area/auth",
          "P2",
          "needs-repro",
          "sweeper:risk-security-boundary",
          "area/profiles"
        ],
        "author": "JoaoMarcos44",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:84742",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": ".",
        "text": ".",
        "url": "https://github.com/NousResearch/hermes-agent/issues/84742",
        "timestamp": "2026-08-13T12:02:48Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/security",
          "area/auth",
          "P2",
          "sweeper:risk-security-boundary",
          "comp/dashboard"
        ],
        "author": "JoaoMarcos44",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:84744",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": ".",
        "text": ".",
        "url": "https://github.com/NousResearch/hermes-agent/issues/84744",
        "createdAt": "2026-08-12T19:01:27Z",
        "updatedAt": "2026-08-13T13:11:29Z",
        "timestamp": "2026-08-13T13:11:29Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/security",
          "comp/gateway",
          "P2",
          "needs-repro",
          "sweeper:risk-session-state",
          "sweeper:risk-security-boundary"
        ],
        "author": "JoaoMarcos44",
        "state": "closed",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:84745",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": ".",
        "text": ".",
        "url": "https://github.com/NousResearch/hermes-agent/issues/84745",
        "timestamp": "2026-08-13T12:04:17Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/security",
          "comp/agent",
          "comp/gateway",
          "comp/tools",
          "area/auth",
          "P2",
          "needs-repro",
          "sweeper:risk-security-boundary",
          "area/profiles"
        ],
        "author": "JoaoMarcos44",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:84748",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": ".",
        "text": ".",
        "url": "https://github.com/NousResearch/hermes-agent/issues/84748",
        "timestamp": "2026-08-13T12:03:21Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/security",
          "area/auth",
          "P2",
          "needs-repro",
          "sweeper:risk-security-boundary",
          "comp/dashboard"
        ],
        "author": "JoaoMarcos44",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:84834",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Webhook Revolution — graph-gated repair campaign (meta-issue)",
        "text": "# Hermes Webhook Revolution — Campaign Meta-Issue (EPIC) Graph-gated 5×2×3 repair campaign for the entire Hermes webhook surface: ingress, execution, delivery, configuration, management UI, deployment, and documentation. **Canonical plan:** [Hermes Webhook Revolution](https://hermes-webhook-revolution.axlibiza.chatgpt.site) — 20 execution tasks, 5×2×3 graph-gate geometry, 2K file ceiling. **Pinned base:** `origin/main` at `1d3d021282098261ce2ad224a76d97d89b16188c` (re-pinned from plan-time `b3aa561`; base moved, evidence re-pinned per plan rule). ## Campaign table (canonical state) | Task | Status | PR | Notes | |------|--------|----|-------| | 1. Storage + pinned workspace | ✅ | — | D:-hosted worktree, rebased 4 shipped commits onto main | | 2. Source/issue/PR graph | 🔄 | — | Materializing | | 3. Contract + Master Meta-Lock | 🔄 | — | This EPIC | | 4. Extract webhook REST routes | ✅ | — | `hermes_cli/web_routers/webhooks.py` (shipped, rebased) | | 5. Extract WebhookAdapter seam | 🔄 | — | profile admission done; auth/ingress/rendering/delivery pending | | 6–20 | ⬜ | — | Pending | | Profile-admission allowlist propagation regression | ✅ | #85054 | repaired on all 7 live branches; exact heads and CI linked below | ## Shipped work (rebased onto main) Controller branch `campaign/webhook-revolution-b3aa561f` carries 4 commits: - `refactor(web): extract webhook API router without behavior change` - `fix(web): preserve webhook summary monkeypatch seam` - `refactor(webhook): extract profile admission policy` - `fix(webhook): preserve profile annotation resolution` Seam tests pass on rebased base: `tests/gateway/test_webhook_profile_admission_seam.py` + `tests/test_web_server_webhooks_seam.py` → 5 passed. ## Issue-family routing (from plan §5) - Effective config/setup: #13240, #24911, #39598, #40324 - Secret persistence: #77471 - Idempotency/raw payload: #7448, #55829 - Signature/provider: #47451, #80327 - Profile/session/resume: #57056, #65939, #67277, #71352, #74980 - Clarify/approval: #31565, #37284, #71571, #78296 - Listener lifecycle: #4260 (wontfix), #78022 - Callback/sync response: #4386, #73828 - Multi-delivery/debounce/cancel: #20201, #32403, #39999 - Model/completion: #43730, #80531 - Provider recipes: #43575, #54693, #66893, #71968 - Source restrictions: #18041 ## Acceptance gates (plan §6) Analyst/witness agreement per lane at final SHA; no dangling graph edges; all god-file touches via approved seams; no file >2K lines; gateway/CLI/REST/web/desktop/setup agree under all config modes; no secret sentinel in any surface; explicit constant-time replay-safe signatures; same-route retry dedupe; valid-JSON raw payload; HTTP contract matches docs; observable execution; profile/session isolation; fail-fast approvals; SSRF-safe callbacks; versioned outbound hooks; profile-correct web/desktop; exercised deployment; executable docs. ## Handoff rule (plan §8) Ares = controller, interlock owner, ledger keeper. Ares does NOT act as blind implementer or blind witness. Fresh implementation/review actors per defect-class PR with two-stage review.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/84834",
        "createdAt": "2026-08-12T21:41:37Z",
        "updatedAt": "2026-08-13T13:37:54Z",
        "timestamp": "2026-08-13T13:37:54Z",
        "metrics": {
          "reactions": 0,
          "comments": 15
        },
        "labels": [
          "type/refactor",
          "comp/cli",
          "comp/gateway",
          "platform/webhook",
          "P3"
        ],
        "author": "andrexibiza",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:84997",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Desktop — switching into an actively-streaming session lands the transcript on old history (scroll jitter + sticky-bottom disengage)",
        "text": "## Bug Description **Hermes Desktop**: switching into a session whose agent is **still running/streaming** makes the transcript bounce up and down for a few frames, then the viewport settles on an **older part of the transcript** while the reply keeps streaming below the fold. The user loses sight of the live turn and must scroll down manually. This is a distinct trigger of the same scroll-disengage bug family as #78486 (\"chat view jumps to a historical message block while the assistant is still responding\") — same mechanism, stronger repro path: the session-switch path adds a settle loop racing the streaming growth, and the final state (sticky-bottom disengaged, view parked on history) is identical. A full mechanism walkthrough with line numbers has been posted as a comment on #78486. ## Steps to Reproduce 1. Open Hermes Desktop. Start a conversation in session A; send a prompt that produces a long, tool-heavy streaming reply (long answers with tool calls make it most reliable). 2. While the reply is still streaming, switch to session B, then switch back to A. 3. Watch the transcript: it lurches up and down several times, then parks on an **earlier message block** (\"Show earlier\" button becomes visible at the top of the viewport). The stream continues below the fold. 4. The view never returns to the bottom on its own; the floating jump-to-bottom button appears (sticky-bottom has disengaged). ## Expected Behavior Switching into a running session should land the viewport on the **latest message** (or at least re-follow the stream once the switch settles), exactly like switching into an idle session does. The brief backfill of older turns should not visibly lurch the viewport, and the budget cut should never be interpreted as a user scroll-up. ## Actual Behavior - Viewport bounces up and down several times during the switch (settle loop vs. stepped backfill racing the streaming height growth). - Viewport ends up parked on a **historical message block** — precisely at the render-budget cut boundary (\"Show earlier\" visible at top). - Sticky-bottom is permanently disengaged: `isAtBottom=false`, composer dims, jump button shows, nothing re-pins (runStart already fired in the background; the settle loop handed back control). ## Root Cause Analysis All paths verified against source at `7113a6924` (`apps/desktop` package 0.17.0), running against desktop-runtime 0.20.0. **Phase 1 — the bounce.** `apps/desktop/src/components/assistant-ui/thread/list.tsx`: - The session-switch settle loop (L569-621) glues `scrollTop = scrollHeight` every frame until the height is stable for 2 frames (or the 15-frame cap). For a **streaming** target session, `scrollHeight` changes every frame, so the loop always runs the full 15-frame cap. - Simultaneously the backfill effect (L426-447) steps `renderBudget` 20 → paneBudget in +60-unit rAF steps, each step **prepending** older turns (growing height). The settle loop's per-frame re-pin fights that growth → the visible up/down bounce. **Phase 2 — parked on history.** Still in `list.tsx`: - `weightSignature` ticks on every streamed token (L326-328) → `weightedGroups` recompute → `firstVisibleGroupIndex()` (L468-472, RENDER_BUDGET=600) grows `hiddenCount` as the streaming turn gains weight → turns above the viewport are **unmounted** → `scrollHeight` shrinks → the browser **clamps `scrollTop`**. - `use-stick-to-bottom@1.1.6`'s `handleScroll` escapes the lock on `scrollTop < lastScrollTop` (dist/useStickToBottom.js L265-274). The clamp is not a programmatic write through the library's `state.scrollTop` setter, so `ignoreScrollToTop` is never set → the library reads the clamp as an intentional user scroll-up → `escapedFromLock=true`, `isAtBottom=false`. - The library's negative-resize rescue (L344-353) only re-locks when within 70px of the bottom; after a large budget cut the view sits far above it, so it never re-engages. - Nothing re-pins afterwards: `thread.runStart` → `scrollToBottom()` (L551) fired in the background before the switch and does not re-fire; the settle loop already handed back control (`loadSettledRef = true`). Note: this is **not** the per-session scroll-restore feature — PR #69554 (per-session `scrollTop` persistence for #45562) is unmerged and no such code exists in the tree. ## Proposed Fix (options, in increasing invasiveness) 1. Exempt the streaming turn's group from budget cuts while a run is active — extend the existing `liveTailStart` exemption (L225-250) to `firstVisibleGroupIndex` (L168-181). Smallest change, kills Phase 2. 2. Before a budget cut, capture distance-from-bottom and restore it in the same commit (same pattern as `anchorBeforePrepend`, L405-409) so the clamp never produces a synthetic scroll-up. 3. For Phase 1: make the settle loop treat a streaming session as \"unstable by definition\" — skip the per-frame glue and go straight to `scrollToBottom('instant')` + backfill, or gate the settle on the run state. The analysis and proposed fix directions above are provided for maintainers or any contributor who picks this up. ## Related Issues - #78486 — original report of the same disengage mechanism (main thread; mechanism walkthrough is there) - #83151 — latest prompt/answer appearing mid-thread (same code area, Mode B branch is this bug) - #59882 — PR \"anchor streaming replies on run start\" (open, unmerged; direction conflicts with this bug) - #81355 — feature request: always-follow-typing toggle (wants the inverse of the disengage) - #73990 — unconditional runStart scrollToBottom yanks readers of history - #65714 — feature: disable auto-scroll during streaming - #70618 — viewport doesn't auto-scroll after task completion - #70447 / #76030 / #73766 — related scroll-failure reports in the same component ## Environment - **Product**: Hermes Desktop (packaged portable edition) - **Desktop app**: `apps/desktop` package 0.17.0 (desktop-runtime 0.20.0) - **Source inspected**: `~/.hermes/hermes-agent` @ `7113a6924` (current `main`) - **OS**: Windows 11 Pro - **Mode**: Desktop chat UI, multi-session tabs",
        "url": "https://github.com/NousResearch/hermes-agent/issues/84997",
        "createdAt": "2026-08-13T02:55:14Z",
        "updatedAt": "2026-08-13T12:55:13Z",
        "timestamp": "2026-08-13T12:55:13Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "duplicate",
          "P3",
          "comp/desktop"
        ],
        "author": "licat2023",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85125",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Tracking: unified deadline layer — architectural fix for the timeout/hang backlog (4 phases)",
        "text": "## Goal Eliminate the recurring timeout/hang bug classes structurally. The open backlog contains **400+ issues matching timeout/hang/stuck/wedge**; community triage #84047 collapsed the 77 title-matched runtime stalls into seven mechanisms. Reading those plus the wider set, the failures reduce to **five structural defects** that we currently fix one incident at a time — each fix adding another site-local timeout, env var, and error string. This issue tracks the build-out of a unified deadline layer: one bounded-execution primitive, one timeout resolver, a poisoned-state contract for stateful backends, and whole-tree subprocess termination — rolled out in four phases, each its own PR. ## The five defect classes (evidence) ### 1. No deadline at all on entire call classes | issue | site | fix in flight | |---|---|---| | #84719 | sequential tool calls (`execute_tool_calls_sequential`) — 21h wedge observed | #84795 | | #76821 / #10048 | plugin lifecycle hooks on the turn hot path | #76822 | | #82846 | smart-approval auxiliary LLM call | #84125 | | #5450 | per-request wall-clock budget (slow-but-not-stale streams) | none | | #4057 | 20+ subprocess/network calls with no timeout | none | | #66843 | delegate_task in-tool stale timeout unreachable for terminal commands | none | ### 2. Timeout fires but poisons state The timeout \"works\", but the wedged resource is returned to a cache, so every later call against it fails until process restart. | issue | poisoned resource | |---|---| | #72205 | browser session cached after command timeout | | #81051, #77765, #84132 | MCP HTTP+OAuth connection permanently \"parked\" after teardown/keepalive race | | #81995 | in-flight tool call attached to dead MCP stdio subprocess for full 300s | | #74799 | CuaDriver never restarts session after MCP timeout | | #82602-class | LSP client cached after server loses stdout (#84047 family C) | ### 3. Timeout fires but doesn't kill descendants | issue | site | |---|---| | #71148, #59549 | cron script timeout leaves orphaned process trees | | #84967 | Docker terminal timeout leaves in-container process trees | | #68139 | browser_tool command timeout leaks agent-browser daemon + Chromium tree | ### 4. The timeout machinery itself is wrong at the edges | issue | defect | |---|---| | #83220 | large `approvals.timeout` overflows time_t on macOS — kills every parallel tool batch (#83225 / #83298 in flight) | | #81048 | approval timeout misattributed as explicit user denial (Tier-1: silence ≠ decision) | | #78183 | empty httpx timeout error string defeats `_is_timeout_error()` → duplicate delivery | | #49629 | int passed where timedelta expected (HTTP MCP) | | #59549, #80323, #81579 | script/inactivity timeouts misreported as provider timeouts | ### 5. Config fragmentation 30+ distinct `HERMES_*TIMEOUT*` env vars; no unified resolution path. Two clarify timeout keys with a silent legacy fallback (#25859, #72688, #74069); hardcoded values that ignore existing config (#63302, #63538, #53161, #43272, #39448); every new surface reinvents resolution, contradicting the \".env is for secrets only\" policy. ## Why one-at-a-time fixes don't converge Six site-local deadline mechanisms already exist, each built for one incident, none shared: `agent/tool_executor.py` batch deadline, `plugins/platforms/telegram/adapter.py:_await_with_thread_deadline` (the strongest primitive in the tree — private to one adapter), `gateway/turn_lease.py`, `agent/reasoning_timeouts.py`, `tools/approval.py:human_wait_ceiling`, per-MCP-handler timeouts. Every new stall report grows this list by one. And #84047 family A shows the deepest failure mode: **when the event loop thread is blocked, every asyncio-based timeout in the process is silently disabled** — which the thread-timer approach solves. ## Architecture One module — `agent/deadline.py` — plus a `timeouts:` config section: - **`run_bounded_async` / `run_bounded_sync`** — bounded execution with a thread-timer deadline (survives a blocked event loop), abandonment of cancellation-shielded tasks, blocked-loop stack-dump diagnostics, and a typed `BoundedResult` outcome. - **`clamp_timeout`** — platform-safe clamping at the shared boundary (kills the #83220 OverflowError class once, for every consumer). - **`resolve_timeout(\"<dotted.key>\")`** — precedence: `timeouts:` config section > legacy `HERMES_*` env bridge > default. No new user-facing env vars. - **`DeadlineExpired`** — our deadline, mechanically distinct from provider/transport timeouts (fixes the misattribution class at the error-classifier contract). - **`kill_process_tree`** — killpg + identity-aware psutil descendant sweep (reaches setsid'd grandchildren); `taskkill /F /T` on Windows. - **Poisoned-state contract** (Phase 3) — `on_deadline_expired()` protocol so a timed-out stateful backend (MCP connection, browser session, LSP client) is marked suspect and health-checked/recycled before reuse instead of returned to cache wedged. ## Execution plan ### Phase 1 — Foundation: primitive + resolver (pure addition, zero behavior change) - [ ] #85147 Deliverables: `agent/deadline.py` (all primitives above), `timeouts:` accepted as a config root, `cli-config.yaml.example` docs, 40 tests, first call-site migration (`tool_executor._resolve_concurrent_tool_timeout` — exact legacy contract preserved). Acceptance: all existing tests green; E2E proof that `timeouts.tools.concurrent_batch` in config.yaml flows through the migrated path; no default-behavior change. ### Phase 2 — Tool-execution paths + in-flight PR alignment - [ ] Sequential tool-call deadline: salvage #84795 onto `run_bounded_*` / `resolve_timeout(\"tools.sequential_call\")`, contributor authorship preserved — closes #84719 - [ ] Smart-approval guardian LLM bound: align #84125 — closes #82846 - [ ] Plugin-hook deadline: align #76822 — closes #76821, #10048 - [ ] approvals.timeout OverflowError: fold #83225/#83298 into `clamp_timeout` at the gate — closes #83220 - [ ] Migrate telegram's private `_await_with_thread_deadline` onto `run_bounded_async` (single source for the subtle timer/watchdog logic) - [ ] MCP `_run_on_mcp_loop` timeout resolution via `resolve_timeout(\"mcp.tool_call\")` (values unchanged) - [ ] Approval outcome semantics: `TIMEOUT_NO_RESPONSE` / `BLOCKED_AWAITING_APPROVAL` mechanically distinct from `DENY` through the full call chain — closes #81048 Acceptance: a deliberately wedged tool on the sequential path returns a `tool_timeout` disposition near the configured deadline and the turn continues; no approval timeout ever renders as \"User denied\". ### Phase 3 — Poisoned-state contract (fixes class 2) - [ ] Define `on_deadline_expired()` protocol + suspect/recycle lifecycle in `agent/deadline.py` - [ ] MCP server connections: timed-out call marks the connection suspect → health-check (ping) before reuse; recycle on failure — addresses #81051, #77765, #84132, #81995 - [ ] Browser sessions: recycle poisoned session after command timeout — addresses #72205 (align with in-flight #72206) - [ ] LSP clients: drop cached client when server stops responding — addresses #82602 class - [ ] CuaDriver session restart after MCP timeout — addresses #74799 Acceptance: after a forced tool timeout against each backend, the NEXT call succeeds without process restart. ### Phase 4 — Subprocess tree-kill migration + wall-clock budget (fixes classes 1+3 residue) - [ ] Cron script timeout kills the whole process tree — closes #71148, #59549 (align with in-flight #59379/#82460 classification fixes) - [ ] Terminal backends (local/Docker) tree-kill on timeout — closes #84967 - [ ] browser_tool daemon+Chromium tree cleanup on timeout — closes #68139 - [ ] Consolidate existing site-local tree-kills (`gateway/status.py`, `tools/code_execution_tool.py`) onto `kill_process_tree` - [ ] Per-request wall-clock budget for API calls (outermost layer) — closes #5450 - [ ] Sweep of unbounded subprocess/network calls from #4057 onto `run_bounded_*` Acceptance: killing a timed-out cron/terminal/browser command leaves zero orphaned descendants (verified with setsid'd grandchild probes); a 1-token-per-5s stream terminates at the configured wall-clock budget. ## Correctness fixes that ride along #78183 (empty httpx timeout string), #49629 (int vs timedelta), #59549/#80323/#81579 (timeout misclassification) land inside whichever phase touches their site, using `DeadlineExpired` as the classification anchor. ## What this is NOT - Not a coarse whole-agent timeout (see #81438's caveat — broad hard limits kill legitimate long-running work). Every bound is per-operation and configurable; human-wait windows are excluded from deadlines the way `tool_executor`'s authorization-gate exclusion already works. - Not a behavior change for defaults — existing timeout values and env vars keep working; the resolver just becomes the single place they're read. - Not cache-breaking — nothing touches conversation context, toolsets, or the system prompt. ## References - #84047 — community seven-mechanism triage (this is the structural answer to its families A, C, D) - #81438 — tool execution lease feature request (absorbed by the primitive + Phase 2) - Prior art in-tree: `plugins/platforms/telegram/adapter.py:_await_with_thread_deadline`, `agent/tool_executor.py` batch deadline, `gateway/turn_lease.py`, `tools/approval.py:human_wait_ceiling`, `agent/reasoning_timeouts.py`",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85125",
        "timestamp": "2026-08-13T12:07:12Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "innovation",
          "comp/agent",
          "comp/tools",
          "area/config",
          "P3",
          "needs-decision"
        ],
        "author": "kshitijk4poor",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85215",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Cron jobs pin to a dead model and ignore fallback_providers — jobs fail with HTTP 402 for days",
        "text": "### Bug Description ## Summary When a cron job is created, it captures a model_snapshot/provider_snapshot of the active model. If that model's quota/plan later becomes exhausted (HTTP 402 from the provider), every subsequent run of the job fails permanently with the same 402 error. The configured fallback_providers chain in config.yaml is NOT consulted for cron runs (Available Model Group Fallbacks=None). Jobs keep failing until the quota resets or the user manually edits ~/.hermes/cron/jobs.json. Additionally, cronjob action=update ... model=X provider=Y does not reliably overwrite the pinned snapshot; the job keeps using the old provider. ## Environment - Hermes version: 0.20.0 (desktop app) - OS: macOS - Provider config: custom providers in config.yaml (providers: section), plus fallback_providers list ## Actual behavior Every run fails with: RuntimeError: HTTP 402: litellm.APIError: APIError: OpenAIException - The Token Plan usage limit has been reached. To avoid interruption, please upgrade your plan or buy credits and turn on auto consumption. (2067). Received Model Group=minimax-m3 Available Model Group Fallbacks=None - jobs.json keeps model_snapshot/provider_snapshot from creation time; cronjob action=update with model=/provider= does not update them (the job still reports the old Model Group even after the update). - config.yaml has a healthy fallback_providers list (deepseek, aws bedrock, etc.), but cron execution ignores it entirely. ------------------------------- ## What I tried (none worked) 1. cronjob action=update job_id=... model=aws-qwen3-235b provider=aws_qwen3 - API returned success but jobs.json still showed the old snapshot; next run used the dead provider anyway. 2. Editing ~/.hermes/cron/jobs.json directly to set model/provider - the ticker picked it up only partially; the job still resolved to the dead provider, and later a second bug surfaced: an \"Unknown provider\" error when the provider name in jobs.json did not byte-match the key in config.yaml's providers: section (names are not validated/normalized at write time, and the error message does not list valid provider names). 3. Wrapper script workaround (cronjob action=update script=... that sets HERMES_MODEL env and calls hermes cron run <id>) - hermes cron run reports \"Job is already being fired by the scheduler; not run again\" and does not execute the job inline, so the env-var override never reaches the actual run. 4. Commenting out the dead provider in config.yaml - cron still resolved to the removed provider name from the snapshot. ## Impact - Scheduled jobs (health checks, cost summaries, dataset maintenance) silently fail for days. - Users on quota-based plans get a broken set-and-forget cron experience with no notification. - Manual jobs.json surgery is required, which is error-prone (invalid provider names cause a different failure). ## Suggested fix direction - At cron run time, resolve the model like a normal chat turn would: if the pinned model is unavailable (402/429), walk fallback_providers before failing. - cronjob action=update should either update model_snapshot/provider_snapshot when model=/provider= is passed, or refuse with a clear message explaining that snapshots are immutable. - Validate provider names against config.yaml's providers: keys when writing jobs.json; on mismatch, surface the list of valid names in the error. ------------------------------ ### Steps to Reproduce 1. Configure provider A (e.g. a subscription-token provider) as the active model in config.yaml. 2. Create any cron job (e.g. cronjob action=create). Note: model_snapshot/provider_snapshot get written into ~/.hermes/cron/jobs.json. 3. Let provider A's quota/plan run out - all calls now return HTTP 402 \"Token Plan usage limit has been reached\". 4. Wait for the job's next scheduled run (or trigger cronjob action=run). Result: every run fails with the same 402 error, fallback_providers is never consulted, and the job stays broken until the quota resets or jobs.json is edited manually. ### Expected Behavior - The cron job should resolve the model like a normal chat turn: if the pinned model is unavailable (HTTP 402/429), it should walk the fallback_providers list before failing, so the job still runs. - cronjob action=update with model=/provider= should update the model_snapshot/provider_snapshot so the next run uses the new provider. - Provider names should be validated against config.yaml's providers: keys when writing jobs.json; on mismatch, the error should list the valid provider names. - The job should fail loudly with a clear \"quota exhausted, switch model\" message instead of silently retrying the same dead provider forever. ### Actual Behavior Every run of the job fails with the same error: RuntimeError: HTTP 402: litellm.APIError: APIError: OpenAIException - The Token Plan usage limit has been reached. To avoid interruption, please upgrade your plan or buy credits and turn on auto consumption. (2067). Received Model Group=minimax-m3 Available Model Group Fallbacks=None - jobs.json keeps model_snapshot/provider_snapshot from creation time; cronjob action=update with model=/provider= does not update them (the job still reports the old Model Group even after the update). - config.yaml has a healthy fallback_providers list (deepseek, aws bedrock, etc.), but cron execution ignores it entirely. - The job silently retries the dead provider on every scheduled run; there is no notification and no fallback. ### Affected Component Other ### Messaging Platform (if gateway-related) _No response_ ### Debug Report ```shell Environment: - Hermes 0.20.0 (desktop app) - macOS - Custom providers in config.yaml (providers: section) + fallback_providers list - Cron jobs created while provider A was active ``` ### Operating System MacOS Tahoe 26.5.1 ### Python Version 3.11.15 ### Hermes Version 0.20.0 (2026.8.3) ### Additional Logs / Traceback (optional) ```shell === ERROR ON EVERY SCHEDULED RUN === RuntimeError: HTTP 402: litellm.APIError: APIError: OpenAIException - The Token Plan usage limit has been reached. To avoid interruption, please upgrade your plan or buy credits and turn on auto consumption. (2067). Received Model Group=minimax-m3 Available Model Group Fallbacks=None === JOBS.JSON (AFTER cronjob action=update WITH NEW MODEL/PROVIDER) === \"model\": null, \"provider\": null, \"provider_snapshot\": \"custom\", \"model_snapshot\": \"minimax-m3\" NOTE: update returned success but the snapshot fields did not change; next run still used Model Group=minimax-m3. ``` ### Root Cause Analysis (optional) Root cause: cron job records persist model_snapshot/provider_snapshot at creation time. At run time the cron scheduler resolves the model EXCLUSIVELY from that snapshot and does not consult the fallback_providers list in config.yaml. When the snapshot provider's quota is exhausted (HTTP 402), every run fails identically and permanently. The failure is silent (job status = error, no delivery), so a broken set-and-forget job can fail for days unnoticed. Secondary issues found while investigating: 1. cronjob action=update with model=/provider= does NOT update model_snapshot/provider_snapshot in jobs.json, so the \"fix\" appears to succeed but changes nothing. 2. Provider names in jobs.json are not validated against config.yaml providers: keys at write time; a mismatch surfaces later as a confusing \"Unknown provider\" error that does not list valid names. 3. `hermes cron run <id>` does not execute the job inline; it returns \"Job is already being fired by the scheduler; not run again\", so env-var/wrapper workarounds cannot override the model. ### Proposed Fix (optional) 1. At cron run time, resolve the model through the same path as a normal chat turn: if the pinned snapshot model is unavailable (402/429), walk fallback_providers before failing. If all fail, record a visible error. 2. cronjob action=update: when model=/provider= is passed, either update model_snapshot/provider_snapshot (or clear them to fall back to current config) — or refuse with a clear message that snapshots are immutable. 3. Validate provider names against config.yaml providers: keys when writing jobs.json; on mismatch, include the list of valid provider names in the error. 4. Optional: surface cron job HTTP 402/429 failures as a notification instead of silent last_status=error. ### Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85215",
        "createdAt": "2026-08-13T10:07:20Z",
        "updatedAt": "2026-08-13T13:28:04Z",
        "timestamp": "2026-08-13T13:28:04Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/cron",
          "area/config",
          "P2",
          "sweeper:risk-compatibility",
          "area/billing",
          "bug"
        ],
        "author": "CPLANETAI",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85237",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Hermes Desktop remote-profile sessions disappear after profile switching",
        "text": "## Summary Hermes Desktop can briefly show a newly created session for a profile connected to a remote gateway, then lose that session from the sidebar after switching to another profile and back. ## Reproduction 1. Configure the primary Desktop profile against a local backend. 2. Configure a second Desktop profile with a remote gateway override. 3. Switch to the remote profile and send a message, creating a new session. 4. Switch to a local profile. 5. Switch back to the remote profile after the sidebar refreshes. ## Observed behavior The optimistic session row appears initially, but the refreshed remote session list fails and the row disappears. With an active sidebar filter, Desktop raises the requested session window to 300. The Electron remote-profile interceptor forwards that logical window directly to the remote gateway's `/api/sessions` endpoint: ```text GET /api/sessions?limit=300&... 422 Unprocessable Entity limit: Input should be less than or equal to 100 ``` `/api/profiles/sessions` accepts the Desktop's larger aggregate window, but the remote splice reads one profile through `/api/sessions`, whose intentional per-database cap is 100. ## Expected behavior Remote-profile session refreshes should honor the Desktop's logical window without sending an individual `/api/sessions` request above its 100-row cap, so newly created sessions remain visible across profile switches. ## Environment - Hermes Desktop 0.20.0 - Linux - primary profile: local backend - secondary profile: remote OAuth gateway",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85237",
        "timestamp": "2026-08-13T11:11:50Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "P3",
          "sweeper:risk-session-state",
          "comp/desktop",
          "area/profiles"
        ],
        "author": "frizikk",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85238",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Desktop — agent-sandbox files unreachable for preview, MEDIA delivery and downloads shelf in SSH-remote + docker topology",
        "text": "**Platform:** macOS desktop (client built from main, includes #76008), backend on a Linux host over SSH. The agent's tools (terminal / file writes) run inside a **docker sandbox** on that host, so agent-written paths (`/workspace/...`, `/tmp/...`) exist only in the container filesystem — not on the backend host, not on the client. **Repro** 1. The agent writes a file (e.g. an HTML mockup) inside its sandbox. 2. The agent hands it to the user through any of the three surfaces: preview pane, MEDIA attachment, downloads shelf. **Actual** - Preview pane: `Preview unavailable — Error invoking remote method 'hermes:api': Error: 404: {\"detail\":\"File not found\"}`. - MEDIA attachment: `Couldn't fetch <file> from the gateway (missing, unreadable, or too large)`. - Downloads shelf: entries appear (e.g. 4 × `index.html`) but clicking them does nothing — the `file://` path does not exist on the client. **Expected (any of)** - Agent-mediated fetch: the backend/gateway asks the agent runtime for the bytes — the agent *can* read its own sandbox paths. - An honest \"file lives inside the agent sandbox\" message instead of a bare 404 / silent shelf entries. - Or a documented shared-mount convention so host-side services can reach sandboxed-agent files. **Context** #75011 + #76008 fixed host-level remote paths (confirmed working here: the preview request now reaches the backend and fails honestly with a structured 404). Sandboxed-agent files are one hop further — container FS ⊄ host FS. This exact gap was flagged in a comment on #75011; reporting it standalone now that all three delivery surfaces hit it in one session.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85238",
        "timestamp": "2026-08-13T11:11:45Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "backend/ssh",
          "comp/gateway",
          "backend/docker",
          "P2",
          "sweeper:risk-message-delivery",
          "comp/desktop"
        ],
        "author": "sashalab",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85244",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "fix(webhook): propagate authenticated event and delivery metadata to route scripts",
        "text": "## Goal Propagate authenticated webhook event type and immutable delivery identity into configured route scripts so deterministic zero-LLM consumers can verify and persist the same delivery identity that Hermes authenticated. ## Repository `NousResearch/hermes-agent` ## Profile `default` ## Current condition After HMAC validation, `WebhookAdapter._handle_webhook` derives the event type from authenticated request context, but invokes `WebhookRouteProcessor.run_route_script` with only the parsed JSON payload. The processor builds a sanitized subprocess environment without event/delivery metadata. Route scripts therefore cannot bind processing to `X-GitHub-Event` and `X-GitHub-Delivery` without inferring or synthesizing identity from attacker-controlled payload content. This blocks R4 / Slice C acceptance in `quickwhips/hermes-github-kanban-sync#19`. The QuickWhips runtime remains fail closed: synchronization writes disabled, recurrence paused, global dispatch disabled. ## Required work 1. Resolve one immutable delivery ID from supported authenticated request headers before route-script execution. 2. Pass event type and delivery ID explicitly to `run_route_script`. 3. Add only those bounded values to the sanitized child environment as `HERMES_WEBHOOK_EVENT_TYPE` and `HERMES_WEBHOOK_DELIVERY_ID`. 4. Use the same delivery ID for downstream Hermes idempotency and route-script processing. 5. Fail closed for script routes when no externally supplied delivery identity exists; do not synthesize a timestamp or payload-hash identity for a stateful script consumer. 6. Preserve existing non-script webhook behavior and generic provider support. 7. Document the script metadata contract. ## Validation gates - Signed end-to-end route test proves a real script receives exact event and delivery values. - Missing delivery identity on a script route fails closed before script invocation. - Duplicate delivery uses one canonical identity and does not invoke the script twice. - Payload fields cannot override authenticated metadata. - Existing webhook adapter/signature/rate-limit/integration tests pass. - Full relevant repository validation and exact-head CI pass. - Specification review then quality/security review reports P0/P1/P2 = `0/0/0` for the unchanged candidate. ## Autonomous actions Kaine may implement, test, fork, push, open a PR, and shepherd CI for this source-only fix. No QuickWhips runtime installation, gateway restart, canary retry, synchronization write, recurrence activation, global dispatch, public exposure, or credential expansion is authorized by this issue. ## Approval boundary Merging this framework fix does not authorize installing it into the QuickWhips Hermes runtime. Runtime installation and a renewed exact identity-bound canary require separate Josh approval after the candidate, compatibility packet, backup/rollback evidence, and exact-head CI are frozen. ## Exclusions Do not add QuickWhips/plugin-specific logic to Hermes core; do not expose arbitrary request headers or secrets; do not make user-facing behavior depend on new `.env` configuration; do not weaken HMAC, rate limiting, or idempotency; do not activate any controller or recurring job. ## Deployment policy None. Source-only upstream contribution. Rollback is revert of the exact merged commit.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85244",
        "timestamp": "2026-08-13T11:20:53Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "platform/webhook",
          "P3",
          "sweeper:risk-message-delivery"
        ],
        "author": "quickwhips",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85248",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug] Hermes Desktop auto-starts duplicate service processes causing conflicts",
        "text": "# Bug Report: Hermes Desktop App 启动重复服务进程导致冲突 ## Description Hermes桌面应用在自动重启服务时（`codex_app_server_auto: native`）没有检查是否已有服务进程在运行，导致同时启动多个Hermes服务进程。新进程可能使用错误的Python路径（回退到uv缓存中的旧 Python），造成端口冲突、进程卡死和服务不稳定。 ## Steps to Reproduce 1. 在 `config.yaml` 中设置 `codex_app_server_auto: native` 2. 启动Hermes桌面应用 3. 等待桌面应用自动管理服务（或手动重启服务） 4. 使用 `tasklist /FI \"IMAGENAME eq python.exe\"` 观察进程 5. 发现两个 python.exe 进程同时运行： ``` PID 12528: E:\\hermes_agent\\hermes-agent\\venv\\Scripts\\python.exe -m hermes_cli.main serve --host 127.0.0.1 --port 0 PID 12956: C:\\Users\\okttl\\AppData\\Roaming\\uv\\python\\cpython-3.11-windows-x86_64-none\\python.exe -m hermes_cli.main serve --host 127.0.0.1 --port 0 ``` 6. 其中一个进程会进入 `Not Responding` 状态 7. 端口9177可能无监听（两个进程都在尝试绑定但互相冲突） ## Expected Behavior 桌面应用应该： - 启动新服务前检查是否已有服务进程在运行（通过锁文件、端口占用检测或进程ID文件） - 如果已有进程，应该终止旧进程再启动新进程，而不是并行运行 - 使用与原始进程相同的Python环境，而不是回退到uv缓存中的Python ## Actual Behavior 1. 多个服务进程同时运行 2. 进程使用不同的Python路径（venv vs uv cache） 3. 端口绑定冲突导致服务不可用 4. 部分进程进入 `Not Responding` 状态 ## Environment - **OS**: Windows 10 - **Hermes版本**: v0.20.0 (2026.8.3) - **Python版本**: 3.11.15 - **安装路径**: E:\\hermes_agent\\hermes-agent - **关键配置**: `codex_app_server_auto: native` ## 配置片段 ```yaml # config.yaml service_tier: auto home_mode: auto codex_app_server_auto: native ``` ## 可能的解决方案 1. 在启动服务前检查是否存在锁文件（如 `~/.hermes/gateway.pid`） 2. 检查端口9177是否已被占用 3. 使用进程树关系确保子进程不会重复启动 4. 记录实际使用的Python路径到环境变量或配置，避免回退到系统Python ## Additional Context 此问题在Windows平台上更容易复现，因为uv缓存的Python路径（`AppData\\Roaming\\uv\\python`）与hermes-agent的venv路径不同。Linux/Mac可能不受影响，因为路径解析逻辑可能不同。",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85248",
        "timestamp": "2026-08-13T11:30:15Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/cli",
          "P2",
          "sweeper:risk-platform-windows",
          "comp/desktop",
          "platform/windows"
        ],
        "author": "okttl",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85251",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "MiniMax Anthropic endpoint: prior-round thinking blocks are stripped, breaking interleaved thinking",
        "text": "## Summary `_manage_thinking_signatures` (`agent/anthropic_adapter.py`) strips **all** thinking blocks from the conversation history for third-party Anthropic-Messages endpoints. MiniMax is explicitly named in the docstring as one of the hosts that \"cannot validate them and will reject them outright\". Measured against the live endpoint, that premise does not hold for MiniMax: it returns *signed* thinking blocks and accepts them back verbatim. Because MiniMax is an interleaved-thinking model, stripping them costs real agentic performance — by MiniMax's own published figures, Tau² 87 → 64 and BrowseComp 44.0 → 31.4. Kimi (#13848) and DeepSeek (#16748) each got an exception for the same class of problem. MiniMax has none. ## Reproduction / measurement Endpoint `https://api.minimax.io/anthropic/v1/messages`, model `MiniMax-M3`, three runs per case, each with a control. Measured 2026-08-13. **1. Does M3 emit thinking at all on this route?** | request | thinking block returned | avg. thinking chars | |---|---|---| | no `thinking` field (control) | 0/3 | 0 | | `{\"type\":\"enabled\",\"budget_tokens\":32000}` — what Hermes sends | 3/3 | 297 | | `{\"type\":\"adaptive\"}` — what MiniMax documents | 3/3 | 393 | So the `enabled`/`budget_tokens` form Hermes sends on the non-adaptive path is accepted and does produce thinking. (The control confirms the docs: with the field omitted, thinking is off on this endpoint.) **2. Does MiniMax accept its own thinking blocks back?** Two-turn conversation, assistant turn replayed verbatim including the signed thinking block: | replayed history | HTTP | |---|---| | thinking stripped (current behaviour, control) | 200, 3/3 | | thinking preserved verbatim | **200, 3/3** | The blocks carry a `signature` and are still accepted. No 400, no \"invalid signature in thinking block\". **3. After the fix, end-to-end** Real M3 response → `_manage_thinking_signatures(..., \"https://api.minimax.io/anthropic\", \"MiniMax-M3\")` → replayed to the live endpoint: - thinking block survives the adapter: 3/3 - MiniMax accepts the resulting history: 3/3 HTTP 200 - control with an unrelated third-party base URL: 0/3 blocks left (still stripped) ## Suggested fix Give MiniMax the same replay-as-is treatment as the Kimi family, right after the Kimi branch in `_manage_thinking_signatures`: ```python elif _is_minimax_anthropic_endpoint(base_url): # MiniMax needs every prior round's thinking replayed (interleaved # thinking); it accepts its own signed blocks back verbatim. pass ``` `_is_minimax_anthropic_endpoint` already exists in the same module and covers both `api.minimax.io/anthropic` and `api.minimaxi.com/anthropic`. The docstring above the branch should also drop MiniMax from the \"will reject them outright\" list — that sentence is what makes the current behaviour look intentional. Happy to open a PR with the change plus tests (`TestMiniMaxThinkingReplay`: both MiniMax hosts keep their blocks, an unrelated third-party host still loses them) if that is useful. ## References - MiniMax on why interleaved thinking must be preserved, incl. the benchmark deltas: https://www.minimax.io/news/why-is-interleaved-thinking-important-for-m2 - MiniMax Anthropic-SDK docs (\"When a response includes `thinking` blocks, preserve them unchanged in later turns, especially in tool-use conversations\"): https://platform.minimax.io/docs/api-reference/text-anthropic-api ## Environment - hermes-agent at `89a84e1ae` - Windows 11, provider `minimax`, `base_url: https://api.minimax.io/anthropic`, model `MiniMax-M3`",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85251",
        "timestamp": "2026-08-13T11:30:09Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/minimax",
          "P2"
        ],
        "author": "webergastro-source",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85252",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "cron: stale-watchdog abort ineffective on hung sockets in non-streaming inline path (direct_api_call) — stalls exceed threshold by 5-11x",
        "text": "## Summary The stale-request watchdog for the non-streaming inline path fires, but its abort does NOT actually kill hung TCP sockets. Stalls therefore run far beyond the configured threshold — observed **7137s vs 600s threshold** (11.9x) and 6780s/6562s/4990s/3366s on 2026-08-13 during a DeepSeek API slowdown window. ## Environment - hermes-agent v0.20.0 (2026.8.3) - provider: deepseek (deepseek-v4-flash), non-streaming inline path - effective stale threshold 600s (reasoning floor for V4 reasoning-delta models) ## Evidence (logs 2026-08-13, cron sessions) - `Inline non-streaming API call stale for 7137s (threshold 600s), ctx ~54k` (heimdall cron, 09:13:54) - `Inline non-streaming API call stale for 4990s (threshold 600s), ctx ~56k` (10:37:57) - `Inline non-streaming API call stale for 6780s (threshold 600s), ctx ~7.9k` (huginn cron, 07:53:59) - `Inline non-streaming API call stale for 6562s (threshold 600s), ctx ~16k` (09:44:26) - `Inline non-streaming API call stale for 3366s (threshold 600s), ctx ~36k` (10:43:11) ## Root cause hypothesis From the streaming-path stale-kill log (2026-08-06): `OpenAI client aborted (stale_stream_kill, shared=False, tcp_force_closed=0, deferred_close=stranger_thread)`. The abort is issued from a timer/stranger thread, so the httpx/OpenAI client defers the TCP close (`deferred_close=stranger_thread`) and cannot hard-close the connection (`tcp_force_closed=0`). The hung request keeps waiting until the provider itself responds or the connection dies on its own — which during a provider stall can be hours. Net effect: in the cron path the watchdog bounds stalls effectively NOT at all (600s timer + ~6500s stuck abort). ## Impact - Cron jobs with DeepSeek in the morning risk window (06:00-10:45 CEST) stall 1-2h per attempt; 5 retries with backoff extend this to hours. - Failover providers only activate after retry exhaustion, so a stall that eventually resolves looks like \"slow API\" instead of \"dead request\" — the whole point of the watchdog. ## Suggested direction (no code fix in this report) - In `agent/chat_completion_helpers.py` `direct_api_call`: after the stale abort fires, force-close the underlying transport socket (e.g. `transport.close()` / force TCP RST, set `tcp_force_closed=1`) instead of relying on deferred close from the timer thread. - Consider a hard upper bound on inline non-streaming stalls independent of provider response time.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85252",
        "timestamp": "2026-08-13T11:30:04Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/cron",
          "provider/deepseek",
          "P1"
        ],
        "author": "Muetzengot",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85257",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Desktop] MEDIA file link label shows percent-encoded filename for non-ASCII names",
        "text": "Bug Description In the desktop app, when the assistant delivers a file via a [File: ](https://github.com/NousResearch/hermes-agent/issues/new?title=%5BDesktop%5D%20MEDIA%20file%20link%20label%20shows%20percent-encoded%20filename%20for%20non-ASCII%20names&labels=bug,comp%2Fdesktop#media:)path, the rendered link label shows the URL-percent-encoded filename instead of the decoded one — for any filename containing non-ASCII characters (e.g. Chinese). Example: assistant replies with [Open %E8%B6%85%E5%88%86%E5%8A%9F%E8%83%BD%E5%AE%9A%E4%B9%89-%E7%A0%94%E5%8F%91%E7%89%88.md%60;](https://github.com/NousResearch/hermes-agent/issues/new?title=%5BDesktop%5D%20MEDIA%20file%20link%20label%20shows%20percent-encoded%20filename%20for%20non-ASCII%20names&labels=bug,comp%2Fdesktop#) the chat renders the link as Open %E8%B6%85%E5%88%86%E5%8A%9F%E8%83%BD%E5%AE%9A%E4%B9%89-%E7%A0%94%E5%8F%91%E7%89%88.md. The link still works when clicked (file opens/downloads fine) — this is a display-only bug. Verified still present after updating the app (2026-08-13). Steps to Reproduce In the desktop app, have the assistant deliver a file whose name contains Chinese (or any non-ASCII) characters via a [File: ](https://github.com/NousResearch/hermes-agent/issues/new?title=%5BDesktop%5D%20MEDIA%20file%20link%20label%20shows%20percent-encoded%20filename%20for%20non-ASCII%20names&labels=bug,comp%2Fdesktop#media:)link. Look at the rendered link label in the chat. Expected Behavior Label shows the decoded filename, e.g. 超分功能定义-研发版.md. Actual Behavior Label shows the raw percent-encoded URL string: %E8%B6%85%E5%88%86%E5%8A%9F%E8%83%BD%E5%AE%9A%E4%B9%89-%E7%A0%94%E5%8F%91%E7%89%88.md. Environment OS: Windows App: Hermes desktop app (latest, after 2026-08-13 update) Model provider: DeepSeek",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85257",
        "timestamp": "2026-08-13T11:39:26Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "duplicate",
          "P3",
          "sweeper:risk-platform-windows",
          "comp/desktop",
          "platform/windows"
        ],
        "author": "SongZ-57",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85262",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Session lifecycle plugin payloads lack cross-surface transition and workspace parity",
        "text": "## Summary Hermes exposes native session IDs to plugins, but session lifecycle payloads are not consistent enough across CLI, TUI, and Gateway for plugins that must attribute state or writes to the exact logical session. On current `main` (`fa83af3f9a42790730b8966ff67e7d9fb627899f` when reproduced): - CLI `/new` finalizes the old session before minting the new ID, so `on_session_finalize` cannot report an explicit old→new transition. - TUI lifecycle helpers emit only `session_id` and `platform`. - Gateway `/new` emits `old_session_id` and `new_session_id`, but not the session-scoped working directory. - `pre_llm_call` carries `session_id`/`task_id`, but not the authoritative task workspace. A plugin can therefore distinguish many parallel sessions by ID, but cannot uniformly prove session identity + workspace at reset/write boundaries without guessing from process CWD. That guess is unsafe in multiplexed TUI/Gateway processes serving multiple sessions and workspaces. ## Minimal reproduction Register a plugin for: - `pre_llm_call` - `on_session_finalize` - `on_session_reset` Then invoke `/new` through CLI, TUI, and Gateway and compare kwargs. Observed contract: | Surface | finalize/reset old+new IDs | authoritative session CWD | |---|---:|---:| | CLI | no | no | | TUI | no | no | | Gateway | yes | no | | `pre_llm_call` | current ID only | no | The missing CWD must not be replaced with `os.getcwd()` or a host-process fallback: one TUI/Gateway process can serve several concurrent session workspaces. ## Expected behavior For an explicit `/new` rotation, all surfaces should emit additive, keyword-only payloads with: ```python { \"session_id\": old_id, # finalize \"old_session_id\": old_id, \"new_session_id\": new_id, \"reason\": \"new_session\", \"cwd\": authoritative_session_cwd_or_empty, } ``` and: ```python { \"session_id\": new_id, # reset \"old_session_id\": old_id, \"new_session_id\": new_id, \"reason\": \"new_session\", \"cwd\": authoritative_session_cwd_or_empty, } ``` `pre_llm_call` should include the authoritative task/session CWD as an additive kwarg. If Hermes cannot prove one, it should pass `\"\"`/`None`, not process CWD. Ordinary startup/resume/final shutdown must not fabricate an old→new transition. ## Actual behavior Payload fields vary by surface, forcing standalone plugins either to: 1. guess session ownership from checkout/process state; or 2. fail closed even though Hermes internally has the relevant task/session identity. ## Proposed fix A narrow patch is prepared against current `main`: - expose a strict, no-fallback session-CWD accessor; - add authoritative CWD to `pre_llm_call`; - make CLI/TUI boundary helpers accept additive `reason`, `old_session_id`, `new_session_id`, and `cwd` kwargs; - mint the CLI `/new` ID before finalizing the old session, while preserving the existing state/DB rotation point; - add Gateway's bound session CWD to its existing old/new-ID payload; - cover CLI, TUI, Gateway, and no-process-CWD fallback with focused tests. Current focused verification on the candidate: 7 lifecycle tests pass; Python compilation and `git diff --check` pass. I am willing to submit the PR after independent review. ## Related issues - #12176 — `/new` emits a fake finalize event with `session_id=None` when no old session exists. - #77538 — CLI `/resume` and `/branch` skip external context-engine lifecycle. - #64231 — lifecycle hook taxonomy/catalog; this report is a concrete payload-consistency bug within the shipped session hooks.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85262",
        "timestamp": "2026-08-13T11:49:48Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "comp/gateway",
          "comp/tui",
          "comp/plugins",
          "P3",
          "sweeper:risk-session-state",
          "area/sessions"
        ],
        "author": "vinjaune89",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85265",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug] Windows desktop: closing the app kills the gateway, reopening never respawns it — stale gateway_state.json shows DingTalk 'connected' while all messages are silently dropped",
        "text": "## Environment - OS: Windows 11, Chinese (Simplified) locale, codepage cp936/GBK - Hermes: v0.20.0 (auto-updated 2026-08-13), native desktop app (not WSL) - Python: 3.11.15, venv install - Channel: DingTalk (Stream Mode), user DM - Gateway persistence: Windows login item only (`%APPDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\Hermes_Gateway.vbs`) — no scheduled task ## Summary On Windows, **closing the Hermes desktop app kills the gateway process**, but **reopening the app does not respawn it**. `gateway_state.json` is left stale (`gateway_state: \"running\"`, dingtalk `\"connected\"`) pointing at a dead PID, so: - The messaging UI shows a contradictory state: \"消息网关已停止 / gateway stopped\" (real) + \"DingTalk 已连接 / connected\" (stale, from the dead process's state file). - DingTalk messages get **no response and are silently lost** — DingTalk Stream Mode has no offline queue, so anything sent while the gateway is dead is dropped forever. - The same happens after an in-app `hermes update`: the update log claims \"✓ Restarting Windows gateway profile(s): default\" but no gateway process is actually spawned. Only a manual `hermes gateway start` / `hermes gateway restart` (direct spawn, bypassing the desktop supervision) or a Windows re-logon (login item fires) recovers it. ## Repro timeline (local time, UTC+8) | Time | Event | |---|---| | 19:16:24 | Gateway pid 23476 (up since 07:43) exits when the desktop app closes: `Received UNKNOWN as a planned gateway stop — exiting cleanly`, shutdown phases run | | 19:23:03 | User relaunches desktop app → gateway pid 186348 auto-starts (`--replace`), `✓ dingtalk connected` via Stream Mode, `Gateway running with 1 platform(s)` | | 19:30:51 | `hermes update` runs: reinstall venv (0.20.0), kills app + gateway process tree, relaunches desktop. Update log says `✓ Restarting Windows gateway profile(s): default` — **but `gateway-exit-diag.log` shows NO `gateway.start` entry afterwards**. Gateway never comes back | | 19:37:41 | Manual `hermes gateway restart` (workaround) → pid 126240 starts, DingTalk connected | | 19:38:13 | Last heartbeat of pid 126240 — **killed again when the user closes the desktop app** | | 19:38:38 | Desktop app relaunched (serve backend restarted) → **still no gateway spawn** | | 19:42:xx | Manual `hermes gateway start` (direct spawn) → pid 173096, DingTalk connected, stays alive indefinitely | ## Evidence 1. **Stale state file**: `gateway_state.json` contained `{\"pid\":126240,...,\"gateway_state\":\"running\",...,\"platforms\":{\"dingtalk\":{\"state\":\"connected\",\"updated_at\":\"2026-08-13T11:38:28Z\"}}}` while `tasklist /FI \"PID eq 126240\"` reported **no such process** (dead for ~20 min). The desktop UI's \"connected\" indicator reads this file, hence the fake \"已连接\". 2. **Desktop boot failures**: `desktop.log` accumulated **199** lines of `[hermes] [boot] Desktop boot failed: Could not reach the remote Hermes gateway while refreshing its WebSocket ticket. Try reconnecting.` — the app only ever *connects* to a gateway; it does not bring one up. 3. **No spawn attempt after relaunch**: `gateway-exit-diag.log` shows no `gateway.start` / `gateway.exit_*` entries between the 19:37 manual start and 19:42 — the 19:38 app relaunch never attempted to start a gateway (nor did the 19:30 update's claimed restart). 4. **Unclean kill**: the gateways killed by app close/update leave no clean exit record (`previous_unclean_exit`, `suspected_oom=false`) — consistent with the app's process-tree teardown (`forceKillProcessTree` in `apps/desktop/electron/main.ts`, shim-unlock path: `taskkill /T /F`). ## Root-cause hypothesis - The desktop app **supervises the gateway as a child** and tears the whole tree down on close/update (`main.ts` shim-unlock taskkill path reaps \"a backend `hermes.exe` that spawned its own grandchildren (... the gateway)\"). - On relaunch, the boot flow apparently **trusts the on-disk `gateway_state.json`** (still \"running\" from the dead process) or otherwise never attempts a respawn — the app expects the login item / manual start to provide a gateway, so after any mid-session app restart the gateway is simply gone. - The stale state file then poisons the UI indicator and any liveness-based logic (cf. #32887). ## Impact - Every desktop app close/reopen or auto-update **silently kills all messaging** (observed on DingTalk; likely all platforms). - DingTalk Stream Mode drops messages sent during downtime — no replay. - The user-facing contradiction (\"gateway stopped\" + \"DingTalk connected\") makes it look like a channel problem, not a gateway problem. ## Suggested fix directions 1. On desktop boot, **verify process liveness** of the PID recorded in `gateway_state.json` before trusting `gateway_state: \"running\"`; respawn the gateway (or surface a clear \"start gateway\" affordance) if the PID is dead. 2. After `hermes update`'s \"Restarting Windows gateway profile(s)\" step, **verify the spawn actually happened** (the log claims success but no process appeared). 3. Consider having the desktop app respawn the gateway on boot whenever a platform is enabled and no gateway process is alive. ## Related issues - #83851 — Desktop `[gateway-crash]`: GBK encoding kills gateway on Chinese Windows (same env, same workaround of direct-spawn `hermes gateway start`; not the active killer here — no `gateway-crash` in desktop.log) - #80569 — Windows Desktop: duplicate startup entries / gateway respawn after update - #56524 — In-band update can leave gateway offline after drain timeout - #32887 — `gateway_state.json` heartbeat tick missing → liveness checks fail - #39969 — Windows gateway `/restart` could leave gateway stopped (fixed)",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85265",
        "timestamp": "2026-08-13T11:58:00Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/gateway",
          "P2",
          "sweeper:risk-message-delivery",
          "sweeper:risk-platform-windows",
          "comp/desktop",
          "platform/windows"
        ],
        "author": "bilibililqh-arch",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85272",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Telegram adapter fails to connect on python-telegram-bot 22.8 — \"Any cannot be instantiated\" (regression after 22.6→22.8 pin bump in v0.20.0)",
        "text": "### Bug Description After updating 0.19.1 → 0.20.0, the Telegram gateway adapter fails to connect. The gateway starts with **no connected platforms** (cron jobs still run), so the bot never answers on Telegram. It retries every few minutes and always fails with `Any cannot be instantiated`. Only the Telegram platform is affected — the dashboard / Desktop connection (`hermes-dashboard`) works fine. The failure happens right after the DNS-over-HTTPS fallback-IP discovery, during Telegram client instantiation. ### Steps to Reproduce 1. Run Hermes v0.19.1 with Telegram configured (works — python-telegram-bot 22.6). 2. `hermes update` to v0.20.0 (this bumps `python-telegram-bot[webhooks]` 22.6 → 22.8). 3. `systemctl restart hermes-gateway` (or otherwise start the gateway). 4. Gateway comes up with \"no connected platforms\"; Telegram enters a retry loop, each attempt failing with `Any cannot be instantiated`. ### Expected Behavior The Telegram adapter connects and the gateway serves Telegram normally — exactly as it did on 0.19.1 with python-telegram-bot 22.6 (`✓ telegram connected`). ### Actual Behavior Telegram fails to connect on every attempt; the gateway starts with no messaging platform connected: ``` WARNING [Telegram] Discovering Telegram API fallback IPs via DNS-over-HTTPS… INFO [Telegram] Auto-discovered Telegram fallback IPs: 149.154.166.110 ERROR [Telegram] Failed to connect to Telegram: Any cannot be instantiated WARNING gateway.run: ✗ telegram failed to connect WARNING gateway.run: Gateway started with no connected platforms — 1 platform(s) queued for retry: telegram: Telegram startup failed: Any cannot be instantiated ``` The gateway keeps running for cron jobs, but no Telegram messages are received or answered. It retries every few minutes with the same error. ### Affected Component Gateway (Telegram/Discord/Slack/WhatsApp) ### Messaging Platform (if gateway-related) Telegram ### Debug Report ```shell `hermes debug share` was intentionally not uploaded (see the redaction note under \"Additional Logs\"). Key info provided manually: version: 0.20.0 [fa83af3f] (2026-08-13) os: Linux 6.8.0-134-generic x86_64 python: 3.11.15 model: deepseek/deepseek-v4-flash provider: openrouter messaging pin: python-telegram-bot[webhooks] 22.8 (broken) → 22.6 (works) ``` ### Operating System Ubuntu 24.04 ### Python Version 3.11.15 ### Hermes Version 0.20.0 [fa83af3f] ### Additional Logs / Traceback (optional) ```shell Secondary security note: `hermes debug share` does NOT redact the Apify API token stored as `Authorization: Bearer apify_api_…` in config.yaml MCP server headers — the full token appears verbatim in the generated `--local` bundle. (All standard .env secrets ARE redacted correctly.) Worth extending the redactor to also cover MCP-header credentials. ``` ### Root Cause Analysis (optional) v0.20.0 bumped the `messaging` pin `python-telegram-bot[webhooks]` from **22.6 → 22.8** (pyproject.toml). The error occurs only with 22.8; reverting to 22.6 fixes it. This looks like the same `HTTPXRequest`-subclass instrumentation (used for the DNS-over-HTTPS fallback-IP feature) that has broken on earlier python-telegram-bot version bumps — cf. #64482 / #64694 / #67817 (\"`HTTPXRequest.do_request` is read-only\"), fixed in #64506. python-telegram-bot 22.8 (released 2026-08-04) appears to break that instrumentation again, now surfacing as `Any cannot be instantiated` during client instantiation. Not a duplicate of the *hang* issues #72454 and #78052 (the latter closed as a dup of #72454): those are an `Application.initialize()` deadlock with no timeout. This is an immediate exception at instantiation on 22.8 — the adapter never reaches the connect/hang stage. On 22.6 the gateway connects normally, so the hang does not affect this setup. Confirmed workaround: ``` venv/bin/pip install 'python-telegram-bot[webhooks]==22.6' && systemctl restart hermes-gateway # → \"✓ telegram connected\" ``` ### Proposed Fix (optional) Revert the `python-telegram-bot` pin to 22.6, or update the `HTTPXRequest` instrumentation for 22.8 compatibility (cf. the approach in #64506). ### Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85272",
        "timestamp": "2026-08-13T12:16:41Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "platform/telegram",
          "P2",
          "needs-repro",
          "sweeper:risk-message-delivery",
          "bug"
        ],
        "author": "Rainer-Edlinger",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85278",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "fix(cli): Windows PowerShell same-surface guard weak + pid_alive fallback chain + spinner-loop interval asymmetry + voice auto-restart direct invalidate",
        "text": "## Summary Line-by-line audit of `cli.py` (~18,915 lines) and `hermes_cli/active_sessions.py` at commit `036516d63` for the Windows PowerShell stacked-frame-prompt artifact. I verified each candidate against the actual code before flagging — the well-known vectors (refresh_interval default, _invalidate throttle, erase_when_done, spinner_loop idle sleep) are all correct in this checkout and are NOT the cause. What follows are real gaps worth fixing. ## What I checked and cleared (no false positive) - `Application(refresh_interval=...)` at cli.py:17563 reads `display.cli_refresh_interval` with `.get(..., 0)` — correct, default 0 in the code path. PR #53655 aligns DEFAULT_CONFIG. - `_invalidate()` at cli.py:4872 gates on `min_interval` + `_resize_recovery_pending` — correct. - `spinner_loop` at cli.py:17656 sleeps when `_command_running` is false (17664-17670) — correct, the idle path intentionally does NOT invalidate (comment at 17665-17669). NOT a bug. - `erase_when_done=True` at cli.py:17574 — present. - `_on_tool_progress` scrollback at cli.py:12404-12420 — intentionally prints stacked lines for \"new\"/\"all\"/\"verbose\" modes; this is by-design tool history, not the artifact. - `same_surface_holder` check in `active_sessions.py:334-348` — works when `max_sessions` is set. - Direct `app.invalidate()` sites at cli.py:17811/17829 — one-shot turn-boundary, intentional. - `_cprint` at cli.py:3132 uses `print_formatted_text(ANSI(...))` — patch_stdout-aware, NOT raw stdout. ## Real issues ### 1. Windows same-surface detection is weak (active_sessions.py:31-43) `_get_terminal_surface_id()` uses `GetConsoleTitleW` on win32. Two PowerShell tabs with the default title both resolve to `win32-console:Windows PowerShell` and the same-surface guard either fires incorrectly (false refusal) or misses (both get the same key and the `pid != os.getpid()` check at line 339 is the only differentiator, which is itself fragile under PID reuse). On Windows Terminal with multiple PowerShell tabs this is the realistic failure mode for the stacked-frame guard. Suggested fix: on win32, also read the console attachment via `GetConsoleWindow()` + `GetWindowThreadProcessId()` to get a window-handle-based surface ID, or use the ConPTY handle (`CreatePseudoConsole` / `ConPTY` API) when available, so each PowerShell tab gets a distinct surface key. ### 2. `_pid_alive` fallback chain is fragile / redundant (active_sessions.py:247-268) `active_sessions._pid_alive` calls `gateway.status._pid_exists`, which has its own psutil import at gateway/status.py:760. If psutil is somehow missing in BOTH modules (stripped install, import race during scaffold), `_pid_alive` catches the exception at line 259 and returns `False`. That makes EVERY entry look dead — `_prune_dead` at line 271 wipes the whole registry and the same-surface guard loses all prior-state knowledge. The two modules should share one authoritative `_pid_exists` (or lazy-import at the active_sessions layer only) instead of each having its own import + fallback. ### 3. Voice auto-restart direct invalidate (cli.py:17877) At the end of `process_loop`'s turn cleanup, when voice continuous mode auto-restarts recording, `app.invalidate()` is called directly (line 17877) from a daemon thread, bypassing the `_invalidate` throttle. If this fires while the spinner_loop is sleeping (idle, 0.2s gap) it produces an unthrottled repaint. Single-shot and rare, but worth routing through `_invalidate(min_interval=0.0)` for consistency with the rest of the turn-boundary path. Minor. ## Environment - Windows 10, PowerShell 7 + Windows Terminal - Hermes commit `036516d63` (fix/cli: same-surface guard, orphan reapers, cursor-blink suppress, mcp_servers config fix) - `display.interface: cli`, `display.streaming: false`, `display.cli_refresh_interval: 0` - `max_concurrent_sessions` set to a positive integer (same-surface guard enabled) - Multiple PowerShell tabs / Windows Terminal panes ## Validation attempted - Read cli.py line-by-line around the Application construction, _invalidate, spinner_loop, process_loop, _on_tool_progress, and the active_sessions surface-detection path. - Checked each previously-reported vector (refresh_interval default, throttle bypass, erase_when_done, spinner idle behavior) against the actual code — all correct in this checkout. - Checked for multi-process stacking via active_sessions same-surface guard — guard exists and works when max_sessions is set, but Windows surface key is weak. ## Labels `comp/cli`, `type/bug`, `sweeper:risk-platform-windows`, `P2`",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85278",
        "timestamp": "2026-08-13T12:26:04Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P2",
          "needs-repro",
          "sweeper:risk-session-state",
          "sweeper:risk-platform-windows",
          "platform/windows"
        ],
        "author": "iap",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85288",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Design] Tool-aware relevance filtering for tool output before context injection",
        "text": "## Problem `tool_output.max_bytes`, `tool_output.max_lines`, and `tool_output.max_line_length` cap tool output by **size**, not by **relevance**. Every tool result is injected into the agent context as a raw blob up to that cap. That's the wrong axis for a lot of tools. Concrete examples: - **`search_files` / grep-style tools**: a search across a codebase can return 200 matching lines. In most cases, only the first few matches and the last few (if the agent is scanning for a pattern near a boundary) actually matter. The middle 190 lines are context noise. Today they all enter context equally. - **`read_file`**: reading a 5000-line file to check the last 10 lines (e.g. \"what does the bottom of this config say?\") currently injects the whole file up to the cap. The answer is in lines 4990-5000; the other 4990 lines are wasted context. - **`patch` / `write_file`**: returns a full unified diff. The agent already knows what it asked the tool to change — the diff is informational for the human reader, not necessary context for the agent to continue. (#49316 makes this point for diffs specifically.) - **`terminal`**: a long command output where the relevant part is the tail (error at the end) or the head (banner/version at the top). The middle is frequently irrelevant. - **`search_files` with subdirectory hint injection**: can append large instruction files (AGENTS.md, CLAUDE.md) to the result (#47194 covers this specific inflation source, but the broader principle is the same — tool output shouldn't silently ingest unrelated large payloads). Related issues capture **specific instances** of this: - #47194 — subdirectory hint files inflating `search_files` output - #49316 — `patch`/`write_file` diffs persisting verbatim in agent context - #15962 — auto-scaling tool-output caps from model context (configuration side) None of them ask the general question: **why does Hermes treat all tool output as equally worth keeping verbatim, regardless of which part the tool's semantics imply is relevant?** ## Proposed Direction Add a **tool-aware relevance filter** that runs on tool results before they're injected into the agent context, per tool type. The size cap stays — this is a complement, not a replacement. The filter answers: \"for this tool, which subset of the output is the agent likely to need?\" Possible shape (design discussion, not a committed API): ### 1. Per-tool result profiles Each tool declares a result-handling profile: ``` tool_result_profiles: search_files: mode: bounded_matches # keep first N + last N matches, summarize middle first_matches: 5 last_matches: 5 middle_summary: \"N additional matches omitted\" read_file: mode: tail_or_head # when file is large relative to query intent, prefer tail/head default_tail_lines: 50 default_head_lines: 50 full_if_under_chars: 4000 # small files still pass through patch: mode: summary # agent doesn't need the diff verbatim summarize: \"patched N files: <file list>\" suppress_diff: true # aligns with #49316 write_file: mode: summary summarize: \"wrote <path>, N chars\" terminal: mode: smart_tail # prefer tail for error-detecting commands, head for version/banner default_tail_lines: 100 default_head_lines: 50 ``` This is intentionally a **configurable profile system**, not hardcoded per tool — new tools can be added without code changes, and users can override for their workflows. ### Before/after example **`search_files` returning 200 matches, query: \"find all usages of X\":** Before (current behavior): ``` <all 200 matching lines injected verbatim, each consuming context> ``` After (profile `bounded_matches`, first=5, last=5): ``` match 1: src/a.ts:42: const x = new X() match 2: src/b.ts:10: import { X } from \"./x\" ... match 5: src/e.ts:88: x.doSomething() [... 190 additional matches omitted — use narrower search to see all] match 196: tests/z.test.ts:203: expect(x).toBeDefined() ... match 200: docs/api.md:12: X is deprecated in v3 ``` The agent gets the signal (pattern exists in files a/b/e/z, docs) without 190 lines of middle noise. A narrower follow-up search retrieves the omitted matches if needed. ### 2. Size cap still applies on top The existing `tool_output.max_bytes` / `max_lines` / `max_line_length` remain as a hard backstop. The relevance filter runs first, then the size cap clips anything that still exceeds it. This is backward-compatible — tools with no profile just get the current behavior. ### 3. Metadata-only mode for hint injection For subdirectory/project-context discovery (the #47194 case), the default should be **metadata only** in the visible tool result: ``` [Subdirectory context discovered: .hermes/hermes-agent/AGENTS.md, 69199 chars, not injected] ``` Full injection, if available at all, should be opt-in and size-limited. The discovered content shouldn't silently dominate a small search result. ### 4. Explicit vs. implicit intent Some tools need the full output (e.g. a tool that returns structured JSON the agent must parse). The profile system should have a `mode: full` default for those, and the relevance filter should be **off by default for tools that declare their result as structured data the agent must consume verbatim**. ## What this is NOT - Not a replacement for #15962 (auto-scaling caps). The two are complementary: #15962 sets the right ceiling; this issue asks that we fill that ceiling intelligently rather than with raw blobs. - Not a replacement for #47194 or #49316. Those are specific instances; this is the general principle that would make those fixes part of a coherent system rather than one-off patches. - Not compression / context compaction. Compression runs when the context is already too large. This runs at tool-result-inject time, preventing the bloat from entering context in the first place. ## Non-goal for now I'm not asking for a full semantic summarizer (LLM-based) on every tool result. That's a possible future layer, but the first pass should be **rule-based and tool-aware**: each tool knows its own output shape, and the relevance filter uses that shape to pick what to keep. An LLM summarizer on top of that is a separate enhancement. ## Questions for discussion 1. Is a per-tool profile system the right abstraction, or should this be handled inside each tool's result builder (tool authors decide what to return)? 2. For `read_file`, how should Hermes infer \"the user wants the tail\" vs \"the user wants the whole file\"? Query text analysis? Always-offer-a-preview? Configurable default? 3. Should the relevance-filtered result be what the agent sees, while the full result is persisted to disk/on-demand (similar to the existing `result_persist_threshold_chars` indirection)? Or should the filter just reduce what enters context, with the full thing gone? 4. Backward compatibility: should the default be `mode: full` for all existing tools (current behavior preserved) with users opting into profiles, or should a few high-impact tools (`search_files`, `read_file`, `terminal`) default to a smarter profile? 5. Is the context savings worth the implementation complexity? A lighter-weight alternative — per-tool configurable size caps (e.g. `search_files.max_lines=10` by default) — might capture most of the benefit with far less machinery. Is a full profile system the right investment, or is that overkill for the problem? ## Environment - Hermes Agent current main - Observed on long-running project sessions where tool-output noise is a material context cost - Related to context-bloat observations in #32156 (long-session context growth from tool-output noise)",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85288",
        "createdAt": "2026-08-13T12:40:32Z",
        "updatedAt": "2026-08-13T13:01:51Z",
        "timestamp": "2026-08-13T13:01:51Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "innovation",
          "comp/agent",
          "comp/tools",
          "P3",
          "area/compression"
        ],
        "author": "RajeshShrirao",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85294",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Desktop cross-profile prompt leak: one profile backend resumes and writes another profile's session",
        "text": "## Bug Description Hermes Desktop violated both session and profile isolation on a local Windows installation. While secondary/compact session windows belonging to two different profiles were active, a prompt intended for Profile A appeared, executed, and was durably stored in the unrelated Profile B session. It appeared twice in Profile B. This was not only a rendering artifact: - The exact same redacted prompt hash was persisted in three distinct message rows. - One row belongs to Profile A / Session A. - Two rows belong to Profile B / Session B. - Every row has a distinct message ID and its embedded `session_id` matches the destination session. - The Profile B backend also resumed, auto-continued, and compressed Profile A's session. The replies were generated using each destination session's different conversation context. **Impact:** cross-profile information and instructions can contaminate unrelated conversation histories and be executed by the wrong agent context. This breaks the integrity and confidentiality boundary users expect from Hermes profiles. There is no evidence of external exfiltration or total database corruption in this incident, but this should be triaged as P1/High because stored conversation data crossed a profile boundary. ## Steps to Reproduce This is the exact observed incident sequence. It has not been reduced to a deterministic click-by-click reproduction because intentionally repeating it would risk additional cross-profile writes. 1. Run Hermes Desktop with two secondary/compact session windows: - Profile A, Session A: `20260812_190824_3b1b48` - Profile B, Session B: `20260813_104421_d30d11` 2. Keep both long-running sessions active so that context compression is triggered. Another Profile B session was also active concurrently. 3. An interrupted-turn marker existed for Session A. 4. Send a prompt from the Profile A window while the sessions are active/compacting. 5. Hermes displays: `No reply: the turn was stopped because session storage was busy (another Hermes process was writing to the state database).` 6. Observe that the prompt appears and is answered in Session B. 7. Inspect sanitized logs and native session exports: the Profile B backend auto-continued Session A and the same prompt was persisted once in Session A and twice in Session B. ## Expected Behavior - A backend serving Profile B must never resume, auto-continue, compress, or submit a prompt into a session owned by Profile A unless the Desktop connection has been atomically re-homed to that owning profile. - Queued prompts and interrupted-turn markers must remain bound to one immutable `(profile, stored session ID, runtime session ID)` tuple. - A prompt must execute and persist exactly once in its intended session. - Secondary windows must remain isolated even while multiple sessions are running or compressing. ## Actual Behavior - The Profile B backend processed both Session B and the unrelated Profile A Session A. - The prompt was executed in both conversation contexts. - The prompt was durably persisted three times across the two sessions. - Profile B's backend held Profile A's compression lease for approximately 207 seconds. - Profile A's own backend then encountered the foreign lease and emitted the generic storage-busy warning. ## Affected Component - Other: Hermes Desktop secondary-window and session routing - Agent Core: interrupted-turn auto-continue and context compression - Gateway/session dispatch ## Messaging Platform (if gateway-related) N/A — Hermes Desktop local session windows. ## Debug Report Not uploaded. `hermes debug share` would upload local configuration and logs containing private conversation data. Sanitized forensic evidence is included below. Raw logs can be further redacted if maintainers specify the exact fields required. ## Operating System Microsoft Windows 11 Pro, version 10.0.26200, build 26200 ## Python Version - Hermes runtime: Python 3.11.15 - System Python: 3.14.6 ## Hermes Version Hermes Agent v0.20.0 (2026.8.3) - Installed build: `5e09333d5753fc2b97b9e246c916509c8e4628fd` - Build branch: `local/update-live-20260811` - Install method: git - The installation has six carried local commits. - Current main was not installed during diagnosis in order to preserve the active sessions and incident evidence. The affected build already contains: - Fix commit `8830f22` from PR #73875 - Merge commit `c9de69c` from PR #74581 Both are verified ancestors of the installed build. ## Additional Logs / Traceback (sanitized) All timestamps are 2026-08-13, Europe/Berlin. ```text 13:54:47.922 Profile B backend: conversation turn started in Session B prompt prefix matches the duplicated prompt hash 13:55:01.237 Profile B backend: auto-continue scheduled for Session A attempt=1, interrupted=761s ago 13:55:04.140 Profile B backend: conversation turn started in Profile A's Session A prompt prefix matches the same duplicated prompt hash 13:55:04.207 Profile B backend: compression started for Session A messages=228, estimated tokens=408773 13:55:20.150 Profile A backend: conversation turn started in Session A same prompt prefix 13:55:20 Profile A backend: compression skipped because holder=pid=33840 PID 33840 is the Profile B backend 13:58:31.793 Profile B backend: compression committed for Session A messages 228 -> 50 13:58:33.556 Profile B backend: another conversation turn started in Session B same prompt prefix ``` The storage errors were compression-lease conflicts, not ordinary SQLite locks: ```text Session DB append_message failed: Session '20260812_190824_3b1b48' is being compressed by another writer ``` The UI currently maps this condition to the generic message: ```text No reply: the turn was stopped because session storage was busy (another Hermes process was writing to the state database). ``` Persisted-message evidence: ```text Prompt SHA-256: 5f7ba2df307e81a88e1cbd3ceda58189805e549ef7b5ec5a73a7521a70a163a5 Profile B / Session B: 13:58:03.393 Profile A / Session A: 13:58:31.746 Profile B / Session B: 13:58:33.562 ``` The prompt content is intentionally omitted because it contains private financial context. ## Root Cause Analysis ### Verified 1. This is a durable routing failure, not just stale rendering. 2. The Profile B backend ran a turn and compression against Profile A's Session A. 3. Profile B's process owned Profile A's compression lease. 4. The same prompt was persisted in both sessions. 5. Existing queued-drain isolation fixes from #73875 and #74581 are present in the affected build. ### Probable boundary A stale or non-atomic association between the secondary window, owning profile, stored session ID, runtime session ID, queue/marker ownership, and backend connection allowed Profile A's interrupted turn to be resumed inside Profile B's backend. Secondary-window URLs carry the session ID (`?win=secondary#/<sessionId>`) but not the owning profile. The renderer resolves the profile and re-homes the gateway connection later. The logs indicate that this handoff, or the subsequent auto-continue path, permitted a cross-profile session/runtime association. Production logs do not currently record the full tuple at `session.resume`, auto-continue scheduling, and `prompt.submit`, so the exact frontend source line is not yet proven. ## Proposed Fix 1. Treat `(owning profile, stored session ID, runtime session ID)` as one immutable routing identity for: - secondary windows; - queued prompts; - interrupted-turn markers; - `session.resume`; - auto-continue; - compression. 2. Before submitting or auto-continuing, atomically verify that the gateway connection is serving the session's owning profile. Drop and re-resume on mismatch. 3. Never let a process begin compression for a session whose resolved owning profile disagrees with the connection/window routing identity. 4. Add a regression test with: - two compact windows; - two profiles; - an interrupted-turn marker; - simultaneous long compression; - one unique prompt. The oracle should require exactly one persisted prompt row and zero turns/compressions in the unrelated profile. 5. Log the sanitized routing tuple at resume/submit time: `windowId, connectionProfile, owningProfile, storedSessionId, runtimeSessionId, queueOwner, autoContinueMarkerOwner`. ## Related Issues - #46194 — related queued-follow-up/session-switch leakage, but it does not establish this cross-profile auto-continue/compression path. - #71527 — related profile-routing failures, but its reported remote WebSocket mechanism differs from this local secondary-window incident. - #84234 and #81227 — related cross-process compression-lease contention; they explain the storage warning but not why the wrong profile backend owned the session. - #59305 — earlier general cross-session content mixing. - PR #73875 — queued-drain stale runtime binding fix, already present. - PR #74581 — origin-session queue binding fix, already present. ## Workaround No verified permanent workaround. Containment only: avoid sending or resending prompts from secondary windows belonging to different profiles while any of those sessions is running or compressing. The UI's “send it again” instruction is unsafe in this failure mode because the message may already have persisted elsewhere. ## Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85294",
        "createdAt": "2026-08-13T12:57:21Z",
        "updatedAt": "2026-08-13T13:20:59Z",
        "timestamp": "2026-08-13T13:20:59Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "P2",
          "needs-repro",
          "sweeper:risk-session-state",
          "sweeper:risk-platform-windows",
          "comp/desktop",
          "platform/windows",
          "area/sessions",
          "area/profiles"
        ],
        "author": "akivavh",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85295",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: -  ×  X  \"workspace\" failed to render  Maximum call stack size exceeded  G Retry  C Running 7: 11  13%  32.8k/1M 22255  Session 1:17  # v0.20.0 (+31 7060ac7",
        "text": "### Bug Description - <img width=\"1614\" height=\"1016\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/f60b67c5-4ca5-40e1-9b1d-911cd2fd1159\" /> × X \"workspace\" failed to render Maximum call stack size exceeded G Retry C Running 7: 11 13% 32.8k/1M 22255 Session 1:17 # v0.20.0 (+31 7060ac7 <img width=\"1614\" height=\"1016\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/49d56b24-a959-4c89-a9e2-ca8109da5fa4\" /> ### Steps to Reproduce <img width=\"1614\" height=\"1016\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/77cdb18c-374f-48aa-8636-900429204ffc\" /> ### Expected Behavior <img width=\"1614\" height=\"1016\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/0c635bd3-4dee-4c41-a217-a1f58d15b02a\" /> ### Actual Behavior <img width=\"1614\" height=\"1016\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/f7f0e8bd-f375-4c68-bfb1-3efe5d322bc6\" /> ### Affected Component CLI (interactive chat) ### Messaging Platform (if gateway-related) N/A (CLI only) ### Debug Report ```shell https://paste.rs/J5gX8 ``` ### Operating System windows 11 ### Python Version Python 3.13.0 ### Hermes Version Hermes Agent v0.20.0 (2026.8.3) · upstream fa83af3f Install directory: C:\\Users\\USER\\AppData\\Local\\hermes\\hermes-agent Install method: git Python: 3.11.15 OpenAI SDK: 2.24.0 Up to date ### Additional Logs / Traceback (optional) ```shell ``` ### Root Cause Analysis (optional) _No response_ ### Proposed Fix (optional) _No response_ ### Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85295",
        "createdAt": "2026-08-13T12:58:25Z",
        "updatedAt": "2026-08-13T13:13:34Z",
        "timestamp": "2026-08-13T13:13:34Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "P3",
          "needs-repro",
          "sweeper:risk-platform-windows",
          "comp/desktop",
          "platform/windows",
          "bug"
        ],
        "author": "chilawautoparts1-eng",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85297",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: install.sh prints \"Installation Complete!\" and exits 0 after install_node_deps fails, leaving no node_modules",
        "text": "`scripts/install.sh` prints \"Installation Complete!\" and exits 0 after `install_node_deps` fails, leaving no `node_modules`. ## What happens `install_node_deps` runs `npm install` inside the cloned repo. The failure is caught under a `log_warn` and execution continues; `log_success` then runs unconditionally, `main()` reaches the completion banner, and the script exits **0**. So a dependency install can fail outright and the installer reports success. The hermes launcher is written and works — `hermes --version` responds — while the repo has no `node_modules` at all. ## Observed Automated image build, 2026-08-12, Ubuntu 24.04, node v22.23.2, installing from a pinned ref (`9de9c25f620ff7f1ce0fd5457d596052d5159596`). From npm's own debug log on the resulting disk: ``` verbose cwd /home/agent/.hermes/hermes-agent verbose argv \"install\" \"--loglevel\" \"silent\" ... error at ClientRequest.<anonymous> (.../got/dist/source/core/index.js:970:111) silly unfinished npm timer build:run:postinstall:apps/desktop/node_modules/electron verbose exit 1 verbose code 1 ``` A TLS/network failure during electron's postinstall. `npm` exited 1. The installer then printed: ``` ┌─────────────────────────────────────────────────────────┐ │ ✓ Installation Complete! │ └─────────────────────────────────────────────────────────┘ ``` and exited 0. The final image had no `~/.hermes/hermes-agent/node_modules`. The underlying network failure is transient and not the complaint — the issue is that it was reported as success. ## Why it is hard to catch downstream The obvious post-install checks all pass on the broken result: - `/home/agent/.local/bin/hermes` exists and is executable — the launcher is written regardless. - `hermes --version` responds correctly. - The root `package.json` declares **no** runtime dependencies (everything real lives in the workspaces), so \"do the root package's deps resolve\" is vacuously true on a broken tree as well as a healthy one. A consumer therefore has to know the workspace layout to write a check that can fail at all. In our case the broken image was caught only because an unrelated step aborted the build first; otherwise it would have shipped as a clean image and failed at first use of the browser tooling. ## Suggestion Have `install_node_deps` propagate the failure — either fail the install outright, or keep the warning but exit non-zero, so the completion banner is not reached. Anything that makes the exit status distinguish the two outcomes would be enough for automated consumers. ## Reference Line reference is against the pinned ref above; `install_node_deps` is around `scripts/install.sh:2132` there, and the same shape appears in current `main`. Happy to supply the full npm debug log or test a patch if useful.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85297",
        "createdAt": "2026-08-13T12:59:11Z",
        "updatedAt": "2026-08-13T13:22:44Z",
        "timestamp": "2026-08-13T13:22:44Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P1",
          "sweeper:risk-compatibility",
          "comp/desktop",
          "area/install-update"
        ],
        "author": "oussemamaknii",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85301",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Bug: MCP servers page ignores the active profile — shows default profile's servers in any profile",
        "text": "**Bug: MCP servers page ignores the active profile — shows default profile's servers in any profile** I run Hermes with two profiles (default + a secondary business profile). MCP servers are configured **only** in default (`mcp_servers` in default's config.yaml). The secondary profile has no `mcp_servers` at all — `hermes --profile <name> mcp list` confirms \"No MCP servers configured\". **What happens:** in the desktop app / web dashboard, when I switch to the secondary profile and open the MCP page, it still lists all MCP servers from the **default** profile — and they show as error/failed, because they are not actually connected under the secondary profile's runtime. **Root cause (frontend):** `getMcpServers()` calls `GET /api/mcp/servers` **without** the `profile` query param — unlike `getSessions`, `getCronJobs`, `getToolsets` etc., which pass the active profile (`profile=v()`). The dashboard process runs under the default profile's home, so it always returns default's servers regardless of the profile selected in the UI. **Expected:** the MCP servers page should be profile-scoped — selecting a profile with no MCP servers should show an empty list, not the default profile's servers (with bogus error states). **Suggested fix:** pass the active profile to `getMcpServers` (and for consistency to `testMcpServer`, `setMcpServerEnabled`, `removeMcpServer`, `authMcpServer`). Related: #80746, #85204 (runtime side — different layer, but same profile-scoping theme).",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85301",
        "createdAt": "2026-08-13T13:05:24Z",
        "updatedAt": "2026-08-13T13:22:45Z",
        "timestamp": "2026-08-13T13:22:45Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "tool/mcp",
          "P2",
          "comp/dashboard",
          "area/profiles"
        ],
        "author": "antonbru",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85302",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "bug(desktop): agent-created sessions don't paint as live in the sidebar",
        "text": "The sidebar derives the live/working dot from renderer-side turn activity only. Sessions created by other surfaces (CLI, TUI, gateway, cron) that are actively running on the backend appear idle until the renderer sees a turn. Expected: the backend's `active_list` / liveness stamps drive the dot for foreign (agent-created) sessions too. Implementation: #84821",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85302",
        "createdAt": "2026-08-13T13:05:46Z",
        "updatedAt": "2026-08-13T13:13:15Z",
        "timestamp": "2026-08-13T13:13:15Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "P3",
          "sweeper:risk-session-state",
          "comp/desktop",
          "area/sessions"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85303",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "bug(desktop): resuming a finalized session re-lights liveness without any new activity",
        "text": "Opening (mounting) a finished session reopens its row (`ended_at` cleared), and the dot-state machine paints it as live even though no message was sent. Expected: resume is read-only; only a real first turn (real activity) reopens/re-lights a finalized session. Implementation: #84847 (stacked on #84821).",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85303",
        "createdAt": "2026-08-13T13:05:47Z",
        "updatedAt": "2026-08-13T13:13:10Z",
        "timestamp": "2026-08-13T13:13:10Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "P3",
          "sweeper:risk-session-state",
          "comp/desktop",
          "area/sessions"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85304",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "feat(sessions): heal orphan non-chat session rows after 24h idle",
        "text": "Session rows created by one-shot surfaces (cli, acp, cron, subagents) that never close accumulate as open rows with no liveness. Expected: a maintenance pass closes non-chat rows idle for 24h+ (orphan_heal). Implementation: #84846.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85304",
        "createdAt": "2026-08-13T13:05:48Z",
        "updatedAt": "2026-08-13T13:13:05Z",
        "timestamp": "2026-08-13T13:13:05Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "P3",
          "sweeper:risk-session-state",
          "area/sessions"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85305",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "feat(desktop): mark a session as unread with a persisted watermark",
        "text": "The sidebar has no way to mark a session unread or track read-state across restarts. Expected: mark unread/read actions backed by the existing `last_read_at` watermark, surfaced as a persisted unread dot. Implementation: #84822.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85305",
        "createdAt": "2026-08-13T13:05:49Z",
        "updatedAt": "2026-08-13T13:13:00Z",
        "timestamp": "2026-08-13T13:13:00Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "P3",
          "sweeper:risk-session-state",
          "comp/desktop",
          "area/sessions"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85306",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "bug(desktop): spaced currency amounts (US$ 5M) parsed as inline math",
        "text": "remark-math parses prose amounts like `US$ 5M` / `R$ 15.4M` as inline math, corrupting message rendering. Expected: escape currency amounts so the parser leaves them as prose. Implementation: #84183.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85306",
        "createdAt": "2026-08-13T13:05:50Z",
        "updatedAt": "2026-08-13T13:12:54Z",
        "timestamp": "2026-08-13T13:12:54Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "P3",
          "comp/desktop"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85307",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "bug(agent): empty title completions stall the title ladder",
        "text": "The title ladder descends on schema failures but returns unconditionally on empty 200 completions, so a session can end with no title. Expected: descend to the next rung on empty completions too. Implementation: #84137.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85307",
        "createdAt": "2026-08-13T13:05:51Z",
        "updatedAt": "2026-08-13T13:12:47Z",
        "timestamp": "2026-08-13T13:12:47Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P3"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85308",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "bug(desktop): stale inflight-journal folds duplicate transcript answers",
        "text": "A stale inflight-journal fold can re-emit answers that are already in the transcript, showing duplicated messages. Expected: skip folds whose journal state is stale relative to the transcript. Implementation: #84021.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85308",
        "createdAt": "2026-08-13T13:05:52Z",
        "updatedAt": "2026-08-13T13:12:42Z",
        "timestamp": "2026-08-13T13:12:42Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "P3",
          "sweeper:risk-session-state",
          "comp/desktop",
          "area/sessions"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85315",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug] auxiliary.free_only gate rejects explicitly-requested :free models and misreports the skip as a payment/credential error",
        "text": "## Title [Bug] `auxiliary.free_only` gate rejects explicitly-requested `:free` models and misreports the skip as a payment/credential error ## Summary When an auxiliary task resolves an OpenRouter client via `resolve_provider_client(provider=\"openrouter\")` with `auxiliary.free_only: true` and the caller passes an **explicit** `:free` model, the `model` argument is dropped and the gate evaluates the configured `auxiliary.openrouter_model` default (a **paid** SKU) instead. Every explicit `:free` request is silently rejected, OpenRouter is marked unhealthy as a bogus \"payment / credit error\", and the diagnostic blames missing/expired credentials that are actually present and valid. ## Root cause In `agent/auxiliary_client.py`, `resolve_provider_client`'s OpenRouter branch calls: ```python client, default = _try_openrouter(explicit_api_key=explicit_api_key) ``` dropping `model`. `_try_openrouter` computes `or_model = model or cfg_model`, so it falls back to `cfg_model` (from `_aux_openrouter_settings`, i.e. `auxiliary.openrouter_model` — the paid default). The gate then rejects any caller that explicitly asked for a `:free` model: ```python if free_only and not _is_free_model(or_model): ... _mark_provider_unhealthy(\"openrouter\", ttl=60) # logged as \"payment / credit error\" return None, None ``` `_describe_openrouter_unavailable()` also takes no `model` and cannot name the gate, so the failure is blamed on credentials. ## Impact With `auxiliary.free_only: true`, any explicitly-configured `:free` auxiliary model (e.g. `auxiliary.title_generation.model`, or any task in the auto-chain) is rejected, and OpenRouter is spuriously marked unhealthy for 60s — the exact free, reliable traffic the option is meant to guarantee. Confusing for users: the log hunts for a missing/expired API key that is present and funded. ## Proposed fix (patch attached) 1. Forward the caller's `model` into `_try_openrouter` from `resolve_provider_client`. 2. Give `_mark_provider_unhealthy`/`_describe_openrouter_unavailable` a precise `reason` so a config-gate skip is NOT reported as a payment/credit error. Regression tests: `TestFreeOnlyGateHonoursCallerModel` (4 tests) — all pass. ## Related Distinct from #75803 / #75838 / #75819 (those fix the hardcoded **paid default** model & paid-lane warning). This is the **gate-resolution** bug: the gate judges the wrong model because the request's model is dropped at the call site.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85315",
        "createdAt": "2026-08-13T13:22:47Z",
        "updatedAt": "2026-08-13T13:39:07Z",
        "timestamp": "2026-08-13T13:39:07Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/openrouter",
          "area/config",
          "P2",
          "sweeper:risk-compatibility",
          "area/billing"
        ],
        "author": "JulesLscx",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85321",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Approval gate: file tools bypass the shell-rc gate the terminal tool enforces; touch/mkdir/ln uncovered",
        "text": "## Summary `~/.bashrc` and the other shell rc files are gated by the terminal tool but **not** by the file tools, so `patch`/`write_file` can rewrite them with no prompt. Separately, `touch`, `mkdir` and `ln` are not covered by any `DANGEROUS_PATTERNS` rule, so the `write_file` deny on `~/.ssh/` is trivially reachable around via `terminal`. I know from SECURITY.md §2.4 that the approval gate is a heuristic and not a boundary, and from §3.2 that regex bypasses are out of scope for the private channel — hence a regular issue. The report is not \"the denylist is incomplete\". It is that **two layers documented as paired have drifted apart**, so the gate reports a denial it does not actually enforce, and the in-tree comments say this pairing is intended (`tools/approval.py:305-308`: *\"Pair the write_file/patch deny … with terminal-side coverage … otherwise the deny is unpaired theater\"*, and `tools/approval.py:930-933` on the earlier `cp evil ~/.bashrc` fix, *\"Same unpaired-door rationale as #14639\"*). If maintainers read item 1 as a §3.1 trust-model documentation violation rather than a §2.4 heuristic limit, I'm happy to refile it privately — I filed publicly because §3.2 seemed to be the better fit, not to pre-empt that judgement. ## Environment - Verified on `origin/main` @ `fa83af3f9` (2026-08-13) in a clean `git worktree`, **and** on the installed `v0.20.0 (2026.8.3)` @ `1d3d02128` - Arch Linux, Python 3.13, bundled venv, default local terminal backend - Surface where it was first hit: Telegram gateway (`plugins/platforms/telegram/`) ## 1. File tools do not honour the shell-rc gate the terminal tool enforces `tools/approval.py` puts `_SHELL_RC_FILES` inside `_SENSITIVE_WRITE_TARGET`, so the terminal side prompts. The file tools have no corresponding entry — `build_write_approval_paths()` (`agent/file_safety.py:111`) contains only `~/.ssh/config`. ```python >>> import os >>> from agent.file_safety import is_write_denied, is_write_approval_required >>> p = os.path.expanduser(\"~/.bashrc\") >>> is_write_denied(p), is_write_approval_required(p) (False, False) >>> from tools.approval import detect_dangerous_command as d >>> d(\"echo x > ~/.bashrc\")[0] # terminal side: gated True >>> d(\"sed -i 's/a/b/' ~/.bashrc\")[0] # terminal side: gated True ``` Functional repro against `main` with a sandboxed `HOME`: ```python r = patch_tool(path=f\"{HOME}/.bashrc\", mode=\"replace\", old_string=\"export X=1\\n\", new_string=\"export X=1\\n# INJECTED\\n\") # -> {'success': True}, no prompt; \"# INJECTED\" is in the file ``` Observed first over the Telegram gateway on v0.20.0: `patch` appended to `~/.bashrc`, returned `success: true`, no approval was shown. A shell rc write executes on the next shell, which is the same authority the terminal path already asks about. Same asymmetry applies to `.zshrc`, `.profile`, `.bash_profile` (all `False`/`False`). ## 2. The `~/.ssh` write deny is unpaired for file-creation verbs Real transcript, one turn earlier in the same Telegram session: 1. `write_file(\"~/.ssh/test.txt\")` → `Write denied: '/home/…/.ssh/test.txt' is a protected system/credential file.` 2. the model retried on its own initiative with `terminal`: `mkdir -p /home/…/.ssh && touch /home/…/.ssh/test.txt` → **exit 0, file created, no prompt.** `detect_dangerous_command()` on `main` covers redirection, `tee`, `cp|mv|install`, `sed -i`, `perl/ruby -i`, `dd`, `python3 -c open(...)`, nested `bash -c` and `curl | bash` against these targets, but nothing matches `touch` or `mkdir`: ```python >>> d(\"touch ~/.ssh/test.txt\")[0] False >>> d(\"mkdir -p ~/.ssh && touch ~/.ssh/test.txt\")[0] False ``` Related: the deny string in `agent/file_safety.py:216` carries no instruction against retrying by another route, while `_check_approval_required_write` (`tools/file_tools.py:991`) does (*\"Do NOT retry it via another path (terminal, execute_code)\"*). After I added the same sentence locally, the model stopped retrying — a one-line change with a visible behavioural effect. ## 3. `ln` is not covered ```python >>> d(\"ln -sf /tmp/evil ~/.bashrc\")[0] False >>> d(\"ln -s /tmp/evil ~/.ssh/authorized_keys\")[0] False ``` The rc file is replaced by a pointer to attacker-controlled content without touching any gated write verb. ## 4. Two correctness issues in `_check_approval_required_write` `tools/file_tools.py:985-1007`: - the prompt text is hardcoded to `\"SSH client config file(s)\"` — the moment any non-SSH path enters `build_write_approval_paths()`, the operator is asked to approve the wrong thing; - `pattern_key=\"ssh_config_write\"` and `autoapprove_log_prefix=\"ssh_config_write\"` are hardcoded too, so a single approve-**always** answer for `~/.ssh/config` would silently authorise every other approval-gated path from then on. Both are latent today (only one path is registered) but they are the reason a fix for item 1 cannot be a one-line list append. ## Suggested fix Shape of the patch I'm running locally (happy to open a PR): - `build_write_approval_paths()` — add `.bashrc .zshrc .profile .bash_profile .zprofile .zshenv .bash_login`, at the **approval** tier rather than hard deny, since editing one's own rc file is routine and the operator can still say yes. - `DANGEROUS_PATTERNS` — add `(touch|mkdir)` and `ln` rules against `_SENSITIVE_WRITE_TARGET`. - `_check_approval_required_write()` — derive the prompt text and `pattern_key` from the target class (`ssh_config_write` / `shell_rc_write`). - optionally, carry the \"do not retry via another path\" sentence into the hard-deny message. ### Verification of that patch - 5/5 previously-escaping commands now gated - 0 false positives on ordinary usage (`touch /tmp/x`, `mkdir -p ~/projects/new`, `cat ~/.bashrc`, `ln -s /tmp/a /tmp/b`, `ls ~/.ssh`) - no regressions on previously-gated commands (redirection, `cp`, `sed -i`, `dd`, `rm -rf /`, `curl | bash`) - `pytest tests/tools/test_file_write_safety.py` → 57 passed - `pytest tests/tools/test_approval.py tests/tools/test_approval_deny_rules.py tests/tools/test_file_tools.py` → 154 passed, 2 skipped - live re-test over Telegram: both requests returned `BLOCKED: denied by user`, the filesystem was unchanged, and the model did not attempt an alternate path ## Unrelated observation `tests/tools/test_approval_config_readonly.py::test_guard_never_calls_deepcopy_variant` and `::test_config_readers_never_call_deepcopy_variant` fail when the approval test files are run in one invocation (`assert calls[\"readonly\"] == 5` gets `4`) but pass in isolation. Reproduced on a clean tree with no local changes, so it looks like test-ordering pollution rather than anything in this report. Can file separately if useful.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85321",
        "createdAt": "2026-08-13T13:33:07Z",
        "updatedAt": "2026-08-13T13:33:07Z",
        "timestamp": "2026-08-13T13:33:07Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "ihsandeniz",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85322",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "DaemonThreadPoolExecutor crashes on Python 3.14: '_initializer' attribute removed in CPython 3.14",
        "text": "## Bug Report **Version:** Hermes Agent v0.20.0 (2026.8.3), running on Python 3.14.6 (Homebrew) ### Symptom Parallel tool calls fail with: ``` AttributeError: 'DaemonThreadPoolExecutor' object has no attribute '_initializer' ``` This happens when multiple tool calls are dispatched concurrently (e.g. several `web_search` calls in one turn). Single tool calls work fine. ### Root Cause `tools/daemon_pool.py` `_adjust_thread_count()` copies the CPython 3.8–3.13 implementation and references `self._initializer` / `self._initargs`: ```python args=( weakref.ref(self, weakref_cb), self._work_queue, self._initializer, # <-- AttributeError on 3.14 self._initargs, ), ``` CPython 3.14 **removed** `ThreadPoolExecutor._initializer` / `_initargs`. The constructor now calls `type(self).prepare_context(initializer, initargs)` and stores `_create_worker_context` / `_resolve_work_item_task` instead. Also, `_worker`'s signature changed in 3.14 from `(executor_reference, work_queue, initializer, initargs)` to `(executor_reference, ctx, work_queue)`. ### Impact Any parallel (batched) tool dispatch on Python 3.14 fails the whole batch. Hermes's Homebrew formula installs Python 3.14 (this user's install: `libexec/lib/python3.14/site-packages`), so this affects all Homebrew-installed Hermes on macOS. ### Fix (verified locally) Version-adaptive `_adjust_thread_count` that handles both paths: ```python if hasattr(self, \"_create_worker_context\"): # Python 3.14+ path ctx = self._create_worker_context() args = ( weakref.ref(self, weakref_cb), ctx, self._work_queue, ) else: # Python 3.8–3.13 path args = ( weakref.ref(self, weakref_cb), self._work_queue, getattr(self, \"_initializer\", None), getattr(self, \"_initargs\", ()), ) ``` Verified working on Python 3.14.6: `DaemonThreadPoolExecutor` parallel execution succeeds with the patch. ### Repro ```python from tools.daemon_pool import DaemonThreadPoolExecutor with DaemonThreadPoolExecutor(max_workers=4) as ex: futures = [ex.submit(print, i) for i in range(10)] [f.result() for f in futures] # AttributeError before patch, OK after ``` ### Environment - macOS (Homebrew install: `hermes-agent 2026.8.3_1`) - Python 3.14.6 - OpenAI SDK 2.24.0",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85322",
        "createdAt": "2026-08-13T13:37:41Z",
        "updatedAt": "2026-08-13T13:37:41Z",
        "timestamp": "2026-08-13T13:37:41Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "ShawnLiann",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85325",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Feature]: Add SayGM model provider support",
        "text": "### Problem or Use Case Hermes users cannot select SayGM through the provider/profile system today, despite SayGM exposing compatible Chat Completions models and live model discovery. Generic endpoint configuration does not provide curated fallback models, protocol filtering, model output limits, or the required Sol request adjustment. ### Proposed Solution Add a SayGM `ProviderProfile` using `https://api.saygm.com/v1` and `SAYGM_API_KEY`. Discover only available `chat.completions` models, retain a curated fallback list, map output limits, and omit static prices because routing prices can change at runtime. ### Alternatives Considered A generic OpenAI-compatible configuration was considered, but it cannot safely filter the mixed-protocol public catalogue or carry the provider-specific model metadata. A bundled provider profile matches Hermes' existing architecture. ### Feature Type Other: model provider integration ### Scope Medium (few files, < 300 lines) ### Contribution - [x] I'd like to implement this myself and submit a PR",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85325",
        "createdAt": "2026-08-13T13:45:24Z",
        "updatedAt": "2026-08-13T13:45:24Z",
        "timestamp": "2026-08-13T13:45:24Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "markdavison",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85327",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Desktop boot fails: /api/ws rejects session token when .env pins HERMES_DASHBOARD_SESSION_TOKEN",
        "text": "## Bug Description Hermes Desktop fails to boot with: ``` [hermes] [boot] could not read served dashboard token (Hermes backend): 404: {\"error\":\"Headless backend (hermes serve): web UI disabled — use `hermes dashboard` for the browser UI.\"} [hermes] [boot] Desktop boot failed: Local Hermes backend is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: WebSocket connection failed. ``` The desktop spawns its own headless backend (`hermes serve`), the child reports HTTP-ready, but the WebSocket auth probe is rejected. This reproduces on every boot attempt (retried 3x, same result). Trigger: any setup where `~/.hermes/.env` contains `HERMES_DASHBOARD_SESSION_TOKEN` (the documented remote-TUI setup in the dashboard docs). Works when the token is absent from `.env`. ## Root Cause Three behaviors collide: 1. **Desktop mints a fresh spawn token.** `apps/desktop/electron/main.ts` spawns the backend with `const token = crypto.randomBytes(32).toString('base64url')` and passes it via `HERMES_DASHBOARD_SESSION_TOKEN` in the child env. 2. **The backend's dotenv loader overrides it.** `hermes_cli/env_loader.py` loads `~/.hermes/.env` with `override=True`, so a token pinned there clobbers the spawn token before `hermes_cli/web_server.py` resolves `_SESSION_TOKEN` at import time. 3. **Headless `serve` hides the drift.** `mount_spa()` deliberately 404s every route when `HERMES_SERVE_HEADLESS=1` (never serves the SPA page that injects `window.__HERMES_SESSION_TOKEN__`). The desktop's token discovery (`adoptServedDashboardToken` in `apps/desktop/electron/dashboard-token.ts`) therefore falls back to its own spawn token, which is now wrong. Net effect: the child's `/api/ws` expects the `.env`-pinned token; the desktop probes with its fresh token; `_ws_auth_ok()` rejects it. The legacy runtime path (`dashboard --no-open`) served the SPA, so the desktop could adopt the served token and the drift was invisible. Any update that moves the desktop onto the `serve` runtime exposes this. ## Steps to Reproduce 1. Set a stable token: `echo \"HERMES_DASHBOARD_SESSION_TOKEN=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')\" >> ~/.hermes/.env` 2. Launch `hermes desktop` (v0.20.0+ with the `serve` runtime). 3. Observe boot failure in `~/.hermes/logs/desktop.log`. Minimal probe without the GUI: ```bash HERMES_DASHBOARD_SESSION_TOKEN=fresh-test-token-abc123 HERMES_DESKTOP=1 \\ hermes serve --host 127.0.0.1 --port 0 > /tmp/serve.out 2>&1 & sleep 8; PORT=$(grep -oP 'port=\\K[0-9]+' /tmp/serve.out | head -1) H=(-H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==') curl -s -o /dev/null -w 'fresh token -> %{http_code}\\n' \"${H[@]}\" \"http://127.0.0.1:$PORT/api/ws?token=fresh-test-token-abc123\" STABLE=$(grep '^HERMES_DASHBOARD_SESSION_TOKEN=' ~/.hermes/.env | cut -d= -f2-) curl -s -o /dev/null -w 'stable token -> %{http_code}\\n' \"${H[@]}\" \"http://127.0.0.1:$PORT/api/ws?token=$STABLE\" ``` Result: `fresh token -> 403`, `stable token -> 101`. ## Expected Behavior Desktop boots when `.env` pins a stable `HERMES_DASHBOARD_SESSION_TOKEN` (or at minimum fails with a message explaining the drift instead of a generic WS rejection). ## Actual Behavior Desktop boot fails on the WS auth probe and never connects. ## Environment - OS: Linux (Pop!_OS 24.04) - Version: v0.20.0 (2026.8.3), commit fa83af3f9a - Desktop: pre-built release at `apps/desktop/release/linux-unpacked` ## Proposed Fix The desktop should prefer an existing `HERMES_DASHBOARD_SESSION_TOKEN` from `$HERMES_HOME/.env` as the spawn token, minting fresh only when nothing is pinned. Since the backend loads `.env` with `override=True`, spawning with the pinned value keeps both sides in agreement; the headless 404 no longer matters because there is no drift to discover. Working patch (verified locally, 6 new unit tests in `apps/desktop/electron/dashboard-token.test.ts`): - `apps/desktop/electron/dashboard-token.ts`: add `resolveSpawnSessionToken(hermesHome)` which reads `HERMES_DASHBOARD_SESSION_TOKEN` from `process.env` first, then from `$HERMES_HOME/.env` (with quote/comment handling), falling back to `crypto.randomBytes(32).toString('base64url')`. - `apps/desktop/electron/main.ts`: both spawn sites (`const token = crypto.randomBytes(32).toString('base64url')`, lines ~8123 and ~8402) call `resolveSpawnSessionToken(HERMES_HOME)` instead. Alternative upstream directions worth considering: - Include the resolved token in the desktop ready-file payload (`HERMES_DESKTOP_READY_FILE`) so the desktop can adopt the child's actual token even headless. - Have `hermes serve` not override a spawn-env token when `HERMES_DESKTOP=1` (rejected in the patch above: the `.env` pin should win; the desktop is the component making the wrong assumption). ## Additional Context Removing `HERMES_DASHBOARD_SESSION_TOKEN` from `.env` also \"fixes\" the symptom but breaks the remote-TUI stable-token setup (the dashboard then rotates its token on every restart, invalidating clients that hardcode it).",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85327",
        "createdAt": "2026-08-13T13:45:47Z",
        "updatedAt": "2026-08-13T13:45:47Z",
        "timestamp": "2026-08-13T13:45:47Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "tommulkins",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85377",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Desktop build fails on Linux: get-windows npm install fails (404 prebuilt + gyp ModuleNotFoundError in venv)",
        "text": "## Bug Description During `hermes update`, the desktop app rebuild fails because the `get-windows@9.3.0` npm dependency cannot install on Linux. This causes the Electron GUI to be unavailable until the build eventually succeeds (or never, if the conditions persist). The failure chain: 1. `node-pre-gyp` tries to download a prebuilt binary for `get-windows` → **404 Not Found** (no Linux prebuilt exists) 2. Falls back to `node-gyp` source compilation → **fails** because `gyp_main.py` runs under the Hermes venv Python (`~/.hermes/hermes-agent/venv/bin/python3`), which does **not** have the `gyp` Python package installed 3. `npm ci` exits with code 1 → desktop build is skipped ## Steps to Reproduce 1. Install Hermes Agent on Linux (Ubuntu 26.04, Node v22.22.1) via git method 2. Run `hermes update` when there are upstream desktop changes that trigger a rebuild 3. The update pulls code successfully, but the desktop build step fails ## Expected Behavior The desktop app should rebuild successfully on Linux during `hermes update`, or `get-windows` should be handled gracefully (it's a Windows/macOS-only package — no Linux prebuilt binary exists and source compilation depends on `gyp` being available in the active Python environment). ## Actual Behavior `npm ci` fails with:npm ERR! code 1 npm ERR! path node_modules/get-windows npm ERR! command failed npm ERR! command sh -c node-pre-gyp install --fallback-to-build 1. Prebuilt binary download fails: npm ERR! node-pre-gyp http GET [github.com/sindresorhus/get-windows/releases/download/v9.3.0/napi-9-linux-glibc-x64.tar.gz](https://github.com/sindresorhus/get-windows/releases/download/v9.3.0/napi-9-linux-glibc-x64.tar.gz) npm ERR! node-pre-gyp ERR! install response status 404 Not Found 2. node-gyp fallback fails: npm ERR! gyp info find Python using Python version 3.11.15 found at \".../venv/bin/python3\" npm ERR! Traceback (most recent call last): npm ERR! File \"/usr/share/nodejs/node-gyp/gyp/gyp_main.py\", line 5, in npm ERR! from gyp import script_main npm ERR! ModuleNotFoundError: No module named 'gyp' npm ERR! gyp ERR! configure error ✗ Desktop dependency install failed Run manually: cd ... && npm ci ⚠ Desktop build failed (non-fatal; run hermes desktop to retry) The build failure is marked \"non-fatal\", but in practice the GUI goes down: The running Electron process is killed when the gateway restarts during update The new binary is not built, so hermes desktop cannot launch desktop.log shows ~351 backend boot cycles (repeated spawn → port assignment → immediate restart) until the build eventually succeeds on a later retry Root Cause Two compounding issues: get-windows has no Linux prebuilt binary. The package (by sindresorhus) only publishes Windows and macOS binaries. The napi-9-linux-glibc-x64.tar.gz release asset returns 404. On Linux, get-windows is functionally useless (it's for getting the active window title on Win/macOS). node-gyp picks up the Hermes venv Python, which lacks gyp. When node-pre-gyp falls back to source compilation, node-gyp discovers the Hermes virtualenv Python and uses it to run gyp_main.py. The venv doesn't include the gyp package, so the build crashes immediately. Environment OS: Ubuntu 26.04 LTS (Linux 7.0.0-29-generic, x86_64) Node.js: v22.22.1 (system, node-v127 ABI) npm: bundled with Node Python: 3.11.15 (Hermes venv) Hermes Agent: v0.20.0 (git install, commit fa83af3f) Electron: 40.10.2 Suggested Fixes Skip get-windows on Linux in the desktop build pipeline (it's a no-op package on this platform). The stage-native-deps script already stages it but with an empty .node binding. Or: Make get-windows an optional dependency (optionalDependencies in package.json) so its install failure doesn't break npm ci. Or: If the dependency must stay, ensure the Python environment used by node-gyp has gyp installed (e.g., pip install gyp into the venv, or point npm_config_python to a system Python that has it).",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85377",
        "createdAt": "2026-08-13T15:01:55Z",
        "updatedAt": "2026-08-13T15:08:01Z",
        "timestamp": "2026-08-13T15:08:01Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P2",
          "needs-repro",
          "sweeper:risk-compatibility",
          "comp/desktop",
          "area/install-update"
        ],
        "author": "hageliezioula",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85380",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "In cut 20, updates add LF, should not.",
        "text": "+# Bug Report: Spinner/status line smears vertically instead of updating in-place (v0.20.0, Windows) + +## Description + +After updating from v0.19.0 to v0.20.0, the CLI spinner/status line no longer updates in-place. Each animation frame renders on a new line, filling the terminal with hundreds of repeated status lines instead of a single updating line. + +## Environment + +- Hermes Agent v0.20.0 (2026.8.3) +- Windows 10 +- PowerShell (Windows Terminal) +- Python 3.11.15 +- Display interface: cli (not tui) +- Model: claude-opus-4-6 (Anthropic) + +## Expected Behavior + +The spinner status line (showing face, thinking verb, elapsed time, token flow, model info) should update in-place on a single line using carriage return (`\\r`). + +## Actual Behavior + +Every spinner tick prints on a NEW line, rapidly scrolling the terminal. The output looks like: + +``` +(?_?) brainstorming... (0.0s) ? claude-opus-4-6 159K/1M ] 16% ? 1 2.8d ? 5s | √ 50m +(?_?) brainstorming... (0.2s) ? claude-opus-4-6 159K/1M ] 16% ? 1 2.8d ? 5s | √ 50m +(?_?) brainstorming... (0.4s) ? claude-opus-4-6 159K/1M ] 16% ? 1 2.8d ? 5s | √ 50m +...hundreds more lines... +(?_?) cogitating... (0.0s) ? claude-opus-4-6 159K/1M ] 16% ? 1 2.8d ? 5s | √ 50m +(?_?) cogitating... (0.2s) ? claude-opus-4-6 159K/1M ] 16% ? 1 2.8d ? 5s | √ 50m +``` + +This continues throughout tool execution and model thinking, making the terminal nearly unusable. + +## Analysis + +The code in `agent/display.py` has a `_is_patch_stdout_proxy()` check that detects prompt_toolkit's `StdoutProxy` and suppresses `\\r`-based animation. However, the status line rendering path in `cli.py` (via `_render_spinner_text()` and the TUI widget system) appears to bypass this guard, causing each frame to render as a new [smear.bmp](https://github.com/user-attachments/files/31034352/smear.bmp) line through StdoutProxy's newline-injecting flush behavior. + +This is a regression from v0.19.0 where the spinner updated cleanly in-place. + +## Screenshot + +A screenshot showing the smearing behavior is available (24-bit BMP showing the full terminal filled with repeated status lines). + +## Workaround + +None found. `display.spinner_token_flow: false` reduces line length but doesn't fix the underlying issue. Many thanks. This is likely a very simple change. -- David Small p.s. ff you need to contact me, davidmsmall (at) gmail.com",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85380",
        "createdAt": "2026-08-13T15:10:27Z",
        "updatedAt": "2026-08-13T15:16:34Z",
        "timestamp": "2026-08-13T15:16:34Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P2",
          "needs-repro",
          "sweeper:risk-platform-windows",
          "platform/windows"
        ],
        "author": "davidmsmall-lab",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85381",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Telegram MarkdownV2 fenced-code regex over-matches inline triple backticks, corrupts content and drops rich formatting",
        "text": "### Bug Description The fenced-code protection regex in `plugins/platforms/telegram/adapter.py` (around line 8022, the `_protect_fenced` step before MarkdownV2 escaping) is too permissive: `r'(```(?:[^\\n]*\\n)?[\\s\\S]*?```)'` matches **inline** triple backticks anywhere in the text, not just standalone fenced blocks. When a message contains inline `` ``` `` sequences (e.g. the model discusses backtick syntax, paths, or inline code written as triple backticks), the matcher treats the inline span as a multi-line code block; `_protect_fenced` then re-splits it on `\\n` (which is absent), escapes the backticks/backslashes, and emits malformed MarkdownV2 entities (e.g. `\\`` escape sequences and unbalanced `` ` ``). Telegram then rejects the message with `MarkdownV2 parse failed`, and Hermes falls back to plain text — dropping all rich formatting (bold, tables, links) for the whole message. Real gateway log evidence (2026-08-13): ``` WARNING hermes_plugins.telegram_platform.adapter: [Telegram] MarkdownV2 parse failed, falling back to plain text: Can't parse entities: can't find end of pre entity at byte offset 922 WARNING hermes_plugins.telegram_platform.adapter: [Telegram] MarkdownV2 edit failed, falling back to plain text: Can't parse entities: can't find end of pre entity at byte offset 922 ``` `can't find end of pre entity` is the Telegram-side signature of a broken `<pre>` entity — exactly what the over-matching + naive `\\n` split produces. ### Steps to Reproduce 1. Configure Hermes Telegram gateway (any client; observed on Linux server + Telegram mobile/desktop). 2. Ask the agent something whose reply contains **inline** triple backticks on a single line, e.g. content like: `the syntax is ```like this``` inline` (or a path with backslashes next to inline ```). 3. Observe the gateway log: `MarkdownV2 parse failed ... can't find end of pre entity` or `character '...' is reserved`. 4. Observe the delivered message arrives as plain text with all markdown formatting stripped. Minimal regex repro (Python 3.11): ```python import re OLD = r'(```(?:[^\\n]*\\n)?[\\s\\S]*?```)' NEW = r'(?m)(^[ ]{0,3}```[^\\n]*\\n[\\s\\S]*?^[ ]{0,3}```[ \\t]*$)' text = 'code ```inline``` here' print(re.findall(OLD, text)) # ['```inline```'] <- over-match print(re.findall(NEW, text)) # [] <- correct ``` ### Expected Behavior - Only **standalone** fenced code blocks (``` on its own line, opening and closing) should be matched and protected. - Inline triple-backtick spans should be left untouched (or treated as inline code) so Telegram's MarkdownV2 parser receives valid entities and rich formatting (bold, tables, links) is preserved. ### Actual Behavior - Inline ``` spans are consumed by the fenced-block regex, then mangled by `_protect_fenced` (naive `\\n` split + `\\` / backtick escaping), producing unbalanced backticks / illegal escape sequences. - Telegram returns `MarkdownV2 parse failed`; Hermes falls back to plain text and the entire message loses formatting. ### Affected Component - Gateway (Telegram/Discord/Slack/WhatsApp) ### Messaging Platform (if gateway-related) - Telegram ### Debug Report I can provide full logs on request; key evidence is quoted above from `/root/.hermes/logs/gateway.log` (4 `MarkdownV2 parse failed` events, 2 with `can't find end of pre entity`). `hermes debug share` output can be attached if needed. ### Operating System Linux NAS 6.18.18 (Debian-based, TRIM/fnOS NAS) ### Python Version 3.11.2 ### Hermes Version Hermes Agent v0.20.0 (2026.8.3) · upstream fa83af3f ### Additional Logs / Traceback (optional) ``` 2026-08-13 17:06:40,903 WARNING hermes_plugins.telegram_platform.adapter: [Telegram] MarkdownV2 parse failed, falling back to plain text: Can't parse entities: can't find end of pre entity at byte offset 922 2026-08-13 17:06:41,580 WARNING hermes_plugins.telegram_platform.adapter: [Telegram] MarkdownV2 edit failed, falling back to plain text: Can't parse entities: can't find end of pre entity at byte offset 922 ``` ### Root Cause Analysis (optional) The bug is in `plugins/platforms/telegram/adapter.py`, the fenced-code protection step before MarkdownV2 escaping: - Regex `r'(```(?:[^\\n]*\\n)?[\\s\\S]*?```)'` matches ANY ``` pair, including single-line inline occurrences (the `(?:[^\\n]*\\n)?` prefix makes the `\\n` optional). - `_protect_fenced` assumes a multi-line block: it splits on `\\n` (`open_end = raw.index('\\n') + 1 if '\\n' in raw[3:] else 3`), then escapes `\\` and `` ` `` in the \"body\". For inline matches there is no body/close split as intended, so backticks get escaped into `\\`` and the entity becomes unbalanced. ### Proposed Fix (optional) Anchoring the regex to line starts/stops fixes it — the pattern I verified locally: ```python r'(?m)(^[ ]{0,3}```[^\\n]*\\n[\\s\\S]*?^[ ]{0,3}```[ \\t]*$)' ``` This only matches fences that start at the beginning of a line (0-3 spaces indent) and close on their own line, leaving inline triple backticks untouched. Verified against: normal multi-line code blocks (still matched), inline ``` spans (no longer matched), mixed inline+block content (no corruption). ### Are you willing to submit a PR for this? - [x] I'd like to fix this myself and submit a PR",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85381",
        "createdAt": "2026-08-13T15:11:19Z",
        "updatedAt": "2026-08-13T15:40:28Z",
        "timestamp": "2026-08-13T15:40:28Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "platform/telegram",
          "P3"
        ],
        "author": "jhdh487",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85383",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "cronjob create fails when repeat='forever' is passed explicitly",
        "text": "# Bug report: cronjob tool create fails when repeat='forever' is passed explicitly ## Summary `cronjob action='create'` raises `TypeError: '<=' not supported between instances of 'str' and 'int'` whenever the `repeat` parameter is explicitly provided as the string `\"forever\"`, even though the tool's own schema/success responses use that exact string value for infinite recurrence. ## Environment - Platform: Hermes desktop / Linux - Tool: `cronjob` (create action) - Date: 2026-08-13 ## Reproduction 1. Call `cronjob` with `action='create'`, `name='...'`, `schedule='30 6 * * *'`, `repeat='forever'`, plus any of `prompt`, `script`, or `workdir`. 2. Observe failure: ``` '<=' not supported between instances of 'str' and 'int' ``` 3. Retry with the same payload but omit `repeat` entirely. 4. Observe success; tool returns `\"repeat\": \"forever\"` in the created job. Also confirmed: - `repeat=1` succeeds. - Failure is independent of whether `script`, `prompt`, or `workdir` are supplied. ## Evidence - Failing call shape: - `name='proposal-evaluator'` - `schedule='30 6 * * *'` - `repeat='forever'` - `prompt='Run proposal-evaluator.py once.'` - Successful call shape: - Same as above, but without `repeat`. - Successful call shape: - `name='proposal-evaluator-debug'` - `schedule='30 6 * * *'` - `repeat=1` - `prompt='Run proposal-evaluator.py once.'` ## Documentation reference Hermes background-systems docs describe `repeat` as supporting integer counts and infinite recurrence. The tool's own successful responses also represent infinite recurrence as the string `\"forever\"`. So the documented/intended representation is the string `\"forever\"`; the bug is that passing it explicitly triggers a type comparison against an integer sentinel. ## Root-cause hypothesis The tool appears to have a validation branch that assumes `repeat` is numeric and performs an integer comparison against it, without first normalizing the string sentinel `\"forever\"`. When `repeat` is omitted, a different defaulting path is used that avoids the comparison. ## Workaround Omit `repeat` entirely when creating jobs that should run forever. The tool defaults to `\"forever\"` on its own, as shown in successful responses. ## Impact Users cannot explicitly request infinite recurrence via the documented parameter value, even though that value is what the tool itself outputs for infinite jobs.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85383",
        "createdAt": "2026-08-13T15:17:00Z",
        "updatedAt": "2026-08-13T15:24:47Z",
        "timestamp": "2026-08-13T15:24:47Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/cron",
          "P2"
        ],
        "author": "kaalalexanderrosser",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85384",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "fix(cli): Windows PowerShell same-surface guard weak + pid_alive cross-module blast radius + voice auto-restart direct invalidate",
        "text": "## Summary Two fixes in one file for the stacked-frame guard on Windows PowerShell: ### 1. _get_terminal_surface_id: HWND augmentation After GetConsoleTitleW returns a title, also call GetConsoleWindow() and append the HWND. Two PowerShell tabs with the same default title (\"Windows PowerShell\") previously both resolved to \"win32-console:Windows PowerShell\" and the same-surface guard could not distinguish them. Each tab now gets a distinct key \"win32-console:{title}:hwnd:{handle}\". Falls back to title-only if GetConsoleWindow fails or returns NULL. ### 2. _pid_alive: replace per-call import with lazy resolver Replace the per-call \"from gateway.status import _pid_exists\" inside the inner try/except with a lazy one-time resolver (_resolve_pid_exists) that binds _PID_EXISTS globally. The old pattern meant any import failure in gateway.status (circular import, syntax error, missing transitive dep, scaffold race) was silently caught and returned False for every PID — wiping the entire active-session registry through _prune_dead exactly when it matters most (startup). With the lazy resolver an import failure surfaces as a real error instead of silently making every PID look dead. Runtime failures of _pid_exists itself are still caught and return False as before. ### Scope - Both changes are additive inside existing fallback paths; POSIX and PID-based fallbacks are untouched. - Not touching the orphaned TUI node reaper (dashboard_procs.py) — that has a separate gap (reaper only runs at TUI launch, not CLI startup) that needs its own design review. ### Validation - py_compile passes on hermes_cli/active_sessions.py - No new external dependencies - Existing active-sessions tests continue to pass (test_active_sessions.py, test_cli_active_session_limit.py)",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85384",
        "createdAt": "2026-08-13T15:19:22Z",
        "updatedAt": "2026-08-13T15:24:40Z",
        "timestamp": "2026-08-13T15:24:40Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/cli",
          "P2",
          "needs-repro",
          "sweeper:risk-session-state",
          "sweeper:risk-platform-windows",
          "platform/windows"
        ],
        "author": "iap",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85385",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "permission-test",
        "text": "test",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85385",
        "createdAt": "2026-08-13T15:21:47Z",
        "updatedAt": "2026-08-13T15:24:32Z",
        "timestamp": "2026-08-13T15:24:32Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "invalid",
          "P3"
        ],
        "author": "linglinex01",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85387",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Security] Dashboard managed-files write endpoints can overwrite .env / config.yaml — read-side sensitive guard not applied to writes",
        "text": "## Summary The dashboard's managed-files API protects sensitive files (`.env`, `config.yaml`, credential stores) on the **read** side (`list` / `read` / `download`), but the **write** endpoints (`/api/files/upload`, `/api/files/upload-stream`, `/api/files/mkdir`) will happily **overwrite** those same files. `_is_sensitive_path()` explicitly documents this asymmetry: > *\"Read-side only: this guards list/read/download (the #57505 exfil surface). The write endpoints (upload/mkdir/delete) are a separate threat class handled by the write-path checks; extending this guard to them is out of scope for this fix.\"* But the \"write-path checks\" it refers to only enforce `..` traversal and `locked_root` containment — **they never check the target basename against `_SENSITIVE_MANAGED_FILE_BASENAMES`**. So the claimed protection on the write side does not exist. ## Reproduction (hermes-agent v0.19.0, docker) ```bash # 1. Start dashboard (localhost bind) hermes dashboard --no-open # 2. Upload a file that overwrites .env — this SUCCEEDS (200) curl -X POST http://127.0.0.1:9119/api/files/upload-stream \\ -H \"Content-Type: multipart/form-data\" \\ -F \"path=.env\" \\ -F \"overwrite=true\" \\ -F \"file=@evil.env\" # 3. .env is now overwritten (verify) cat $HERMES_HOME/.env # → EVIL=1 ``` Same for `config.yaml`, `auth.json`, `google_token.json`, etc. — all present in `_SENSITIVE_MANAGED_FILE_BASENAMES` (read-side) but writable via upload. ## The contradiction: docstring claims separation, auth model has none The guard's own docstring says writes are *\"a separate threat class handled by the write-path checks\"* — implying reads and writes are governed differently. In reality: - **Authentication is identical for read and write.** `auth_middleware` (web_server.py:606) applies the same session-token (loopback) or OAuth-cookie (remote bind) check to every `/api/` route. `/api/files` (list) and `/api/files/upload` (write) pass through the exact same gate with no per-method authorization. The only read/write difference is that `/api/files/download` additionally accepts `?token=` (because a browser-opened download link can't set a header) — writes have no such carve-out. - **The \"write-path checks\" that do exist are path-safety only, not authorization.** `_resolve_managed_path(for_write=True)` enforces `..` rejection, `locked_root` containment, and (for delete) \"can't delete the root\". None of these check *what* file is being written — `.env`, `config.yaml`, `auth.json` are all writable targets. - **Net effect**: authentication (who you are) is being conflated with authorization (what you may touch). Any client that clears the auth gate — a valid OAuth identity on a remote bind, a compromised browser tab, a local process on the host — has *unrestricted write* to every file under the managed root, including the ones the read side treats as secret. A \"read-only auditor\" OAuth user would not exist in this model; anyone who can read `.env` can also overwrite it. This is exactly the \"authentication is not authorization\" gap flagged for the dashboard's control-plane surface in #84743 (auth ≠ authorization for the host-owner power set) — the managed-files write path is the same class of gap, one level deeper: not just \"any valid identity gets host-owner power\" but \"any valid identity gets secret-file overwrite power\". ## Proposed fix (minimal) Apply the existing read-side guard to the write path — one line in `_resolve_managed_path()`: ```python # in _resolve_managed_path(), when for_write=True: if for_write and _is_sensitive_path(candidate): raise HTTPException(status_code=403, detail=\"Cannot write to a sensitive path\") ``` This blocks upload/mkdir/delete against `.env`, `config.yaml`, `auth.json`, `mcp-tokens/**`, `pairing/**`, etc. while leaving the dedicated `/api/env` (and any intentional management route) untouched. Alternative: add the check in each write route explicitly. ## Notes - We independently reimplemented the managed-files API for a lightweight local file browser (same security posture) and hit this exact gap during testing; adding the sensitive-path check on writes was a 2-line change and caught the failure in one test run. - Severity: P2 (medium) on localhost bind with single trusted user; P1 (high) on any OAuth/remote bind, because the write side currently has **zero** secret-file protection. ## Environment - Hermes Agent v0.19.0 (2026.7.20) · upstream 199f5580 · docker - Python 3.13.5 - Reproduced against `web_server.py` lines 2456-2557 (upload / upload-stream / mkdir)",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85387",
        "createdAt": "2026-08-13T15:22:58Z",
        "updatedAt": "2026-08-13T15:22:58Z",
        "timestamp": "2026-08-13T15:22:58Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "linglinex01",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85391",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "WhatsApp pairing: `hermes whatsapp` wizard writes to a different session dir than the gateway reads, and a 0-byte `creds.json` counts as \"paired\"",
        "text": "# WhatsApp pairing: `hermes whatsapp` wizard writes to a different session dir than the gateway reads, and a 0-byte `creds.json` counts as \"paired\" **Version:** Hermes Agent v0.19.0 (2026.7.20) · upstream `36e41c09` **Platform:** Linux (s6 container image), `HERMES_HOME=/opt/data`, install root `/opt/hermes` Two independent bugs in the WhatsApp pairing flow. Together they produce a state that is very hard to diagnose: the user scans successfully, the phone shows a linked device, and the gateway still reports `WhatsApp enabled but not paired` / `Start failed`. Restarting the gateway can never fix it, so the user ends up in a restart loop against an unsatisfiable precondition. --- ## Bug 1 — pairing wizard bypasses `get_hermes_dir()`, so it can pair into a directory nothing reads The gateway adapter and the dashboard both resolve the session dir through the compatibility helper: - `plugins/platforms/whatsapp/adapter.py:407` → `get_hermes_dir(\"platforms/whatsapp/session\", \"whatsapp/session\")` - `hermes_cli/web_server.py:8312` → `get_hermes_dir(\"platforms/whatsapp/session\", \"whatsapp/session\")` The CLI wizard hardcodes the **legacy** path instead: - `hermes_cli/main.py:2875` → `session_dir = get_hermes_home() / \"whatsapp\" / \"session\"` `get_hermes_dir(new, old)` returns the legacy path *only when it exists and has content*, else the new consolidated path (`hermes_constants.py:239`). The docstring is explicit that an empty legacy stub must not shadow real data: > A bare empty `<old_name>/` directory does **not** count as \"the legacy install is in use\" … See #27602 for the pairing-store regression where a dormant empty `pairing/` orphaned approved-user data in `platforms/pairing/`. So the two paths agree only while `$HERMES_HOME/whatsapp/session` is populated. The moment it is emptied, they diverge — the wizard keeps writing to `whatsapp/session` while the gateway reads `platforms/whatsapp/session`. ### Reproduction 1. Pair WhatsApp normally (session lands in the legacy `$HERMES_HOME/whatsapp/session`; both paths agree). 2. Unlink the device in WhatsApp → Linked Devices, then `rm -rf \"$HERMES_HOME/whatsapp/session\"` (a natural \"start clean\" move). 3. Start the gateway. It now resolves to `platforms/whatsapp/session`, which still holds the **revoked** credentials, and replays them: ``` {\"reasonNode\":{\"tag\":\"conflict\",\"attrs\":{\"type\":\"device_removed\"}}, \"fullErrorNode\":{\"tag\":\"stream:error\",\"attrs\":{\"code\":\"401\"}, ...},\"msg\":\"stream errored out\"} ❌ Logged out. Delete session and restart to re-authenticate. ``` 4. Re-link on the phone via `hermes whatsapp`. **No effect** — the wizard writes to `whatsapp/session`, which the gateway is no longer reading. The gateway keeps replaying the revoked creds in a restart loop (5 consecutive `Logged out` cycles observed) and reports `enabled but not paired`. ### Suggested fix Have `main.py:2875` call the same resolver as the other two call sites. Emitting the resolved path during pairing would also make a divergence self-evident: ```python session_dir = get_hermes_dir(\"platforms/whatsapp/session\", \"whatsapp/session\") ``` --- ## Bug 2 — the paired check is existence-only, so a truncated `creds.json` passes `plugins/platforms/whatsapp/adapter.py:502-503`: ```python creds_path = self._session_path / \"creds.json\" if not creds_path.exists(): ``` A `creds.json` truncated mid-write is **0 bytes and still passes `.exists()`**, so a pairing that was actually lost is reported as paired. This is easy to hit because, in `--pair-only` mode, the bridge writes `creds.json` **after** emitting the `connected` event and then exits on its own — any supervising watcher that terminates the bridge on `connected` truncates the file. Observed exactly this: the scan succeeded and **826 key files** were written, yet `creds.json` was 0 bytes. ``` *** connected: {\"event\":\"connected\",\"user\":{\"id\":\"9955xxxxxxxx:2@s.whatsapp.net\",\"name\":\"...\"}} $ wc -c < creds.json 0 $ ls session | wc -l 826 ``` Because existence-only passes, downstream code proceeds on unusable credentials instead of reporting the real problem. ### Suggested fix Validate rather than stat — size > 0, parseable JSON, and ideally the presence of `noiseKey` / `signedIdentityKey`: ```python def _has_valid_creds(creds_path: Path) -> bool: try: if creds_path.stat().st_size == 0: return False data = json.loads(creds_path.read_text()) except (OSError, ValueError): return False return bool(data.get(\"noiseKey\") and data.get(\"signedIdentityKey\")) ``` A note in the pairing docs/code that the bridge must be allowed to exit on its own after `connected` (rather than being killed on the event) would prevent the truncation in the first place. --- ## Bug 3 (minor) — `node_modules` live in the `HERMES_HOME` bridge copy, but the install-root copy is what's easy to reach `resolve_whatsapp_bridge_dir()` returns `$HERMES_HOME/scripts/whatsapp-bridge` (the copy with dependencies installed). The install root also ships `/opt/hermes/scripts/whatsapp-bridge`, which has **no** `node_modules`. Running that copy fails with: ``` Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@whiskeysockets/baileys' imported from /opt/hermes/scripts/whatsapp-bridge/bridge.js ``` Confusingly, the wizard reports *\"Bridge dependencies already installed\"* — true of the resolved copy, not the install-root one. Either not shipping the dependency-less copy, or having it fail with a pointer to `resolve_whatsapp_bridge_dir()`, would save debugging time. --- ## Impact Symptom triad worth recognising: `connected` fired and named the device · hundreds of `pre-key-*.json` files present · `creds.json` 0 bytes and status still `enabled but not paired`. Since the gateway refuses to start while a platform is enabled-but-unpaired, users hit **Restart Gateway** repeatedly against a precondition no restart can satisfy. ## Workaround 1. Resolve the true session dir: `python3 -c \"from hermes_constants import get_hermes_dir; print(get_hermes_dir('platforms/whatsapp/session','whatsapp/session'))\"` 2. Back up, then wipe it, and `rmdir` any empty legacy stub so resolution is unambiguous. 3. Pair via the **dashboard** (it uses the same resolver) or drive the bridge directly with `--pair-only --pair-json`, letting it exit on its own after `connected`. 4. Confirm `creds.json` is non-zero and parses as JSON before starting the gateway.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85391",
        "createdAt": "2026-08-13T15:26:56Z",
        "updatedAt": "2026-08-13T15:50:24Z",
        "timestamp": "2026-08-13T15:50:24Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [],
        "author": "dashgin",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85399",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Opened in error",
        "text": "Opened in error. Please ignore.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85399",
        "createdAt": "2026-08-13T15:44:55Z",
        "updatedAt": "2026-08-13T15:48:10Z",
        "timestamp": "2026-08-13T15:48:10Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "flong-coatue",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85406",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Windows host + Docker terminal] `vision_analyze` fails for sandbox-side local paths — host-side `Path()` mangles POSIX separators into backslashes before the container exec-read",
        "text": "## Description On a **Windows host** with `terminal.backend: docker`, `vision_analyze` fails for every local image path that routes to the in-container exec-read. The error is 100% reproducible: ``` Error analyzing image: sandbox returned non-image data for '\\workspace\\research\\...\\vision_test.png': Only base64 data is allowed ``` Note the **backslashes in the echoed path** — the caller passed forward slashes (`/workspace/...`). The same failure affects container-style cache paths (`/root/.hermes/cache/...`). Host-form desktop upload paths (`C:\\Users\\<u>\\AppData\\Local\\hermes\\images\\...`) work correctly (host-read via #69575). ## Environment - Hermes Agent **v0.20.0** (2026-08-03; install checkout `fa83af3f9a4`, shallow) - Host: Windows 11, Docker Desktop (WSL2 engine) - `terminal.backend: docker` (image `nikolaik/python-nodejs:python3.11-nodejs20`) - Main model: text-only (deepseek-v4); auxiliary vision: `mimo-v2.5` via OpenCode Zen - Reproduces on every attempt (verified 2/2 with a 70-byte PNG) ## Steps to reproduce 1. Inside the sandbox, create any valid PNG at a non-cache path: ```bash python3 -c \"import base64; open('/workspace/research/x.png','wb').write(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='))\" ``` 2. Ask the agent: `vision_analyze(image_url=\"/workspace/research/x.png\")` 3. Observe the error above (backslash-echoed path + `Only base64 data is allowed`). **Control (works):** a direct API call to the auxiliary vision provider with the same file base64-encoded returns a correct description; `vision_analyze` on a host-form desktop upload path (`C:\\Users\\<u>\\AppData\\Local\\hermes\\images\\upload_*.png`) also succeeds. ## Root cause 1. **`tools/image_source.py`, `_resolve_container_fallback` (~lines 353–354):** ```python qp = shlex.quote(str(p)) # p = Path(...) built host-side cmd = f\"head -c {_MAX_INGEST_BYTES + 1} < {qp} | base64 | tr -d '\\\\n'\" ``` On a Windows host, `Path('/workspace/x.png')` is a `WindowsPath` whose `str()` yields `\\workspace\\x.png`. `shlex.quote` passes those literal backslashes into the **Linux** container, where the filename does not exist. The command's failure output then fails `base64.b64decode(..., validate=True)` at line ~376, producing `\"Only base64 data is allowed\"` (the tool's stderr/pipe handling folds the failure text into the output). 2. **`tools/credential_files.py`, `from_agent_visible_cache_path` (~line 485):** ```python path = Path(container_path) # POSIX '/root/.hermes/...' -> WindowsPath rel = path.relative_to(mount[\"container_path\"]) # POSIX mount path ``` The same Windows-path mangling breaks container→host cache-path translation (POSIX `relative_to` against a WindowsPath), so container-style cache paths fall through to the broken exec-read. **Why CI missed it:** `tests/integration/test_vision_docker_resolve.py` runs on Linux hosts, where `Path()` preserves POSIX separators and the same code paths work. **Host-side confirmation** (any Windows box): ```powershell python -c \"from pathlib import Path; print(str(Path('/workspace/foo.png')))\" # -> \\workspace\\foo.png ``` ## Suggested fix Build the container command from the POSIX form of the path, e.g. `shlex.quote(p.as_posix())` (or `PurePosixPath`), and use `PurePosixPath(container_path)` for the mount-relative comparison in `from_agent_visible_cache_path`. Add a Windows-host integration test alongside `test_vision_docker_resolve.py`. ## Related - #32709 — original bug (fixed by #57890, Linux-only in practice) - #69575 — desktop uploads permitted host-read (works, verified) - #76566 — cold-start retry + stderr surfacing (post-dates v0.20.0; doesn't address path mangling) ## Impact An agent with the Docker terminal backend on Windows cannot *see* any sandbox-produced image (container screenshots, files the user drops into shared mounts) — a common workflow. Workaround in the field: read the file in-container and call the vision provider directly (bypasses `vision_analyze`).",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85406",
        "createdAt": "2026-08-13T15:55:02Z",
        "updatedAt": "2026-08-13T16:02:15Z",
        "timestamp": "2026-08-13T16:02:15Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [],
        "author": "maqifrnswa",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85413",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: s6 boot reconciler auto-starts per-profile gateways (crash-loop, 100% CPU) when multiplex_profiles enabled via config.yaml only",
        "text": "### Bug Description The Docker container's **s6 boot orchestrator** crash-loops per-profile gateway services when `multiplex_profiles: true` is set via `config.yaml` alone, because the s6 reconciler keys its auto-start decision off the `GATEWAY_MULTIPLEX_PROFILES` **environment variable** rather than the config file the user was told to edit. Setup / repro: 1. `nousresearch/hermes-agent:latest` container, `command: gateway run`, with >=1 named profile (`profiles/<name>/` each containing `SOUL.md`). 2. `config.yaml` sets `multiplex_profiles: true` (the documented way to enable the one-gateway multiplexer). 3. **Do not** set `GATEWAY_MULTIPLEX_PROFILES` in the container environment (the default for a plain `docker compose` / `docker run` deploy). Expected: only the default multiplexing gateway starts and serves all profiles; named gateway slots are registered but left down. Actual: the named gateway slots boot, hit the \"already served by the multiplexer / double-bind\" guard, exit, and **s6 restarts them in a loop**, each boot pegging a CPU core near 100%. Observed (host: ai-tools, up 22 min): - `systemctl`/`s6` shows `gateway-hermes-work` and `gateway-hermes-personal` repeatedly up-for-1-second → crash → restart. - Each relaunch is `/opt/hermes/.venv/bin/python3 hermes -p <profile> gateway run --replace` at ~90-100% CPU. - Logs: `✗ The default gateway is running as a profile multiplexer and already serves profile 'hermes-personal'. ... Starting a separate gateway for this profile would double-bind ...`. - `RestartCount=0` on the container — the container itself is NOT restarting; the loop is internal to s6 service supervision. ### Expected Behavior `multiplex_profiles: true` in `config.yaml` and the container flag `GATEWAY_MULTIPLEX_PROFILES=true` should be treated as one setting. When either is on, the s6 reconciler should register named profile slots as **down** (available for explicit lifecycle management) rather than auto-starting them, so no double-bind crash-loop can occur from a config-only opt-in. ### Actual Behavior `reconcile_profile_gateways` in `hermes_cli/container_boot.py` decides `should_start` for named profiles from ONLY the process env: ```python multiplex_profiles = is_truthy_value(os.environ.get(\"GATEWAY_MULTIPLEX_PROFILES\")) ... should_start = (not multiplex_profiles and prior_state in _AUTOSTART_STATES) ``` It never consults `multiplex_profiles` in `config.yaml`. (`gateway.py`/`gateway/config.py` correctly read the config file — `_env_multiplex_profiles_override` env-override-then-config fallback — but the s6 reconciler does not.) So a user who turns multiplexing on exactly as documented — only editing `config.yaml` — is left with a silent, 100%-CPU crash loop and has to discover the undocumented `GATEWAY_MULTIPLEX_PROFILES=true` env var to stop it. ### Affected Component Container orchestration / s6 boot (docker/s6-overlay), Configuration (config.yaml, environment) ### Messaging Platform (if gateway-related) n/a (crash loop occurs before any platform connects) ### Debug Report n/a — reproduced on a self-hosted Docker deploy; all details above gathered from live container inspection. ### Operating System Debian/Ubuntu containerized via Docker (host is a Linux VM) ### Python Version Container's bundled venv (image `fa83af3f9a`) ### Hermes Version Image `nousresearch/hermes-agent:latest` @ revision `fa83af3f9a42790730b8966ff67e7d9fb627899f` (2026-08) ### Additional Logs / Traceback (optional) s6 service status during the loop: ``` gateway-default: up gateway-hermes-work: up (1 seconds) <- crash-looping gateway-hermes-personal: up (1 seconds) <- crash-looping ``` Per-profile log (`hermes-personal`): ``` ✗ The default gateway is running as a profile multiplexer and already serves profile 'hermes-personal'. When gateway.multiplex_profiles is on, the default gateway is the single inbound process for every profile. Starting a separate gateway for this profile would double-bind its platforms (two pollers on one bot token, port conflicts). ``` Workaround (what I applied in production): set `GATEWAY_MULTIPLEX_PROFILES=true` in the container environment, matching the existing `multiplex_profiles: true` in config.yaml. After recreate the named slots resolve to `down (not started yet)` and only the default multiplexer runs (~7% idle CPU, load 0.25). ### Root Cause Analysis (optional) `hermes_cli/container_boot.py` → `reconcile_profile_gateways` (~line 142) reads multiplex routing solely from `os.environ.get(\"GATEWAY_MULTIPLEX_PROFILES\")`. This diverges from `hermes_cli/gateway.py`/`gateway/config.py`, which implement the documented precedence (env override → config.yaml → default). Suggested fix: have the reconciler fall back to the same config resolution (`load_gateway_config().multiplex_profiles` / read `config.yaml` `multiplex_profiles` key) when the env var is unset, exactly as the runtime already does, so a config-only opt-in is honored at boot. Related but distinct from #50051 (closed 2026-07-06): #50051 was about the gateway **process mis-loading the nested flag and the api_server env leak into secondary profiles**; that was fixed (e.g. `d3602e6308`). This issue is the separate **boot-time s6 orchestrator** deciding to start named slots based on env instead of config — the failure you hit when multiplex is correctly enabled through `config.yaml`. ### Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85413",
        "createdAt": "2026-08-13T16:08:02Z",
        "updatedAt": "2026-08-13T16:08:02Z",
        "timestamp": "2026-08-13T16:08:02Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "Christophe999s",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85417",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "provider: ollama-cloud silently drops /v1 from base_url, request hits ollama.com homepage instead of API (404)",
        "text": "## Bug Setting `provider: ollama-cloud` in `~/.hermes/config.yaml` fails every request with HTTP 404, even though both the native provider profile *and* an explicit `model.base_url` override in config.yaml correctly specify `https://ollama.com/v1`. The actual outbound request silently loses the `/v1` segment and hits `https://ollama.com` (the marketing homepage), not the API. ## Steps to reproduce ```yaml # ~/.hermes/config.yaml model: api_key: '' base_url: https://ollama.com/v1 # explicit override — still has no effect default: deepseek-v4-pro provider: ollama-cloud ``` With `OLLAMA_API_KEY` set correctly (verified valid via a direct `curl https://ollama.com/v1/chat/completions` call, which succeeds). ## Observed ``` WARNING agent.conversation_loop: API call failed (attempt 1/3) error_type=NotFoundError provider=ollama-cloud base_url=https://ollama.com model=deepseek-v4-pro summary=HTTP 404 — Ollama ``` Response body is the actual `ollama.com` marketing page HTML (`<title>Ollama</title>`), confirming the request landed on the bare domain root, not `/v1/chat/completions`. ## Expected Request should go to `https://ollama.com/v1/chat/completions`, matching `plugins/model-providers/ollama-cloud/__init__.py`'s own hardcoded `base_url=\"https://ollama.com/v1\"`. ## Notes - Explicitly setting `model.base_url: https://ollama.com/v1` in config.yaml did **not** change the outcome — the logged `base_url` was still the bare `https://ollama.com` in both cases, suggesting the native provider profile's own resolved value is what's actually used (config.yaml's `base_url` field appears to only take effect for `provider: custom`, not for a named native provider like `ollama-cloud`). - Workaround that *does* work: setting `provider: custom` with `base_url: https://ollama.com/v1` and an explicit `api_key` (bypassing the native provider profile's own resolution entirely). - This may share root cause with #7516's finding that `model_metadata.py:detect_local_server_type()` strips `/v1` for local-server detection — if that stripped value is ever reused downstream as the actual request `base_url` rather than staying scoped to detection, it would produce exactly this symptom. Possibly related to #57246 / #57255 (`provider: ollama` falling through to `provider=custom` based on `/v1` suffix) — same URL-construction subsystem, different trigger direction (there: unwanted `/v1` causes fallback; here: a correct `/v1` gets dropped for a *different* named provider, `ollama-cloud`). ## Environment - Provider: `ollama-cloud` (native, not `provider: ollama` / not a custom local endpoint) - Auth: `OLLAMA_API_KEY` env var, independently verified valid",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85417",
        "createdAt": "2026-08-13T16:15:47Z",
        "updatedAt": "2026-08-13T16:15:47Z",
        "timestamp": "2026-08-13T16:15:47Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "mverzal",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85418",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Memory provider proposal: local-first, zero-dependency agent memory benchmarked against Honcho, built on Hermes",
        "text": "Hi @DavidMetcalfe. You helped us out on #52954 back in July, the Chrome DevTools blank-profile issue. We've been running `--autoConnect` since and it fixed it for good. Thank you again for that. While running Hermes we ended up building a memory layer that we think fits Hermes' own philosophy, and we'd love your eyes on it. **What it is:** [MAYA Memory Lane](https://github.com/MAYA-Platform/MAYA-Memory-Lane) is a local-first, tamper-evident memory library. Every session seals a SHA-256-chained block, six blocks fold into a shelf, and a resume phrase is the only key that crosses sessions. Zero dependencies, just Node's built-in runtime, no npm install, no vector DB, no embedding API for the core. Files are the source of truth. The interface is just a window over them. **The part that surprised us:** we pre-registered a benchmark protocol (LongMemEval oracle, 500 instances, identical conditions, each system running its real pipeline) and measured: | Metric | Memory Lane | Honcho | LangMem | Mem0 | |---|---|---|---|---| | recall_all@5 | 61.1% | 40.0% | 72.2% | 68.1% | | recall_all@10 | 73.2% | 47.9% | 81.8% | 77.8% | | ndcg_any@5 | 62.0% | 28.5% | 35.9% | 33.0% | Plainly stated: LangMem and Mem0 out-retrieve us on raw recall. But we beat Honcho outright, lead on rank-aware ndcg@5, and do it with zero LLM calls, zero embeddings, zero recurring cost. We publish our own losses in the README because that's the point of a pre-registered protocol. **Why it might interest you:** the core runs on anything Node runs. We benchmarked it on a 2018 6-core desktop with a 4GB GPU that isn't even used, at $0 runtime. A hosted semantic memory API (Honcho) at 40% recall versus 61.1% for a deterministic SQLite FTS5 store with no model in the loop is a result we found worth reporting honestly. The full writeup is in the repo, at `benchmarks/SIDE_BY_SIDE_REPORT.md`. The protocol is at `benchmarks/BENCHMARK_PROTOCOL.md`, and the exact runners live in `benchmarks/harness/`. Every figure traces to a logged run, nothing hand-typed. No ask beyond a look. If you see a place this could slot into the Hermes memory story, or a reason it shouldn't, we'd genuinely value the honest take. Thanks either way, and again for the #52954 fix.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85418",
        "createdAt": "2026-08-13T16:17:40Z",
        "updatedAt": "2026-08-13T16:17:40Z",
        "timestamp": "2026-08-13T16:17:40Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "2ndNatureAI",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85420",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "BUG: Auto-Update Breaks Multi-Profile Setups — Gateways Not Restarted",
        "text": "## Summary After every Hermes auto-update, all profile gateways remain stopped. The sidebar shows a blank list with zero feedback to the user. Recovery requires manually running `gateway start` for each profile via CLI — an impossible task for non-technical users. ## Environment | Item | Value | |------|-------| | **OS** | Windows 10 | | **Hermes Version** | 0.20.0 | | **Profiles** | 6 (default, law, finance, calculator, lark, history) | | **Backend** | Python 3.11 + Node.js | | **Update Channel** | main (auto-update enabled) | ## Reproduction Steps 1. Ensure 3+ profiles have running gateways (`hermes --profile <name> gateway status` → ✓) 2. Trigger auto-update via the desktop app notification or `hermes update` 3. Wait for the update to finish — desktop app restarts automatically 4. Open the sidebar → **blank, no sessions visible** ## Expected Behavior - All gateways should be restarted automatically after update, OR - Desktop should detect missing gateways and auto-start them, OR - At minimum, show a clear error banner: \"Gateways offline — click to restore\" ## Actual Behavior - Sidebar renders completely empty - No error message, no loading indicator, no recovery prompt - User must discover via CLI that gateways are stopped ## Evidence ### 1. Update log confirms gateways are NOT restarted ``` [hermes] [updates] restart: Updating Hermes — this window will close and the updater will open. [hermes] [updates] launched repo hand-off script: ...\\scripts\\desktop-update.ps1; exiting desktop [hermes] [updates] update finished; proceeding with backend start [hermes] HERMES_BACKEND_READY port=65100 ← Only desktop own serve backend ``` After this line, **no gateway start commands are issued**. The `desktop-update.ps1` script terminates all Hermes processes (including gateways) but only restarts the desktop app. ### 2. Desktop fails to connect to profile backends ``` [hermes] [boot] could not read served dashboard token (Hermes backend for profile \"law\"): 404 [hermes] [boot] could not read served dashboard token (Hermes backend for profile \"finance\"): 404 [hermes] [boot] could not read served dashboard token (Hermes backend for profile \"calculator\"): 404 [hermes] [boot] could not read served dashboard token (Hermes backend for profile \"lark\"): 404 [hermes] [boot] could not read served dashboard token (Hermes backend for profile \"history\"): 404 ``` This error appears **693+ times** in a single log file — once per boot attempt per profile, every startup, because the gateways never came back after update. ### 3. Port conflict during desktop boot (ECONNRESET cascade) ``` [hermes] HERMES_BACKEND_READY port=64347 ← First attempt, port taken [hermes] HERMES_BACKEND_READY port=65100 ← Second attempt [hermes] HERMES_BACKEND_READY port=49432 ← Third attempt [hermes] HERMES_BACKEND_READY port=49464 ← Fourth attempt [hermes] HERMES_BACKEND_READY port=49522 ← ...keeps trying random ports ``` Port number keeps changing = desktop serve backend cannot bind its intended port because old zombie gateway processes still hold it. ### 4. UI-level symptom Renderer console shows: ``` Error invoking remote method \"hermes:api\": Error: read ECONNRESET Timed out connecting to Hermes backend after 60000ms ``` → Result: **Sidebar renders zero rows. User sees nothing.** ## Root Cause ``` Hermes Auto-Update Flow (BROKEN) ───────────────────────────────── 1. Detect update available 2. Run desktop-update.ps1 3. taskkill /F /IM \"Hermes.exe\" + child processes ✗ All gateway processes killed 4. Apply update to venv + source files 5. Restart desktop app ONLY ✗ No gateway restart logic 6. Desktop starts, tries to fetch sessions ✗ All gateways offline → ECONNRESET 7. Sidebar blank, no error shown ``` **The update script (`desktop-update.ps1`) only restarts the desktop app. It does not restart profile gateways.** ## Workaround (Temporary) After every update, manually: ```bash hermes --profile default gateway start hermes --profile law gateway start hermes --profile finance gateway start hermes --profile calculator gateway start hermes --profile lark gateway start hermes --profile history gateway start ``` This is **not acceptable** for normal users and defeats the purpose of auto-update. ## Suggested Fix (Pick Any) **Option A — Fix in update script** (recommended) `desktop-update.ps1` should, after restarting desktop, enumerate profiles and start each gateway: ```powershell # After restarting desktop foreach ($profile in (hermes profiles list --json | ConvertFrom-Json).name) { Start-Process hermes -ArgumentList \"--profile\",$profile,\"gateway\",\"start\" -WindowStyle Hidden } ``` **Option B — Fix in desktop app** On startup, desktop detects gateway state per profile and auto-starts missing ones. **Option C — At minimum, show error** If gateways are unreachable, show a visible banner: \"Gateways offline after update — [Restore All]\" ## Frequency **100%** — Reproducible on every single auto-update since multi-profile setup was configured. ## Severity **Medium-High** — Core UI breaks after standard software maintenance. Requires CLI recovery. No in-app indication of the problem.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85420",
        "createdAt": "2026-08-13T16:18:02Z",
        "updatedAt": "2026-08-13T16:18:02Z",
        "timestamp": "2026-08-13T16:18:02Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "willpeng2025",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85433",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Gateway: same-epoch orphaned .drain_request.json has no TTL — agent bounces every message indefinitely (\"draining for a maintenance action\")",
        "text": "### Summary A `.drain_request.json` marker that is orphaned **without a machine restart** wedges the gateway in `draining` indefinitely — every inbound message on every platform is bounced with *\"⏳ This agent is draining for a maintenance action and isn't accepting new turns right now\"* until someone manually removes the marker or the container happens to be recreated. ### Real-world impact A Hermes Cloud instance had a begin-drain marker written to the durable volume (`/opt/data/.drain_request.json`) for a maintenance action. The action finished, but the marker was never cleared, and the container was **not** restarted afterwards. The gateway bounced every inbound Telegram message for ~3 days until the operator diagnosed it and removed the marker via `clear_drain_request()`. From the user's side the agent looked alive (the bot answers instantly) but refused every turn. ### Why the existing NS-570 fix doesn't cover this `8ab7246c45` (PR #53050) stamps the marker with an instantiation epoch so a marker that survives a **machine restart** is recognised as stale. That fix is built on the assumption stated in `gateway/drain_control.py`'s module docstring: > the disruptive lifecycle actions a drain protects (auto-update / image migrate / env edit / profile change) all **restart the machine**, which is exactly the signal that the drain is over. This incident violated that assumption: the maintenance action completed without recreating the container, so the epoch still matched, `_marker_epoch_is_stale()` correctly returned `False`, and the 1s `_drain_control_watcher` kept honouring the orphan — forever. ### The gap The marker already carries `requested_at` (ISO-8601 UTC, written by `write_drain_request()`), but **no reader ever looks at it**. There is no TTL/max-age fallback, so a same-epoch orphan has unbounded lifetime. Compare the neighbouring `.restart_notify.json` marker, which `gateway/run.py` guards with a 5-minute `requested_at` staleness check precisely so \"a legitimately old marker … should not swallow a fresh /restart\". ### Proposed fix Add a lenient max-age fallback to the reader in `gateway/drain_control.py`, alongside the epoch check: * a marker whose `requested_at` is present, parseable, and older than a generous bound (drain-gated lifecycle actions complete in minutes; 60 min is conservative) reads as stale in `drain_requested()` / `drain_notification_suppressed()`, with a warning log; * a marker with **no** `requested_at`, or an unparseable one, stays honoured — same fail-safe-toward-quiescing leniency as the epoch check; * a deliberately long drain keeps a sanctioned keep-alive: `write_drain_request()` is documented as idempotent and re-writing refreshes `requested_at`. (The writer side — the cloud control plane clearing the marker in a `finally` after non-restart maintenance actions — is out of scope for this repo, but the gateway should defend itself regardless: any writer crash between \"action done\" and \"cancel drain\" reproduces this.) ### Environment - Hermes Cloud (durable Fly volume `HERMES_HOME=/opt/data`) - Verified against current `main` (`fa83af3f9a`): `drain_requested()` has only the epoch staleness check; `requested_at` is write-only.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85433",
        "createdAt": "2026-08-13T16:43:18Z",
        "updatedAt": "2026-08-13T17:53:50Z",
        "timestamp": "2026-08-13T17:53:50Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "P1",
          "sweeper:risk-message-delivery"
        ],
        "author": "kshitijk4poor",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85446",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: kimi-coding streaming request blocks forever in httpcore header read — timeout never applied, fallback never engages",
        "text": "## Bug Description A `kimi-coding` request never returns. The process sends the HTTP request, then blocks **forever** waiting for response headers — no error, no timeout, no fallback. The run has to be killed externally. This is not a slow response: the call is still blocked in `httpcore`'s socket read after 200 s, and the configured per-provider timeout does not bound it. The same one-shot on another provider returns in 14 s, and the Kimi endpoint itself answers unauthenticated probes in ~0.5 s — so neither the CLI path nor the remote endpoint is at fault. ## Steps to Reproduce ```bash hermes -z \"Reply exactly: PONG\" --provider kimi-coding -m k3 ``` Reproduced **4/4 times**, killed at 120 s, 130 s, 180 s and 200 s. `k3` is a valid model for this provider per the models.dev catalogue (`k3`, `k3-256k`, `kimi-for-coding`, `kimi-for-coding-highspeed`). **Control (works):** ```bash hermes -z \"Reply exactly: PONG\" --provider copilot -m gpt-4.1 # → PONG in 14 s ``` ## Blocked stack (captured with `PYTHONFAULTHANDLER=1` + `SIGABRT`) ``` Thread (most recent call first): File \"httpcore/_backends/sync.py\", line 128 in read File \"httpcore/_sync/http11.py\", line 217 in _receive_event File \"httpcore/_sync/http11.py\", line 177 in _receive_response_headers File \"httpcore/_sync/http11.py\", line 106 in handle_request File \"httpcore/_sync/connection.py\", line 103 in handle_request File \"httpcore/_sync/connection_pool.py\", line 236 in handle_request File \"httpx/_transports/default.py\", line 250 in handle_request File \"httpx/_client.py\", line 1014 in _send_single_request File \"httpx/_client.py\", line 979 in _send_handling_redirects File \"httpx/_client.py\", line 942 in _send_handling_auth File \"httpx/_client.py\", line 914 in send File \"openai/_base_client.py\", line 1005 in request File \"openai/_base_client.py\", line 1297 in post File \"openai/_utils/_utils.py\", line 286 in wrapper File \"agent/chat_completion_helpers.py\", line 3365 in _open_stream File \"agent/relay_llm.py\", line 424 in __init__ File \"agent/relay_llm.py\", line 343 in stream File \"agent/chat_completion_helpers.py\", line 3427 in _call_chat_completions File \"agent/chat_completion_helpers.py\", line 4082 in _call ``` The request was **sent**; it is `_receive_response_headers` that never completes. During the hang, `ss -tnp` shows an ESTABLISHED TLS connection from the process to `api.kimi.com`. ## `request_timeout_seconds` does not bound it Adding an explicit per-provider timeout does **not** stop the hang — same run still killed at 200 s: ```yaml providers: kimi-coding: request_timeout_seconds: 60 ``` `get_provider_request_timeout()` (`hermes_cli/timeouts.py`) reads exactly this key, so it is being resolved, but it does not appear to reach the streaming chat-completions client at `chat_completion_helpers.py:3365`. With no configuration at all it returns `None`, and the OpenAI client is then created without any request timeout — which matches the unbounded block above. ## What I ruled out | Hypothesis | Test | Result | |---|---|---| | Endpoint down / slow | `curl https://api.kimi.com/` | TLS OK, 1.3 s | | Endpoint doesn't answer | `POST /coding/v1/chat/completions`, no auth | **401 in 0.56 s** | | Wrong protocol path | `POST /coding/v1/messages`, no auth | **401 in 0.56 s** (both exist) | | Server stalls on bad model | `POST` with a nonexistent model | 401 in 0.53 s | | Streaming-specific server behaviour | `POST` with `\"stream\": true`, no auth | 401 in 0.53 s | | CLI one-shot path broken | `--provider copilot -m gpt-4.1` | PONG in 14 s | | Base URL not resolved | `KIMI_BASE_URL=https://api.kimi.com/coding/v1` | still hangs | | Auxiliary model resolution | forced `auxiliary.compression` onto a working provider | still hangs | ## Possibly related - **#77256** — notes that `api.kimi.com/coding` is `anthropic_messages` while the `kimi-for-coding` overlay declares `transport=\"openai_chat\"`. My probes show *both* `/coding/v1/chat/completions` and `/coding/v1/messages` exist and return 401 unauthenticated, so this may be a contributing factor rather than the whole story — but a protocol mismatch on an authenticated request is a plausible way to get a response that never arrives. - **#36776** (closed) — same *shape* of defect on another provider: \"hangs indefinitely — no overall timeout on search calls\". ## Suggested direction Independently of the Kimi-specific root cause: a provider that accepts the connection and never responds should surface as an error, not as an unbounded block. A default read/TTFB deadline on the streaming path would turn this into a normal failure — and would let `fallback_providers` engage, which currently cannot happen because no error is ever raised. ## Environment - hermes-agent **0.20.0**, commit `1fd21cbd0f51` (confirmed still reproducing after updating to current `main`) - Python 3.11.15, Linux x86_64 - Provider auth: API key, resolved from the environment (valid — the same credential returns HTTP 200 with 12 models against the Moonshot platform API) - Not run: `hermes debug share`. It would upload environment and configuration off-box, and this deployment has a standing rule against that; everything it would have carried is inlined above. Happy to provide anything specific on request.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85446",
        "createdAt": "2026-08-13T16:57:29Z",
        "updatedAt": "2026-08-13T17:15:19Z",
        "timestamp": "2026-08-13T17:15:19Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/kimi",
          "P2"
        ],
        "author": "AlekTkT",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85458",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Fail closed on duplicate plugin identities and attest the loaded source",
        "text": "## Problem Hermes core owns directory and entry-point discovery, collision precedence, and module loading. A plugin-side preflight can validate its own source tree and refuse similarly prefixed sibling directories, but it cannot prove which manifest/module the live PluginManager selected. A generic unsafe case is two enabled directory candidates with different directory keys but the same manifest identity, such as a canonical checkout beside a preserved rollback checkout. Key-based winner selection does not provide an exact source contract for the manifest identity, and plugin hooks can run without a live-bound, read-only source attestation. ## Bounded core requirement Please add a core-owned fail-closed contract that: - rejects more than one enabled candidate for the same manifest plugin identity before any candidate registers hooks, commands, tools, or middleware; - supports an exact configured canonical source directory and immutable expected build identity/revision for a plugin, or otherwise exposes an equivalent deterministic selector; - exposes a public read-only diagnostic/API bound to the actually loaded module, not merely current disk state, with bounded match/mismatch results suitable for pre-start and live verification; - distinguishes directory and entry-point sources without silently applying source-order precedence when an exact source was required; - never mutates, deletes, renames, updates, restarts, or disables a candidate during diagnosis. ## Regression coverage - canonical directory plus a similarly prefixed rollback/backup sibling is rejected deterministically; - duplicate manifest identities across directory and entry-point sources are rejected when exact selection is configured; - the loaded-source result stays bound to the module bytes/path selected at load time even if disk state changes later; - diagnostics do not include manifest payloads, credentials, profile state, session data, or unrelated filesystem paths. This is intentionally a Hermes-core issue: a plugin cannot safely override or attest the discovery decision that loaded it.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85458",
        "createdAt": "2026-08-13T17:12:30Z",
        "updatedAt": "2026-08-13T17:23:09Z",
        "timestamp": "2026-08-13T17:23:09Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/plugins",
          "P3",
          "needs-decision"
        ],
        "author": "yuvalfis",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85464",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "Prompt caching is disabled for Anthropic-compatible providers when Claude is selected through a bare alias",
        "text": "## Summary Prompt caching is silently disabled when a Claude model is selected through a **bare runtime alias** (for example `fable` or `opus`) on a third-party provider using `api_mode: anthropic_messages`. The downstream gateway resolves those aliases to canonical Claude models (`claude-fable-5`, `claude-opus-5`), but Hermes evaluates its prompt-cache policy before that downstream resolution. The current policy uses the runtime model string: ```python model_lower = eff_model.lower() is_claude = \"claude\" in model_lower ... if is_anthropic_wire and is_claude: return True, True ``` Therefore: ```text fable / opus -> (False, False) claude-fable-5 / claude-opus-5 -> (True, True) ``` No `cache_control` breakpoint is generated for the bare aliases, even though the request is sent over the native Anthropic Messages transport and the gateway ultimately selects a cache-capable Claude model. This is a follow-up to #8294 and #17332. #8294 was fixed by #12846 for third-party Anthropic transports with Claude-named models, but the remaining model-name gate does not account for aliases that are canonicalized downstream. Companion OmniRoute issue: https://github.com/diegosouzapw/OmniRoute/issues/10276 That issue covers the router-side part of the compatibility gap: its `auto` policy does not defensively add breakpoints when a normal Anthropic-compatible client supplies none. ## Affected topology ```text Hermes runtime model alias: fable or opus provider: named custom provider api_mode: anthropic_messages | | no cache_control emitted v Anthropic-compatible gateway/router alias resolves downstream: fable -> claude-fable-5 opus -> claude-opus-5 | | ordinary request, no cache breakpoint v Claude API / model service cache_creation_input_tokens = 0 cache_read_input_tokens = 0 ``` Responsibility is split across three layers: 1. **Hermes** constructs the stable agent prefix and decides where to emit `cache_control` breakpoints. 2. **The gateway/router** preserves, translates, or optionally injects those markers. 3. **The model service** creates and reads the actual server-side KV/prompt cache and reports usage. Hermes does not store the KV cache itself; the issue is that it fails to declare cacheable prefix boundaries for this alias shape. ## Minimal reproduction Use a custom Anthropic-compatible provider whose router understands short aliases: ```yaml model: default: fable provider: custom:anthropic-proxy base_url: http://127.0.0.1:PORT providers: anthropic-proxy: base_url: http://127.0.0.1:PORT transport: anthropic_messages api_key: \"[REDACTED]\" models: fable: context_length: 1000000 opus: context_length: 1000000 ``` At the router, configure: ```text fable -> claude-fable-5 opus -> claude-opus-5 ``` Then: 1. Start a new Hermes session using `fable`. 2. Send two or more turns with a sufficiently large, unchanged prefix (system prompt + tool definitions + prior history). 3. Capture only cache-policy diagnostics and usage fields; do not publish the request text. 4. Repeat using the runtime model name `opus`. 5. As controls, repeat with `claude-fable-5` and `claude-opus-5` as the model names visible to Hermes. The policy can also be reproduced directly: ```python from types import SimpleNamespace from agent.agent_runtime_helpers import anthropic_prompt_cache_policy agent = SimpleNamespace( provider=\"custom:anthropic-proxy\", base_url=\"http://127.0.0.1:PORT\", api_mode=\"anthropic_messages\", model=\"\", ) for model in [\"fable\", \"claude-fable-5\", \"opus\", \"claude-opus-5\"]: print(model, anthropic_prompt_cache_policy(agent, model=model)) ``` Observed: ```text fable: (False, False) claude-fable-5: (True, True) opus: (False, False) claude-opus-5: (True, True) ``` ## Sanitized request evidence The following summaries were extracted from request artifacts. Request text, system prompts, user content, credentials, headers, and connection identifiers are intentionally omitted. ### Hermes -> bare alias `fable` -> canonical `claude-fable-5` ```json { \"client\": \"Hermes\", \"canonicalModel\": \"claude-fable-5\", \"claudePromptCache\": { \"applied\": false, \"totalBreakpoints\": 0, \"systemBreakpoints\": [], \"toolBreakpoints\": [], \"messageBreakpoints\": [] }, \"usage\": { \"cacheRead\": null, \"cacheWrite\": null } } ``` ### Hermes -> bare alias `opus` -> canonical `claude-opus-5` ```json { \"client\": \"Hermes\", \"canonicalModel\": \"claude-opus-5\", \"inputTokens\": 26448, \"claudePromptCache\": { \"applied\": false, \"totalBreakpoints\": 0 }, \"usage\": { \"cacheRead\": null, \"cacheWrite\": null } } ``` ### Control: another client -> same router -> `claude-opus-5` ```json { \"canonicalModel\": \"claude-opus-5\", \"inputTokens\": 78802, \"claudePromptCache\": { \"applied\": true, \"totalBreakpoints\": 3 }, \"usage\": { \"cacheRead\": 78553, \"cacheWrite\": 247 } } ``` The control is important: it shows that the same router and upstream can preserve/use Claude prompt caching. The missing-cache samples are not evidence that Fable or Opus lacks server-side cache support; the marker was absent before the request reached that layer. ## Production impact observed One long-running Hermes session using the bare `fable` alias produced: ```text API calls: 88 cumulative input: 28,624,144 tokens cumulative output: 137,051 tokens largest single input: 459,339 tokens cache read: 0 cache creation/write: 0 ``` This is cumulative per-call input, not a claim that all 28.6M tokens were unique or cacheable. In an agent loop, the system prompt, tool schemas, and conversation prefix are repeatedly sent, so failure to emit breakpoints causes the reusable portion to be processed at the uncached input rate on every turn. ## Theoretical cost impact using public API prices This section is an API-price counterfactual only. It does not describe the account type or commercial route used for the observed traffic. Anthropic's public Claude Fable 5 pricing currently lists: - base input: **$10 / MTok** - 5-minute cache write: **$12.50 / MTok** - cache hit/read: **$1 / MTok** - output: **$50 / MTok** Source: https://platform.claude.com/docs/en/about-claude/pricing At public base-input pricing, the observed 28,624,144 input tokens correspond to: ```text 28.624144 MTok * $10/MTok = $286.24 of theoretical uncached input processing ``` The 137,051 output tokens would add about `$6.85`, but output cost is unaffected by prompt caching and is not part of the cache-loss estimate. The exact avoidable amount cannot be inferred from cumulative input alone because each turn has a different cacheable boundary, cache writes cost 1.25x for the 5-minute TTL, and new conversation suffixes remain uncached. A transparent sensitivity range is more appropriate. If `p` is the fraction of cumulative input that could have been served as cache reads, the gross read-vs-uncached difference before cache-write overhead is: ```text 28.624144 MTok * p * ($10 - $1) / MTok ``` | Reusable/readable share `p` | Gross theoretical difference | |---:|---:| | 50% | $128.81 | | 75% | $193.21 | | 90% | $231.86 | These are **not claimed savings**; they are bounded scenarios illustrating why silent cache disablement is operationally expensive. Actual savings would be lower after cache-write premiums, uncached suffixes, TTL expiry, prefix changes, and cold starts. For a stable prefix of `S` tokens reused over `R` calls within a 5-minute cache lifetime: ```text without caching = R * S * $10 / 1M with caching = S * $12.50 / 1M + (R - 1) * S * $1 / 1M ``` The API documentation notes that a 5-minute write pays back after one cache read. ## Expected behavior A third-party provider using `anthropic_messages` should not silently lose caching merely because the model visible at the Hermes boundary is an alias. At least one of these should be supported: 1. Resolve aliases to a canonical model/capability before evaluating prompt-cache policy. 2. Allow custom provider/model declarations such as `prompt_caching: true` or `supports_cache_control: true`. 3. Treat `anthropic_messages` as cache-capable when the provider explicitly opts in, independent of model spelling. 4. Emit a warning when a large Anthropic-wire request is sent with no cache breakpoints. A pure `api_mode == anthropic_messages` default may be too broad because not every compatible endpoint guarantees cache support; an explicit capability override plus known-provider defaults would be safer than a string heuristic. ## Suggested regression tests 1. `anthropic_messages` + runtime alias `fable` + canonical capability `claude-fable-5` enables native cache layout. 2. `anthropic_messages` + runtime alias `opus` + canonical capability `claude-opus-5` enables native cache layout. 3. Explicit `prompt_caching: true` works for a non-Claude alias. 4. Explicit `prompt_caching: false` remains authoritative. 5. Unknown Anthropic-compatible providers do not change behavior unless capability is declared, if backward compatibility requires that default. 6. A two-turn integration test verifies the second response reports `cache_read_input_tokens > 0` when the upstream supports it. ## Acceptance criteria A fixed Hermes -> gateway test should show all of the following: ```text client = Hermes canonical model = claude-fable-5 or claude-opus-5 claudePromptCache.applied = true totalBreakpoints > 0 second turn cache_read_input_tokens > 0 ``` Checking only that `cache_control` exists is insufficient; the second-turn usage field is the end-to-end acceptance signal. ## Related work - #8294 — third-party `anthropic_messages` prompt caching - #12846 — transport/provider fix that closed #8294 - #17332 — model-name gating excluded cache-capable non-Claude model IDs - https://github.com/diegosouzapw/OmniRoute/issues/813 — proxy-side automatic prompt-cache injection and observability - https://github.com/diegosouzapw/OmniRoute/pull/708 — preserving `cache_control` in Claude passthrough - https://github.com/diegosouzapw/OmniRoute/issues/1712 — prefix mutation breaking cache hits ## Environment - Hermes Agent: v0.20.0 - Host: macOS - Provider type: named custom provider - Transport: `anthropic_messages` - Gateway used for the observed artifacts: OmniRoute v3.8.49 No credentials or complete request artifacts are attached because those artifacts contain private system prompts, tools, and conversation content.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85464",
        "createdAt": "2026-08-13T17:21:33Z",
        "updatedAt": "2026-08-13T17:48:07Z",
        "timestamp": "2026-08-13T17:48:07Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/anthropic",
          "P0",
          "sweeper:risk-caching"
        ],
        "author": "xiaoyaner0201",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85491",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "terminal_tool: cron lifecycle guard crashes with ValueError on venv binary paths (os.open raises ValueError, not OSError)",
        "text": "## Summary `terminal_tool` crashes with `ValueError: embedded null byte` before running the command, when the command string leads the guard to derive a script path containing a NUL byte. The failure happens in the cron lifecycle guard's pre-flight scan, so the command never executes and the tool call returns an error instead of output. ## Impact Hit repeatedly during an ordinary coding session on `main` (`470aa20d22`). Two commands that failed: ```bash cd /path/to/repo && git merge --ff-only origin/master && echo \"...\" && .venv/bin/python -m pytest -q cd /path/to/repo && .venv/bin/python -m pytest -q | tail -2 ``` Running a virtualenv interpreter by relative path is extremely common, so this is likely to affect many sessions. It fails loudly rather than silently, which limits the damage — but the error surfaces as a tool traceback with no indication that the *command* was fine and the *guard* was at fault, so it reads like a repo or environment problem. **Workaround:** assign the path to a shell variable first, which changes the token the guard inspects. ```bash PY=.venv/bin/python; \"$PY\" -m pytest -q # works ``` ## Root cause `cron/lifecycle_guard.py::_read_referenced_script` (line ~260): ```python flags = os.O_RDONLY | getattr(os, \"O_NONBLOCK\", 0) try: descriptor = os.open(path, flags) except OSError: return None, False ``` `os.open()` raises **`ValueError`** — not `OSError` — when the path contains an embedded NUL byte. The `except OSError` clause does not catch it, so it propagates up through `_contains_unsafe_gateway_action` → `contains_gateway_lifecycle_command_or_referenced_script` → `terminal_tool` and aborts the tool call. Verified in isolation: ```python >>> import os >>> os.open(\"/tmp/x\\x00y\", os.O_RDONLY) ValueError: embedded null byte ``` The surrounding code already treats unreadable and non-regular paths as \"nothing to scan\" and returns cleanly, so an unopenable path is an expected condition here rather than an exceptional one — this looks like a missed exception type rather than a design question. ## Full traceback ``` File \"tools/terminal_tool.py\", line 2560, in terminal_tool if contains_gateway_lifecycle_command_or_referenced_script( File \"cron/lifecycle_guard.py\", line 353, in contains_gateway_lifecycle_command_or_referenced_script return _contains_unsafe_gateway_action( File \"cron/lifecycle_guard.py\", line 335, in _contains_unsafe_gateway_action if script_text and _contains_unsafe_gateway_action( File \"cron/lifecycle_guard.py\", line 324, in _contains_unsafe_gateway_action script_text, unsafe = _read_referenced_script(script_path) File \"cron/lifecycle_guard.py\", line 260, in _read_referenced_script descriptor = os.open(path, flags) ValueError: embedded null byte ``` ## Suggested fix Catch `ValueError` alongside `OSError`: ```python try: descriptor = os.open(path, flags) except (OSError, ValueError): return None, False ``` A path that cannot be opened is already handled as \"nothing to scan, not unsafe\", and a NUL-containing path can never name a real script, so the same return is correct. Worth checking whether the path *derivation* upstream should also reject NUL-containing candidates before they reach the open — the guard scans several tokens per command, and this one produced a candidate that could not name any file on disk. ## Notes I have not opened a PR — the one-line change is easy, but the guard is security-adjacent code and I would rather the maintainers decide whether the fix belongs at the `open()` call, at the path derivation, or both. Happy to submit one if that is useful.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85491",
        "createdAt": "2026-08-13T17:32:33Z",
        "updatedAt": "2026-08-13T17:45:57Z",
        "timestamp": "2026-08-13T17:45:57Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/tools",
          "comp/cron",
          "tool/terminal",
          "P2"
        ],
        "author": "PolyphonyRequiem",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85492",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Bug]: Desktop Agents panel keeps timed-out subagents stuck as running forever",
        "text": "### Bug Description After a delegated subagent hits the hard child timeout (`delegation.child_timeout_seconds`), the desktop app's **Agents panel keeps showing it as \"running\" forever** — spinning spinner, tool badge, counted in the status-bar \"Agents N running\" indicator — even though the parent conversation already received the timeout error. The row is never cleaned up, not even on subsequent turns. The same happens for subagents that exit via an exception. ### Steps to Reproduce 1. Set `delegation.child_timeout_seconds: 600` in `config.yaml` (or any finite value). 2. Use the desktop app; dispatch a batch of `delegate_task` subagents (`tasks=[...]`) and let them hit the 600s timeout (e.g. a subagent stuck before its first LLM call, or on a slow tool call). 3. Wait for the timeout — the parent conversation shows the \"Subagent timed out after 600s…\" error. 4. Open the Agents panel (or look at the status bar): the timed-out subagent rows are still shown with a running spinner and their last tool badge, and the status bar still counts them as active. ### Expected Behavior A timed-out (or exception-failed) subagent should render as a terminal failure — a failed/error state — and be pruned from the Agents panel like any other finished row at the next `message.start` boundary, so the status-bar count returns to the actually-active subagents. ### Actual Behavior The row stays in the `running` state indefinitely: spinner glyph, included in `activeSubagentCount` (status bar \"Agents N running\"), and never pruned by `pruneFinishedSessionSubagents` (which only removes terminal-status rows). Only a Stop action or session wipe clears it. ### Affected Component Other (Desktop app — Agents panel / status-bar subagent indicator) ### Messaging Platform (if gateway-related) N/A (CLI only) ### Debug Report ``` Report https://paste.rs/bIA8m agent.log https://paste.rs/bIA8m gateway.log https://paste.rs/Jjo1N gui.log https://paste.rs/eVe75 ``` ### Operating System macOS (26.5.2) ### Python Version 3.11.15 ### Hermes Version v0.20.0 (2026.8.3) · upstream 91e550b0 ### Root Cause Analysis (optional) Status-vocabulary drift between the Python backend and the desktop store. The backend completes subagents with status values the desktop store does not recognize: - `tools/delegate_tool.py:2479-2488` — hard child timeout emits `subagent.complete` with `status=\"timeout\"` (introduced in [`dd8ab40556`](https://github.com/NousResearch/hermes-agent/commit/dd8ab40556), PR #13770 — \"'timeout' as a new exit_reason/status\"). - `tools/delegate_tool.py:2485` — non-timeout exception exit emits the same event with `status=\"error\"`. - Success / interrupt / inner-failure paths emit `completed` / `interrupted` / `failed` (`delegate_tool.py:2642-2650`, `:2884`) — these the UI does recognize. The desktop renderer forwards `subagent.*` gateway events verbatim into its store (`apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts:919-932` → `upsertSubagent`), and the store's status mapper (`apps/desktop/src/store/subagents.ts:58-59`) is an allowlist: ```ts const asStatus = (v: unknown): SubagentStatus => v === 'completed' || v === 'failed' || v === 'interrupted' || v === 'queued' ? v : 'running' ``` `\"timeout\"` and `\"error\"` are not in the allowlist, so both fall through to `'running'`. Consequences: - The row keeps the running spinner and counts in `activeSubagentCount` → status bar \"Agents N running\". - `pruneFinishedSessionSubagents` only removes terminal-status rows at the `message.start` boundary, so the stuck row is never pruned. `asStatus` has not changed since the initial desktop app commit ([`51c68d4ab1`](https://github.com/NousResearch/hermes-agent/commit/51c68d4ab1)); the backend `timeout` status was added later without the desktop store learning it. ### Proposed Fix (optional) Map the backend's `timeout` and `error` completion statuses to the existing terminal `failed` status in the desktop store's `asStatus()`. This makes the row render the failed glyph, count as failed, and get pruned at the next `message.start` like any other finished row — no type/i18n/glyph changes needed. Also synthesize a \"Timed out after Xs\" summary line when the raw status is `timeout` and the backend sent no summary, so the row shows why it failed. PR: coming — will link from a comment once opened. - [x] I'd like to fix this myself and submit a PR",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85492",
        "createdAt": "2026-08-13T17:32:50Z",
        "updatedAt": "2026-08-13T17:42:51Z",
        "timestamp": "2026-08-13T17:42:51Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "duplicate",
          "tool/delegate",
          "P2",
          "sweeper:risk-session-state",
          "comp/desktop",
          "area/sessions"
        ],
        "author": "DavidMetcalfe",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85495",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "`--in <dir>` is silently overridden by restored session cwd in one-shot mode (`-z`)",
        "text": "v0.20.0 (2026.8.3), Windows native. In headless one-shot mode, `--in <dir>` is documented to set the working directory, but when a previous session has a saved cwd, that cwd wins silently: we invoked `hermes -z \"…\" --in C:\\<throwaway-dir> --ignore-user-config` from `C:\\<throwaway-dir>` itself, and the agent operated in `C:\\dev` (the restored cwd of an earlier session) — created the requested file there and reported on that directory's git state. No warning was emitted. Impact: headless automation can silently act in the wrong directory — in our case the file landed in an unrelated real workspace. Likely the same flag-handling gap as #67458 (`-w/--worktree` silently ignored in `-z` mode). Expected: `--in` takes precedence over any restored cwd (or hard error if both are given and conflict); at minimum a warning. Workaround for others: pass `--no-restore-cwd` alongside `--in` (verified mitigating in our runs where shell cwd and `--in` agreed).",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85495",
        "createdAt": "2026-08-13T17:37:21Z",
        "updatedAt": "2026-08-13T17:51:18Z",
        "timestamp": "2026-08-13T17:51:18Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P2",
          "sweeper:risk-compatibility",
          "sweeper:risk-platform-windows"
        ],
        "author": "EirikWolf",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85496",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "auth_middleware rejects desktop /api/ws?token= upgrade with HTTP 401 (boot loop)",
        "text": "# Bug: `auth_middleware` rejects the desktop `/api/ws?token=...` upgrade with HTTP 401 ## Affected version hermes-agent `0.20.0` (install stamp `fa83af3f9a42`, main, local install). Reproduced on macOS arm64. ## Symptom The Electron desktop app fails to boot in a loop: ``` Local Hermes backend is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: WebSocket connection failed. → Desktop boot failed → Restarting ``` ## Root cause `apps/desktop/dist/electron-main.mjs` spawns the backend with: ``` serve --host 127.0.0.1 --port 0 ``` and opens the WebSocket upgrade at `/api/ws?token=<session_token>` (the desktop app's own freshly minted token, set as `HERMES_DASHBOARD_SESSION_TOKEN` in the backend child env). The backend's `auth_middleware` (`hermes_cli/web_server.py`, `auth_middleware`) gates **every** `/api/*` request: ```python if path.startswith(\"/api/\") and path not in _PUBLIC_API_PATHS and not is_mcp_oauth_callback: if not _has_valid_session_token(request) and not _has_valid_query_token(request, path): return JSONResponse(status_code=401, ...) ``` `_has_valid_query_token` only whitelists `/api/files/download` for the `?token=` query param — it does **NOT** include `/api/ws` or `/api/pty`. So `auth_middleware` returns 401 on the WS upgrade **before** the actual `@app.websocket(\"/api/ws\")` handler (`_ws_auth_reason`) ever runs and validates the token against `_SESSION_TOKEN`. The WS handler itself would accept the token; the middleware blocks it first. ## Note on token sync (separate, related footgun) If a stale `HERMES_DASHBOARD_SESSION_TOKEN=...` line is left in `~/.hermes/.env` (e.g. pinned during a remote-tunnel setup), the backend is locked to that fixed token while the desktop app generates a *different* fresh token for the WS → also 401. Removing the `.env` line fixes the sync; the middleware bug above is independent of it. ## Proposed fix (minimal, what we currently run locally) Exempt the WS/PTY upgrade paths from the middleware's 401 so the handler's own check runs: ```python is_ws_or_pty = path in (\"/api/ws\", \"/api/pty\") if path.startswith(\"/api/\") and path not in _PUBLIC_API_PATHS and not is_mcp_oauth_callback and not is_ws_or_pty: if not _has_valid_session_token(request) and not _has_valid_query_token(request, path): return JSONResponse(status_code=401, ...) ``` This does **not** weaken auth: `_ws_auth_reason` still validates the token against `_SESSION_TOKEN` (hmac.compare_digest), so a wrong token is still rejected. It only moves the check from the middleware to the handler, where the desktop app expects it. ## Steps to reproduce 1. Fresh `HERMES_HOME`, no stale `.env` token. 2. `hermes desktop` (or spawn `serve --host 127.0.0.1 --port 0`). 3. Observe the boot loop / `curl -i \"http://127.0.0.1:<port>/api/ws?token=<env token>\"` → 401. 4. A real WS upgrade (`websockets.connect(\"ws://127.0.0.1:<port>/api/ws?token=<env token>\")`) is also rejected by the middleware.",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85496",
        "createdAt": "2026-08-13T17:40:49Z",
        "updatedAt": "2026-08-13T17:59:20Z",
        "timestamp": "2026-08-13T17:59:20Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "area/auth",
          "P2",
          "needs-repro",
          "sweeper:risk-security-boundary",
          "comp/desktop",
          "comp/dashboard"
        ],
        "author": "strohjarvis",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:issue:85497",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "issue",
        "title": "[Feature]: kanban HERMES_TENANT has no memory-isolation effect — worker memory pollutes across tenants",
        "text": "### Problem or Use Case Kanban workers receive a `HERMES_TENANT` env var from the dispatcher (`kanban_db.py:_default_spawn`, ~line 9018) when a task is created with `--tenant <slug>`, but that variable has **no memory-isolation effect**. All workers — regardless of board, tenant, or assignee profile — write to the same flat global memory store (`~/.hermes/memories/MEMORY.md`), so concurrent tasks across tenants pollute each other's persistent memory. The kanban docs describe `--tenant` as providing \"data isolation by workspace path and memory key prefix\" ([docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban)). The workspace-path half works; the **memory key prefix half is not implemented**. In a multi-project setup where one role profile (e.g. `implementer`) serves several tenant boards concurrently, the same profile's worker processes write unrelated project context into the same global memory — the next task on any tenant reads the previous tenant's memories. ### Proposed Solution When a kanban worker is spawned with `HERMES_TENANT=<slug>`, scope its memory reads/writes to a per-tenant namespace, mirroring the `context_id` scoping proposed in #47552 and #34352: 1. In `_default_spawn`, map `task.tenant` → the same `context_id` mechanism from #47552 (`MemoryStore` context scoping — writes into `memories/contexts/{context_id}/`, reads merge global + scoped), or 2. Set a dedicated env (e.g. `HERMES_MEMORY_CONTEXT_ID=tenant`) consumed by the memory tool, keeping kanban independent of gateway message-source derivation. Either way, the existing `HERMES_TENANT` plumbing in `_default_spawn` is the natural hook point — the isolation is already half-wired. ### Alternatives Considered - **Manual review of MEMORY.md** — current workaround; fragile at scale with 10+ tenant boards. - **Per-profile memory dirs** (`memories` under each profile) — doesn't help when one role profile serves multiple tenants concurrently, which is the exact kanban pattern. - **Separate boards as hard isolation** — boards already isolate task queues and workspaces, but worker *memory* still lands in one global store regardless of board; board isolation alone doesn't solve this. ### Feature Type - Configuration option / Performance & reliability (memory isolation for multi-tenant kanban workers) ### Scope - Small to Medium (hook `HERMES_TENANT` into the existing `context_id` plumbing in `_default_spawn` + memory tool; tests for scoped reads/writes) ### Contribution - [ ] I'd like to implement this myself and submit a PR --- ### Evidence (source inspection + runtime test) - `HERMES_TENANT` is set at `kanban_db.py:9017-9018` in `_default_spawn`. - The only reader is `tools/kanban_tools.py:1217` (default tenant for `kanban_create`) — no memory path consults it. - Runtime test 2026-08-14: two tasks on two different boards (`cognitive-tree-csharp`, `envoy-task`), both assigned to `decomposer`, each created with `--tenant <board>`. Both workers' memory writes landed in the single global `~/.hermes/memories/MEMORY.md` (mtime during the run window); the profile's own `memories/` dir stayed empty. ### Related - #34352 (Solving the Multi-Tenant Hermes Problem) - #47552 (feat(memory): opt-in context_id scoping — open PR, not merged) ### Environment - Hermes agent, commit aec331899 (recent main) - macOS 26.5, kanban dispatcher in gateway (dispatch_in_gateway: true)",
        "url": "https://github.com/NousResearch/hermes-agent/issues/85497",
        "createdAt": "2026-08-13T17:43:33Z",
        "updatedAt": "2026-08-13T17:52:02Z",
        "timestamp": "2026-08-13T17:52:02Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/cron",
          "tool/memory",
          "P3",
          "needs-decision",
          "sweeper:risk-session-state",
          "area/memory"
        ],
        "author": "hua-hua3321",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:13101",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(memory): auto-install hindsight-all for local_embedded mode",
        "text": "## What does this PR do? Fixes a crash when users select `local_embedded` mode via `hermes memory setup`. The setup wizard tries to install `hindsight-all` but the install can silently fail because `capture_output=True` swallows errors. Since `plugin.yaml` only declares `hindsight-client>=0.4.22` as a dependency, hermes startup validation passes even when `hindsight-all` is missing. At runtime, `from hindsight import HindsightEmbedded` crashes with `ModuleNotFoundError: No module named 'hindsight'`. This patch adds a guard in `HindsightMemoryProvider.initialize()` that: 1. Detects when mode is `local_embedded` 2. Attempts to `import hindsight` — if it fails, auto-installs `hindsight-all` via `uv pip install` 3. Raises a clear `RuntimeError` with manual install instructions if installation fails 4. Does **not** surface errors as swallowed silent failures — errors are logged and propagated This mirrors the existing auto-upgrade logic for `hindsight-client` already present in `initialize()` for cloud mode. ## Related Issue Fixes crash described in hindsight local_embedded setup flow. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `plugins/memory/hindsight/__init__.py`: Added auto-install guard for `hindsight-all` in `initialize()` when mode is `local_embedded` - `plugins/memory/hindsight/plugin.yaml`: Added comment documenting the `hindsight-all` dependency for local_embedded mode ## How to Test 1. Configure hindsight with `local_embedded` mode but ensure `hindsight-all` is NOT installed 2. Start a new hermes session with hindsight memory enabled 3. Verify that the plugin auto-installs `hindsight-all` and logs the installation 4. Verify that if installation fails, a clear `RuntimeError` is raised with manual instructions 5. Test cloud mode still works without `hindsight-all` installed (no regression) ## Checklist - [x] My commit messages follow Conventional Commits - [x] My PR contains only changes related to this fix - [x] I have considered cross-platform impact (uses shutil.which(\"uv\") which works on all platforms) - [x] Updated relevant docstrings/comments — plugin.yaml comment added",
        "url": "https://github.com/NousResearch/hermes-agent/pull/13101",
        "createdAt": "2026-04-20T16:06:45Z",
        "updatedAt": "2026-08-13T17:46:21Z",
        "timestamp": "2026-08-13T17:46:21Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "tool/memory",
          "P2",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate",
          "area/memory",
          "area/install-update"
        ],
        "author": "LehaoLin",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:17736",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix: dedup context engine tools + fix kimi aux deadcode model name",
        "text": "## What does this PR do? <!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? --> Two small bugfixes from local-mods: 1. **Dedup context engine tools** — `lcm_grep`/`lcm_describe`/`lcm_expand` were injected without checking `valid_tool_names`. On session restore or context-engine re-init, `get_tool_schemas()` returns the same tools → duplicate function names → 400 from DeepSeek/Moonshot. 2. **Fix Kimi aux deadcode** — `_API_KEY_PROVIDER_AUX_MODELS[\"kimi-coding-cn\"]` pointed to nonexistent model `kimi-k2-turbo-preview`. Changed to `kimi-k2.6`. ## Related Issue <!-- Link the issue this PR addresses. If no issue exists, consider creating one first. --> Fixes # ## Type of Change <!-- Check the one that applies. --> - [ ] 🐛 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 <!-- List the specific changes. Include file paths for code changes. --> - ## How to Test <!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. --> 1. 2. 3. ## Checklist <!-- Complete these before requesting review. --> ### Code - [ ] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [ ] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) 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: <!-- e.g. Ubuntu 24.04, macOS 15.2, Windows 11 --> ### Documentation & Housekeeping <!-- Check all that apply. It's OK to check \"N/A\" if a category doesn't apply to your change. --> - [ ] 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](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [ ] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## For New Skills <!-- Only fill this out if you're adding a skill. Delete this section otherwise. --> - [ ] This skill is **broadly useful** to most users (if bundled) — see [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#should-the-skill-be-bundled) - [ ] SKILL.md follows the [standard format](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#skillmd-format) (frontmatter, trigger conditions, steps, pitfalls) - [ ] No external dependencies that aren't already available (prefer stdlib, curl, existing Hermes tools) - [ ] I've tested the skill end-to-end: `hermes --toolsets skills -q \"Use the X skill to do Y\"` ## Screenshots / Logs <!-- If applicable, add screenshots or log output showing the fix/feature in action. -->",
        "url": "https://github.com/NousResearch/hermes-agent/pull/17736",
        "timestamp": "2026-08-13T11:45:29Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/kimi",
          "P2",
          "sweeper:risk-compatibility",
          "sweeper:risk-caching",
          "sweeper:blast-moderate"
        ],
        "author": "vincent-wen789",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:20696",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): clear typing indicator when cancel_session_processing gives up",
        "text": "## Problem When an agent processing task gets stuck in blocking I/O (e.g. a hung `web_extract` call lasting 961s), the inactivity watchdog fires, `cancel_session_processing` cancels the task, waits 5s, then unblocks dispatch — but the `_keep_typing` refresh loop keeps running. The user sees an infinite \"typing…\" indicator in Telegram with no way to stop it short of restarting the gateway. Root cause: the cancelled task's finally block (which normally calls `_stop_typing_task`) may never run when the task is wedged in C-level blocking I/O. The `_keep_typing` asyncio task continues its 2-second refresh cycle forever. ## Fix In `cancel_session_processing`, when the task refuses to unwind within the 5s window, proactively call `interrupt_session_activity(session_key, chat_id)`. This does two things: 1. **Sets the interrupt event** — `_keep_typing` checks `stop_event.is_set()` on each tick, so it exits within 2 seconds 2. **Calls `stop_typing`** on the adapter — platform-level cleanup (relevant for platforms with persistent typing loops like Discord/Slack) Also passes `chat_id` through the existing call site in `_handle_slash_command` so command-initiated session cancels (`/new`, `/reset`, etc.) get this cleanup too. ## Changes - `gateway/platforms/base.py` — `cancel_session_processing`: +21 lines - New optional `chat_id` parameter (backward compatible, defaults to `None`) - On 5s timeout, calls `interrupt_session_activity` if `chat_id` is provided - Existing call site in `_handle_slash_command` passes `event.source.chat_id` ## Testing - [x] Tested locally on macOS with Telegram supergroup — typing indicator now clears within 2s of gateway inactivity timeout - [x] Backward compatible — existing callers without `chat_id` behave identically - [x] No new dependencies or config changes required",
        "url": "https://github.com/NousResearch/hermes-agent/pull/20696",
        "timestamp": "2026-08-13T11:45:28Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "P2",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery",
          "sweeper:blast-moderate"
        ],
        "author": "vincent-wen789",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:20959",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): stop typing indicator on Telegram after session completion",
        "text": "## Summary Fixes stuck typing indicator on Telegram that persists after normal session completion. ## Problem Two independent issues caused Telegram typing to stay visible indefinitely: 1. **Normal completion**: `_keep_typing` is cancelled, but Telegram adapter had no `stop_typing()` implementation — the base class no-op left the typing bubble to expire on its own (~5s). Some Telegram clients cache the indicator state and never clear it. 2. **Dead-session timeout**: `cancel_session_processing` gives up after 5s if the task is stuck in blocking I/O. Without the `chat_id` parameter, the interrupt event was never signaled, so `_keep_typing` kept refreshing the typing bubble every 2 seconds indefinitely. ## Changes ### `gateway/platforms/telegram.py` - Add `stop_typing()` implementation that sends `choose_sticker` chat action to supersede the `typing` action (Telegram has no explicit \"cancel typing\" API) - Non-fatal — if the API call fails, the bubble still expires within 5s once `_keep_typing` stops refreshing ### `gateway/platforms/base.py` - Add optional `chat_id` parameter to `cancel_session_processing` - On timeout path (`_gave_up`), call `interrupt_session_activity` to signal the interrupt event and invoke `stop_typing` on the adapter - Update the sole call site in `_handle_slash_command` to pass `chat_id` - Reorder command handling: send response BEFORE cancelling old task (race condition fix from issue #18912) ## Test Plan - [x] Verified typing indicator clears after normal DM session completion (manual test on Telegram) - [x] Gateway restart with changes confirmed stable ## Related - Issue #18912 (race condition: `/new` response dropped during active agent) - Similar dead-session typing issues: #27419, #27075",
        "url": "https://github.com/NousResearch/hermes-agent/pull/20959",
        "timestamp": "2026-08-13T11:45:28Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "platform/telegram",
          "P2",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery",
          "sweeper:blast-moderate",
          "area/sessions"
        ],
        "author": "vincent-wen789",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:21321",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "Fix/tui focus redraw",
        "text": "## What does this PR do? <!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? --> ## Related Issue <!-- Link the issue this PR addresses. If no issue exists, consider creating one first. --> Fixes # ## Type of Change <!-- Check the one that applies. --> - [ ] 🐛 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 <!-- List the specific changes. Include file paths for code changes. --> - ## How to Test <!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. --> 1. 2. 3. ## Checklist <!-- Complete these before requesting review. --> ### Code - [ ] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [ ] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) 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: <!-- e.g. Ubuntu 24.04, macOS 15.2, Windows 11 --> ### Documentation & Housekeeping <!-- Check all that apply. It's OK to check \"N/A\" if a category doesn't apply to your change. --> - [ ] 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](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [ ] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## For New Skills <!-- Only fill this out if you're adding a skill. Delete this section otherwise. --> - [ ] This skill is **broadly useful** to most users (if bundled) — see [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#should-the-skill-be-bundled) - [ ] SKILL.md follows the [standard format](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#skillmd-format) (frontmatter, trigger conditions, steps, pitfalls) - [ ] No external dependencies that aren't already available (prefer stdlib, curl, existing Hermes tools) - [ ] I've tested the skill end-to-end: `hermes --toolsets skills -q \"Use the X skill to do Y\"` ## Screenshots / Logs <!-- If applicable, add screenshots or log output showing the fix/feature in action. -->",
        "url": "https://github.com/NousResearch/hermes-agent/pull/21321",
        "createdAt": "2026-05-07T14:05:04Z",
        "updatedAt": "2026-08-13T15:56:52Z",
        "timestamp": "2026-08-13T15:56:52Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/tui",
          "P2",
          "sweeper:risk-message-delivery",
          "sweeper:risk-compatibility",
          "sweeper:risk-platform-windows",
          "sweeper:blast-broad"
        ],
        "author": "PaytonBurbank",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:29624",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(auxiliary): prevent URL double-rewrite for custom anthropic_messages endpoints",
        "text": "markdown Bug Description Auxiliary tasks (title generation, vision, compression, etc.) fail with HTTP 404 when using a custom provider with api_mode: anthropic_messages (e.g. Zhipu GLM via https://open.bigmodel.cn/api/anthropic). Error: Auxiliary title generation failed: HTTP 404: Error code: 404 - {'status': 404, 'error': 'Not Found', 'path': '/v4/v1/messages'} Root Cause In resolve_provider_client()'s custom + explicit_base_url branch, the URL passes through two contradictory rewrites: 1. _to_openai_base_url() rewrites /api/anthropic → /api/paas/v4 (OpenAI-wire format), because the auxiliary client historically uses the OpenAI SDK. 2. _maybe_wrap_anthropic() detects api_mode=anthropic_messages and builds an AnthropicAuxiliaryClient on the already-rewritten URL. 3. The Anthropic SDK appends /v1/messages to /api/paas/v4, producing /v4/v1/messages → 404. The two steps assume mutually exclusive transports: step 1 assumes OpenAI SDK, step 2 assumes Anthropic SDK. When both fire, the URL is mangled. Fix When api_mode == \"anthropic_messages\", bypass _to_openai_base_url() entirely and build an AnthropicAuxiliaryClient directly from the original explicit_base_url (e.g. https://open.bigmodel.cn/api/anthropic). This mirrors the approach already used in _try_custom_endpoint() (the Step-2 fallback path), which correctly handles anthropic_messages by building the Anthropic client first. On ImportError (anthropic SDK not installed) or other exceptions, falls back to the existing OpenAI-wire path. How to Verify 1. Configure a custom provider with Anthropic-compatible endpoint: yaml model: default: glm-5.1 provider: custom base_url: https://open.bigmodel.cn/api/anthropic api_mode: anthropic_messages api_key: <your-key> 2. Start a new session with hermes 3. Send any message — previously this would log Auxiliary title generation failed: HTTP 404 4. After the fix, title generation succeeds and the session gets a proper title 5. Verify with a unit test: python from agent.auxiliary_client import resolve_provider_client client, model = resolve_provider_client( 'custom', 'glm-5.1', explicit_base_url='https://open.bigmodel.cn/api/anthropic', explicit_api_key='test-key', api_mode='anthropic_messages', ) assert type(client).name == 'AnthropicAuxiliaryClient' assert str(getattr(client, 'base_url', '')).endswith('/api/anthropic') Test Plan - [x] Manual verification with Zhipu GLM Anthropic-compatible endpoint - [x] Module import succeeds (import agent.auxiliary_client) - [x] resolve_provider_client returns correct AnthropicAuxiliaryClient with unrewritten URL - [ ] Existing tests still pass - [ ] Regression test added Risk Assessment Low — The fix adds an early-return branch that only triggers when api_mode == \"anthropic_messages\". All other code paths (OpenAI-wire custom endpoints, named providers, auto-detection without explicit api_mode) are unchanged. The fallback on ImportError ensures no regression if the anthropic SDK is missing.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/29624",
        "createdAt": "2026-05-21T03:50:41Z",
        "updatedAt": "2026-08-13T17:34:21Z",
        "timestamp": "2026-08-13T17:34:21Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "area/config",
          "P2",
          "sweeper:risk-compatibility",
          "sweeper:blast-contained"
        ],
        "author": "yimi128",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:30232",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(custom-provider): preserve base URL for anthropic_messages api_mode",
        "text": "## Problem When a custom provider is configured with `api_mode: anthropic_messages`, the code in `resolve_provider_client()` unconditionally rewrites the base URL via `_to_openai_base_url()`, converting e.g. `http://host/anthropic` → `http://host/v1`. The Anthropic SDK then appends `/v1/messages` to the base URL, resulting in a request to `/v1/v1/messages` → **404**. ## Root Cause The generic `custom` provider branch (~line 3271) lacks the `api_mode` guard that the named custom-provider branch (~line 3380) already has: ```python # named-custom branch (correct) if entry_api_mode == \"anthropic_messages\": # keeps custom_base as-is else: openai_base = _to_openai_base_url(custom_base) ``` The generic branch was missing this check entirely. ## Fix Apply the same guard to the generic `custom` branch: when `api_mode == \"anthropic_messages\"`, keep the original base URL (only strip trailing slash). For all other api_modes, the existing `_to_openai_base_url()` rewrite is preserved. ## Testing Reproduced and verified with a local Anthropic-compatible proxy (`base_url: http://localhost:6655/anthropic`, `api_mode: anthropic_messages`). Before the fix: 404 on every call. After: requests route correctly to `/anthropic/v1/messages`.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/30232",
        "createdAt": "2026-05-22T05:11:24Z",
        "updatedAt": "2026-08-13T17:34:23Z",
        "timestamp": "2026-08-13T17:34:23Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/agent",
          "area/config",
          "P2",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "terlanks",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:31623",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(stt): add prompt files and Speaches hotwords",
        "text": "## Summary This PR is rebased onto current `upstream/main` (`6f1870cd1`) and narrowed to complement #65632 rather than duplicate it. It adds only the OpenAI-compatible provider extensions that #65632 does not cover: - `stt.openai.prompt_file`: a UTF-8 prompt file read at transcription time, so vocabulary edits take effect without restarting Hermes; - `stt.openai.hotwords`: endpoint-specific vocabulary hints sent through the OpenAI SDK's `extra_body`, which merges them into the multipart request for compatible servers such as Speaches. The provider-specific inline `prompt` and `language` settings remain out of this PR. #65632 remains the owner of the generic `stt.prompt`, per-provider language, request-scoped overrides, and `pre_transcription` hook threading. ## Interaction with #65632 The generic prompt argument from #65632 takes precedence. `stt.openai.prompt_file` is only a fallback when the generic/dynamic prompt is empty. Applying this commit after #65632 produces one expected textual conflict in `_transcribe_openai()` because both PRs modify the SDK call. The intended combined resolution is: 1. retain #65632's `language` and `prompt` parameters; 2. resolve `prompt` through `_get_openai_stt_prompt(openai_cfg, prompt)`; 3. add `language` to the shared `create_kwargs` when set; 4. add Speaches `hotwords` via `extra_body`; 5. make one `client.audio.transcriptions.create(file=audio_file, **create_kwargs)` call. This keeps #65632's generic hook architecture and adds only file-backed prompts plus native compatible-endpoint hotwords. ## Safety and compatibility - both settings default to empty and preserve existing behavior; - unreadable prompt files fail open with a warning; - `hotwords` is sent through `extra_body`, without treating it as an official OpenAI parameter; - third-party callers that reuse `_transcribe_openai` do not inherit the configured OpenAI provider hints. ## Validation On current `upstream/main`: ```text python -m pytest tests/tools/test_transcription_prompt_config.py tests/tools/test_transcription_tools.py tests/tools/test_transcription.py tests/tools/test_transcription_deepinfra.py tests/tools/test_transcription_plugin_dispatch.py -o addopts= -q 92 passed ``` `git diff --check upstream/main...HEAD` also passes.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/31623",
        "timestamp": "2026-08-13T12:06:47Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "tool/tts",
          "area/config",
          "P3",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "echo931",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:32175",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(langfuse): include Anthropic system prompt in generation input",
        "text": "## Summary When using Anthropic (and other providers that split `system` out of `messages`), Langfuse **LLM call** generation observations only showed user/assistant turns — the Hermes system prompt (skills, memory, context) was missing from the trace input. This change: - Passes `system_prompt` from `api_kwargs[\"system\"]` (with fallback when the first message is `role: system`) through the `pre_api_request` hook in `agent/conversation_loop.py` - Prepends a serialized `role: system` message to Langfuse generation input in `plugins/observability/langfuse` - Adds `system_prompt_chars` metadata when a system block is logged - Documents verification in the Langfuse plugin README ## Why Anthropic Messages API does not include the system prompt in `messages`; Hermes already builds `api_kwargs[\"system\"]` separately. The Langfuse plugin only serialized `request_messages`, so hosted traces looked like conversations without the agent instructions — making debugging prompt/cache behavior difficult. ## Test plan - [x] `pytest tests/plugins/test_langfuse_plugin.py` (39 passed locally) - [ ] Enable `observability/langfuse`, run `hermes chat -q \"hello\"`, open **LLM call** in Langfuse and confirm `role: system` appears first in generation input Made with [Cursor](https://cursor.com)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/32175",
        "createdAt": "2026-05-25T17:12:35Z",
        "updatedAt": "2026-08-13T17:40:54Z",
        "timestamp": "2026-08-13T17:40:54Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/plugins",
          "provider/anthropic",
          "P3",
          "sweeper:risk-compatibility",
          "sweeper:blast-contained"
        ],
        "author": "db-aeon",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:37684",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(model_metadata): drop stale cache entries for qwen3.6-plus, grok-4-fast, grok-4.20",
        "text": "## Summary Three model families gained explicit `DEFAULT_CONTEXT_LENGTHS` entries after some users may have already cached lower (incorrect) values through earlier resolution paths. This PR adds stale-cache guards for each, mirroring the pattern used for Kimi (≤32K), MiniMax-M3 (≤204,800), and Grok-4.3 (≤256K) in prior PRs. ### qwen3.6-plus (1M context) The `qwen3.6-plus` entry was added on 2026-05-17 (`d9abbe7fa`). Prior builds resolved it via the generic `qwen` catch-all (131,072) and could have persisted that value via the context-overflow cache path. Guard threshold: `cached ≤ 131,072`. ### grok-4-fast and grok-4.20 (2M context each) Both entries were added on 2026-04-10 (`b57769718`). Before that date, these slugs had no explicit entry and fell through to `DEFAULT_FALLBACK_CONTEXT` (256,000) or lower probe tiers. Guard threshold: `cached ≤ 256,000`. ## Changes - `agent/model_metadata.py`: add `_model_name_suggests_qwen3_6_plus()` and `_model_name_suggests_grok_4_fast_or_4_20()` helper predicates; add three `elif` branches in the step-1 stale-cache guard block inside `get_model_context_length()`. - `tests/agent/test_model_metadata.py`: add `TestQwen36PlusStaleCacheGuard` and `TestGrokFastAndGrok420StaleCacheGuard` (12 tests total) — helper unit tests, stale-drop-and-re-resolve, correct-cache-preserved, no-clobber for sibling slugs. ## Test plan - [ ] `pytest tests/agent/test_model_metadata.py::TestQwen36PlusStaleCacheGuard tests/agent/test_model_metadata.py::TestGrokFastAndGrok420StaleCacheGuard -x -q` — 12 tests pass",
        "url": "https://github.com/NousResearch/hermes-agent/pull/37684",
        "createdAt": "2026-06-02T22:11:16Z",
        "updatedAt": "2026-08-13T17:22:20Z",
        "timestamp": "2026-08-13T17:22:20Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/xai",
          "provider/qwen",
          "P3",
          "sweeper:risk-compatibility",
          "sweeper:blast-contained"
        ],
        "author": "AhmetArif0",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:37865",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(messaging): add explicit active handoff primitives",
        "text": "## Summary - add opt-in `send_message(trigger_agent=True)` active handoff result plumbing while keeping passive sends as the default - add Kanban `notify-subscribe --trigger-agent` persistence and gateway notifier support - actively wake the originating gateway agent for gateway-created Kanban task terminal ACKs - preserve Kanban origin ACK edges for CLI/script-created graphs: - child tasks inherit parent/root terminal notification subscriptions - explicit `Origin/return_to: Discord ... (<#channel_id>)` body prose infers a notify subscription for CLI-created cards - document that headless Kanban workers should write board verdicts instead of directly sending Discord/Telegram final ACKs ## Safety - default behavior remains passive unless `trigger_agent=True` / `--trigger-agent` is explicitly requested or a CLI-created task carries a narrow, machine-readable Discord `Origin/return_to` line - active wake is best-effort after a successful visible platform send; it is not an atomic send+wake transaction - visible platform send and active wake are reported separately via `triggered_agent` / `trigger_error` - Kanban subscriptions wake agents only when they carry `trigger_agent=1` - active wake failures are isolated from passive delivery so the notifier does not crash or retry the same terminal event just because wake is unavailable - the `Origin/return_to` parser intentionally recognizes only explicit Discord channel-id shapes such as `<#123...>` or `chat_id=123...`; it is not a general natural-language parser ## Tests - `python -m pytest tests/hermes_cli/test_kanban_notify.py tests/gateway/test_kanban_notifier.py tests/gateway/test_kanban_notifier_watcher_dispatch_gate.py tests/tools/test_send_message_tool.py -q` → 160 passed, 2 warnings - `git diff --check` - `python -m compileall gateway/kanban_watchers.py gateway/slash_commands.py hermes_cli/kanban.py hermes_cli/kanban_db.py tools/send_message_tool.py tests/hermes_cli/test_kanban_notify.py -q` ## Refresh notes - rebased onto current `origin/main` (`4829f8d2c` at refresh time) - force-pushed with `--force-with-lease` from old head `f3c9a6453` to new head `9829f4ee0` Local operator policy and AgentFlow supervisor hooks remain intentionally out of scope.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/37865",
        "createdAt": "2026-06-03T04:05:25Z",
        "updatedAt": "2026-08-13T17:48:00Z",
        "timestamp": "2026-08-13T17:48:00Z",
        "metrics": {
          "reactions": 0,
          "comments": 19
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "comp/tools",
          "comp/cron",
          "P3",
          "blocked",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "verybigdog",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:37977",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(plugins): require renewed review before plugin updates",
        "text": "## Summary Plugin updates now use a staged review transaction instead of modifying an enabled plugin in place. - Copy the installed Git plugin into a private quarantine directory and fast-forward only the staged copy. - Validate the manifest and exact old/new commits, compute a deterministic staged-content hash, inventory changed files, and run the existing skills AST audit as a diagnostic scan. - Return the same review payload to the CLI and dashboard, including the candidate revision, content hash, changed files, scan findings, and `after-install.md` content. - Require explicit acceptance using a review token bound to the plugin key, old commit, new commit, and staged content hash. - Revalidate both the live and staged trees at acceptance time, then promote atomically with rollback. The live enabled plugin remains unchanged until this succeeds. - Append `STAGED` and `ACCEPTED` events to a JSONL audit log. - Bind tool-override consent to the exact installed plugin revision. Updates revoke an earlier grant unless the operator explicitly renews it for the reviewed revision. Closes #37976 ## Review feedback addressed - The live plugin directory is no longer pulled before review; update preparation occurs in a private quarantine directory. - CLI and dashboard use one staged transaction and one exact-token acceptance path. - The dashboard API and UI now surface the review fields and provide explicit accept/cancel controls. - Tool-override permission is revision-bound and fails closed when plugin content changes. - End-to-end local-Git coverage proves that staging leaves the live commit/content untouched, exact acceptance promotes the reviewed commit while preserving enabled state, stale or mutated review state is rejected, and override renewal is explicit. ## Tests and validation - `scripts/run_tests.sh tests/hermes_cli/test_plugins_cmd.py tests/hermes_cli/test_plugins.py` — 217 passed, 0 failed. - `npm run typecheck --workspace=web` — passed. - `cd web && npx eslint src/pages/PluginsPage.tsx src/lib/api.ts` — passed. - `ruff check hermes_cli/plugins.py hermes_cli/plugins_cmd.py hermes_cli/web_server.py tests/hermes_cli/test_plugins.py tests/hermes_cli/test_plugins_cmd.py` — passed. - `git diff --check` — passed. Rebased onto `477c08b44766ace8b890faa72bf82ecbcf2b3ba8`; current head is `9b631420294693b0ce82471edad65ec69418b675`.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/37977",
        "createdAt": "2026-06-03T06:44:38Z",
        "updatedAt": "2026-08-13T18:01:14Z",
        "timestamp": "2026-08-13T18:01:14Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/security",
          "comp/cli",
          "comp/plugins",
          "P3",
          "sweeper:blast-moderate",
          "comp/dashboard"
        ],
        "author": "coygeek",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:39653",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(langfuse): surface reasoning_content in traces (#29482)",
        "text": "## Summary Langfuse assistant-message serialization only reads `message.reasoning`. Reasoning models and adapters can expose reasoning under `reasoning_content` or structured `reasoning_details`, which makes Langfuse traces show `reasoning: None` despite available reasoning data. This PR adds a small reasoning extraction helper and uses it during assistant-message serialization. ## Changes - `plugins/observability/langfuse/__init__.py`: serialize reasoning from `reasoning`, `reasoning_content`, or `reasoning_details`. - Tests: add Langfuse serialization coverage for alternate reasoning fields. ## Validation - `python -m pytest tests -k langfuse -v --timeout=0` - `python -m pytest tests/ -v --timeout=60` ## Upstream Closes #29482. Reported by @swanhtet1992.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/39653",
        "createdAt": "2026-06-05T07:36:15Z",
        "updatedAt": "2026-08-13T17:40:47Z",
        "timestamp": "2026-08-13T17:40:47Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "P3",
          "sweeper:blast-contained"
        ],
        "author": "rodboev",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:41351",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(acp): resolve symlink targets when guarding sensitive edit auto-approval",
        "text": "## What does this PR do? The ACP edit-approval guard promises that sensitive files (`.env`, `.ssh`, `.git`, `id_rsa`, `id_ed25519`) always prompt the user, even when a session runs under an autonomous policy (`session` / `workspace_session`). It did not hold for symlinks. `should_auto_approve_edit` resolves the path with `Path(...).resolve()` for the workspace-containment check, but `_is_sensitive_auto_approve_path` inspected only the *unresolved* literal path. So a symlink with a harmless name — e.g. `notes.txt` -> `~/.ssh/authorized_keys`, or `link.txt` -> `.env` inside the workspace — passed the sensitive check and was silently auto-approved, writing straight through to the protected file. A repo can ship such a symlink (git tracks them), so an autonomous editor session could have a secret overwritten without a prompt. Before: `_is_sensitive_auto_approve_path(\"link.txt\")` looks at the name `link.txt`, sees nothing sensitive, returns `False` -> auto-approved. After: the guard also checks `link.txt`'s `resolve()`d target (`.env`), matches the sensitive set, returns `True` -> the edit prompts instead of auto-approving. ## Related Issue N/A ## Type of Change - [x] 🔒 Security fix ## Changes Made - `acp_adapter/edit_approval.py`: split the sensitivity test into a reusable `_path_is_sensitive(Path)` helper and make `_is_sensitive_auto_approve_path` check both the expanded literal path and its `resolve(strict=False)` target, so an innocently named symlink can no longer launder a write to a protected file. `resolve()` failures (e.g. symlink loops) fall back to treating only the literal path, matching the prior conservative behavior. - `tests/acp/test_edit_approval.py`: add `test_symlink_to_sensitive_file_is_not_auto_approved`, covering a workspace symlink to `.env` (under both `session` and `workspace_session`) and to `.ssh/authorized_keys`, asserting none are auto-approved. ## How to Test 1. `scripts/run_tests.sh tests/acp/test_edit_approval.py` — the new `test_symlink_to_sensitive_file_is_not_auto_approved` passes; `test_workspace_auto_approval_allows_workspace_and_tmp_but_not_sensitive` still passes (real workspace files keep auto-approving). 2. Manually: in a workspace, `ln -s .env link.txt`, drive an ACP session under `workspace_session`/`session` policy, and confirm an edit to `link.txt` now raises an approval prompt instead of being auto-applied. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits (`fix(scope):`, etc.) - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains only changes related to this fix - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin 25.5.0) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/41351",
        "createdAt": "2026-06-07T15:03:25Z",
        "updatedAt": "2026-08-13T18:01:18Z",
        "timestamp": "2026-08-13T18:01:18Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/security",
          "comp/acp",
          "area/auth",
          "P2",
          "sweeper:risk-security-boundary",
          "sweeper:blast-moderate"
        ],
        "author": "Vesna-9",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:41440",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(research): default tokenizer trust_remote_code to False to prevent RCE",
        "text": "## What does this PR do? Closes a critical remote-code-execution hole in the trajectory compressor. `CompressionConfig.trust_remote_code` defaulted to `True`, and that value was passed straight into `AutoTokenizer.from_pretrained(..., trust_remote_code=...)`. HuggingFace honors that flag by importing and running arbitrary Python shipped in the tokenizer repo at load time — before a single trajectory is touched. The blast radius is large because this subsystem is built around *shared* configs: it ships examples in `datagen-config-examples/`, and both the tokenizer name and the flag come unvalidated from a YAML file (or the `--tokenizer` override). Anyone who runs `python trajectory_compressor.py --config <theirs>.yaml` (or points at a malicious tokenizer repo) was one constructor call away from code execution on their machine. The same dangerous default was hardcoded in `scripts/sample_and_compress.py`. The fix makes the behavior secure-by-default, matching HuggingFace's own posture: remote code is off unless the operator explicitly opts in for a tokenizer they trust, and the opt-in can never happen silently — we print a loud warning naming the exact repo before any code runs. Legitimate opt-in (e.g. the Kimi tokenizer that genuinely needs it) still works via an explicit `trust_remote_code: true` in your own config. ## Related Issue N/A ## Type of Change - [x] 🔒 Security fix ## Changes Made - `trajectory_compressor.py`: flipped `CompressionConfig.trust_remote_code` to default `False`; `_init_tokenizer()` now emits a prominent SECURITY warning (naming the tokenizer repo) whenever the flag resolves to `True`, so remote code execution is never silent. - `scripts/sample_and_compress.py`: `_init_tokenizer_worker()` no longer hardcodes `trust_remote_code=True`; it defaults to `False` and warns when enabled. The flag is threaded through `sample_from_datasets()` and `main()` as an explicit, off-by-default option. - `datagen-config-examples/trajectory_compression.yaml`: documented the RCE risk inline so the example's explicit `true` reads as a conscious opt-in for the Kimi tokenizer rather than a normalized default. - `tests/test_trajectory_compressor.py`: added `TestTrustRemoteCodeSecurity` covering the safe default, that an omitted YAML key stays `False`, that an explicit `true` is still honored, and that `_init_tokenizer()` forwards the flag and warns loudly only when enabled. ## How to Test 1. `pytest tests/test_trajectory_compressor.py -k TrustRemoteCode -q` — the new security tests pass (5 passed). 2. `pytest tests/test_trajectory_compressor.py -q` — full file green (40 passed). 3. Sanity-check the default: `python -c \"from trajectory_compressor import CompressionConfig; print(CompressionConfig().trust_remote_code)\"` prints `False`. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin 25.5) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/41440",
        "createdAt": "2026-06-07T17:56:47Z",
        "updatedAt": "2026-08-13T17:20:02Z",
        "timestamp": "2026-08-13T17:20:02Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/security",
          "P2",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:blast-contained"
        ],
        "author": "rrevenanttt",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:42326",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(langfuse): guard _get_langfuse() against concurrent-init TOCTOU",
        "text": "## Summary `_get_langfuse()` holds a global `_LANGFUSE_CLIENT` variable with no lock protecting the double-check initialization. Two concurrent first callers (e.g. two gateway sessions both triggering a Langfuse hook at the same time) both pass the `_LANGFUSE_CLIENT is not None` guard, both construct a `Langfuse(**kwargs)` client, and the loser's client leaks an open HTTPS connection and a background flush thread. Under sustained gateway load this accumulates silently. This is the same TOCTOU class fixed for the Honcho and FAL clients in `plugins/plugin_utils.py` (PR #24759 / commit `47d5177a7`). **Changes:** - Add `_LANGFUSE_CLIENT_LOCK = threading.Lock()` alongside the existing `_STATE_LOCK` (deliberately separate — `_STATE_LOCK` is already on the hot path for every hook call and nesting would risk deadlock). - Wrap the entire init body of `_get_langfuse()` in a double-checked lock. - The two outer fast-path checks (`_INIT_FAILED` / `is not None`) remain outside the lock so every hook call after the first stays zero-contention. - All existing `_INIT_FAILED` sentinel semantics (missing SDK, missing creds, placeholder keys, construction exception) are preserved unchanged. ## Test plan - [ ] `tests/plugins/test_langfuse_plugin.py` — 37 existing tests all pass - [ ] Behavior identical to before for the single-threaded case (fast path unchanged, `_INIT_FAILED` cached on any init failure, same log messages) - [ ] No new imports or external dependencies",
        "url": "https://github.com/NousResearch/hermes-agent/pull/42326",
        "createdAt": "2026-06-08T18:47:06Z",
        "updatedAt": "2026-08-13T17:40:43Z",
        "timestamp": "2026-08-13T17:40:43Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "P3",
          "telemetry",
          "sweeper:blast-contained"
        ],
        "author": "nftpoetrist",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:43130",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(langfuse): omit explicit zero cost_details for subscription-included providers (#43129)",
        "text": "## What does this PR do? Fixes Langfuse showing $0 cost for subscription-included providers (e.g. `openai-codex`). Both the response-object path and the `post_api_request` dict path were sending explicit zero-valued `cost_details` to Langfuse, which treats them as authoritative and never recalculates from model pricing. ## Related Issue Fixes #43129 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `plugins/observability/langfuse/__init__.py`: In `_usage_and_cost()`, gate cost_details population on `cost.status != \"included\"` so subscription-included routes produce empty cost_details instead of explicit zeros. In the `post_api_request` dict path, check `resolve_billing_route().billing_mode != \"subscription_included\"` before computing per-type costs from the pricing entry. - `tests/plugins/test_langfuse_plugin.py`: Added `TestSubscriptionIncludedCostOmission` with 4 regression tests covering both code paths (subscription-included → empty cost_details, normal provider → populated cost_details). ## How to Test 1. Run `pytest tests/plugins/test_langfuse_plugin.py -xvs` — all 43 tests pass 2. The new tests verify: - `_usage_and_cost()` returns empty `cost_details` for `openai-codex` (subscription_included) - `_usage_and_cost()` returns populated `cost_details` for `openai` (normal provider) - `on_post_llm_call` dict path returns empty `cost_details` for `openai-codex` - `on_post_llm_call` dict path returns populated `cost_details` for `openai` 3. Manual verification: configure Langfuse + openai-codex provider, run a turn, check that Langfuse shows estimated cost (not $0) ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## Code Intelligence - Analyzed: `_usage_and_cost`, `on_post_llm_call`, `get_pricing_entry`, `estimate_usage_cost`, `resolve_billing_route` - Blast radius: LOW — changes are confined to the Langfuse plugin's cost-export logic; no behavioral change for non-Langfuse consumers - Related patterns: `estimate_usage_cost` returns `CostResult(amount_usd=Decimal(\"0\"), status=\"included\")` for subscription routes; `get_pricing_entry` returns `PricingEntry` with all `_ZERO` costs. Both paths now check the billing status before populating cost_details.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/43130",
        "createdAt": "2026-06-09T22:42:31Z",
        "updatedAt": "2026-08-13T17:41:04Z",
        "timestamp": "2026-08-13T17:41:04Z",
        "metrics": {
          "reactions": 2,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "provider/openai",
          "P3",
          "sweeper:risk-compatibility",
          "sweeper:blast-contained",
          "area/usage-cost"
        ],
        "author": "liuhao1024",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:44772",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cli): drop agent-browser/@streamdown-math from root npm deps",
        "text": "## What does this PR do? `hermes update` was pruning root-level Node dependencies (e.g. `agent-browser`) on every run. This PR originally tried to fix that by juggling install order between the root and ui-tui/web workspace installs; that approach didn't hold up under review. The old two-pass shape (`npm ci --workspaces=false` then `npm ci --workspace ui-tui --workspace web`) reliably prunes a root-only dependency on its second pass, confirmed empirically and via npm/cli source. A single-pass `npm ci --workspace ui-tui --workspace web --include-workspace-root` does keep a root-only dependency (`--include-workspace-root` is consumed via `flatOptions` into Arborist's `includeWorkspaceRoot`, independent of the two-pass shape's problem), so a flag-only fix was possible. This PR takes a different approach anyway, removing the root-only dependencies entirely, because it also eliminates the manifests-digest skip-marker problem a flag-only fix would still have to handle explicitly, and it doesn't depend on one flag's semantics staying stable across future npm versions: 1. **agent-browser is no longer a root `package.json` dependency.** It resolves lazily via `npx agent-browser` (`tools/browser_tool.py` already had npx as a fallback in its resolution cascade: PATH, then Homebrew/Hermes-managed node, then local `.bin`, then npx); it's now the primary path, identified via a shared `NPX_AGENT_BROWSER_SENTINEL` constant + `_is_npx_agent_browser_sentinel()` predicate used at every comparison site instead of ad-hoc string literals. The npx version spec (`AGENT_BROWSER_NPX_SPEC = \"agent-browser@^0.26.0\"`) matches the same `^0.26.0` range `install.sh`/`install.ps1` use; it is a version range, not an exact pin, and npx resolution has no lockfile-equivalent integrity check, so every real npx invocation of agent-browser passes `--ignore-scripts` so a future compromised patch release can't run install-time lifecycle scripts. A `warm_agent_browser_npx_cache()` helper runs early in both `hermes update` (before the lockfile-unchanged early return, so it actually fires on a plain run) and `hermes doctor --fix`, keeping npx's own cache warm ahead of time with a credential-scrubbed, PATH-propagated environment (matching every other agent-browser subprocess spawn) and killing its whole process tree, not just the top-level npx PID, if it hangs past its timeout. This preserves the \"available before any session starts\" property agent-browser had as an eager dependency (per #27055's original reasoning) without re-entangling it with the npm workspace graph or exposing Hermes's own credentials to a registry-fetched package. 2. **`@streamdown/math` moves to `apps/desktop/package.json`.** It's only ever imported by desktop's own TS code (`markdown-text.tsx`, `katex-memo.ts`); it was misplaced at root and subject to the exact same pruning risk agent-browser had. 3. **`_update_node_dependencies()` runs a single `npm ci --workspace ui-tui --workspace web --include-workspace-root` call.** `apps/desktop` is never named, so its Electron devDependency (and ~200MB postinstall) is still never resolved by default. `--include-workspace-root` protects root's own remaining `devDependencies` (the shared ESLint flat config every workspace's `eslint.config.mjs` imports) from the same scoped-install pruning; it has nothing to do with agent-browser/`@streamdown/math`, which aren't root dependencies anymore. 4. **Every surface that probes for agent-browser resolves through the same cascade**, so none of them can diverge from what browser tools actually invoke at runtime: `hermes_cli/tools_config.py`'s post-setup Chromium-install path, `hermes_cli/doctor.py`'s agent-browser check, `hermes_cli/doctor_live.py`'s `--live` browser probe, `hermes_cli/dep_ensure.py`'s `ensure_dependency(\"browser\")`, and `hermes_cli/nous_subscription.py`'s desktop-Capabilities-panel check all resolve via `_find_agent_browser`/`_resolve_npx_bin` (honoring the same Termux bare-npx carve-out) instead of hand-rolling their own PATH/`node_modules/.bin` lookups. `_resolve_npx_bin()` checks the Hermes-managed/extended search before a bare ambient PATH lookup, validating each candidate actually runs before trusting it, so a broken system npx can't shadow a healthy managed one. 5. **`install.sh`/`install.ps1` no longer npm-install agent-browser at all**, eager or on-demand. Both scripts' browser-setup functions were only ever reached through their explicit `--ensure browser`/`-Ensure browser` fallback mode, itself only triggered by an actual browser-tool call's lazy-install path, which already resolves agent-browser via npx before ever reaching these scripts. Eagerly npm-installing a second, separately version-pinned copy there was redundant and an extra supply-chain surface for a path npx already covers. Chromium acquisition for that on-demand path now defers entirely to `_maybe_autoinstall_chromium`'s existing lazy fallback. `@askjo/camofox-browser`'s install and system-browser detection/configuration are unaffected. ## Related Issue Fixes #43564 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `package.json`: drop `agent-browser` and `@streamdown/math` from root `dependencies` (root now has no runtime dependencies of its own; a shared ESLint `devDependencies` block remains, protected separately, see below); also drop the now-stale `\"agent-browser@0.26.0\": true` `allowScripts` entry, since nothing in the lockfile resolves that pin anymore - `apps/desktop/package.json`: add `@streamdown/math` alongside its sibling `@streamdown/code` - `package-lock.json`: regenerated to match - `hermes_cli/update_cmd.py`: `_update_node_dependencies()` runs `npm ci --workspace ui-tui --workspace web --include-workspace-root` (the last flag protects root's own devDependencies only). `warm_agent_browser_npx_cache()` runs before the `_npm_lockfile_changed` early return (with a status line) so it fires on the common no-op `hermes update` path too, not just when npm actually reinstalls - `tools/browser_tool.py`: - `_resolve_npx_bin()`: checks the Hermes-managed/Homebrew extended PATH search before a bare ambient lookup, validating each candidate with `node_tool_runnable` before trusting it - `warm_agent_browser_npx_cache()`: rewritten around `subprocess.Popen` instead of `subprocess.run`; runs a credential-scrubbed (`_build_browser_env()`), PATH-propagated environment instead of inheriting the full parent environment, runs in its own process group, and kills the whole tree (new `_kill_process_tree` helper: POSIX `killpg`, Windows recursive `taskkill /T /F`) if it hangs past its timeout, since a surviving descendant can otherwise hold a capture pipe open indefinitely - `NPX_AGENT_BROWSER_SENTINEL`/`_is_npx_agent_browser_sentinel()` and `AGENT_BROWSER_NPX_SPEC` (`agent-browser@^0.26.0`, matching `install.sh`/`install.ps1`'s range, not an exact pin) - every real npx-agent-browser invocation (the two real launch sites, the Chromium auto-install site, and the warm-up) resolves npx via `_resolve_npx_bin()` instead of a bare `shutil.which(\"npx\")`, and passes `--ignore-scripts --prefer-offline -y` - `hermes_cli/doctor.py`: agent-browser check mirrors `_find_agent_browser`'s resolution cascade instead of checking `node_modules/agent-browser` directly; `hermes doctor --fix` warms the npx cache, reported info-only (doesn't count toward \"Fixed N issue(s)\" since agent-browser was already healthy) - `hermes_cli/doctor_live.py`: `--live`'s `_browser_available()` probe falls through to the same npx cascade (with the Termux carve-out) when PATH/node_modules checks miss - `hermes_cli/dep_ensure.py`: the `\"browser\"` dependency check gains the same npx rung, so `ensure_dependency(\"browser\")` (used by `hermes acp --setup-browser` and browser_tool's lazy-install path) can't shell out to `install.sh` on an install `hermes doctor` already reports healthy - `hermes_cli/nous_subscription.py`: `_has_agent_browser`'s `tools.browser_tool` import-failure fallback restores the Hermes-managed-Node-path probe (Windows installer) and the PATHEXT-aware `shutil.which` lookup for the local `node_modules/.bin` copy (Windows `.cmd`-shim resolution) - `hermes_cli/tools_config.py`: `_run_post_setup`'s Chromium-install path resolves via the same cascade and passes `--ignore-scripts`, dropping the now-dead `npm install --workspaces=false` step; dropped a dead `import shutil` left over once it stopped calling `shutil.which` directly - `scripts/install.sh` / `scripts/install.ps1`: `ensure_browser()` / `Install-AgentBrowser` no longer npm-install `agent-browser` (camofox's install and system-browser detection are unaffected); `install.ps1` also drops the now-dead `-SkipChromium` switch - Tests: `tests/hermes_cli/test_cmd_update.py`, `test_doctor.py`, `test_doctor_live.py`, `test_dep_ensure.py`, `test_nous_subscription.py`, `test_tools_config.py`, `test_windows_subprocess_no_window_flags.py`, `tests/tools/test_browser_npx_warmup.py` (full rewrite for the `Popen`-based rework), `tests/tools/test_browser_homebrew_paths.py`, `tests/tools/test_browser_chromium_autoinstall.py`, `tests/test_install_sh_browser_install.py`, and new `tests/test_install_ps1_browser_install.py`: updated/extended throughout for the above - `tests-js/package-json-lazy-deps.test.ts`: contract updated for the new behavior (agent-browser and `@streamdown/math` must NOT be root dependencies; `@streamdown/math` must be in `apps/desktop`). This test lived at `tests/test_package_json_lazy_deps.py` until upstream ported it to vitest so it runs in the correct CI lane. Also adds a lockfile-level check (mirroring the existing camofox one) so a future regression that reintroduces `agent-browser` into `package-lock.json` fails this test directly. - Docs updated for the npx-based resolution (root `npm install` no longer installs agent-browser): - `website/docs/user-guide/features/browser.md`: agent-browser install instructions now npx-first - `website/docs/developer-guide/browser-provider-plugin.md`: fixed a stale comment claiming `post_setup: agent_browser` installs the npm dep - `CONTRIBUTING.md`: relabeled the two optional `npm install` steps (no longer \"browser tools\") - `website/docs/getting-started/termux.md`: split the manual Node-deps guidance by backend: cloud browser providers only need Node.js (npx resolves agent-browser lazily), but local browser automation on Termux needs a real `agent-browser` install since local mode rejects the bare npx fallback as too fragile ## How to Test 1. Run the targeted test suite: ``` scripts/run_tests.sh tests/hermes_cli/test_cmd_update.py tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_live.py tests/hermes_cli/test_dep_ensure.py tests/hermes_cli/test_nous_subscription.py tests/hermes_cli/test_tools_config.py tests/tools/test_browser_npx_warmup.py tests/tools/test_browser_homebrew_paths.py tests/tools/test_browser_chromium_autoinstall.py tests/test_windows_subprocess_no_window_flags.py tests/test_install_sh_browser_install.py tests/test_install_ps1_browser_install.py ``` All tests should pass. The package.json invariant tests are now vitest (they were ported upstream to run in the JS CI lane): `npm run --prefix tests-js check`. 2. To verify the regression fix manually: run `hermes update`, then confirm `hermes doctor` no longer reports `agent-browser` missing (it now reports \"resolves via npx on first use\"). 3. To verify the npx warm-up: run `hermes doctor --fix` and confirm it reports warming the npx cache for agent-browser. 4. To verify root devDependencies survive: run `hermes update`, then confirm `node_modules/@eslint`/`node_modules/typescript-eslint` etc. are still present at repo root. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `scripts/run_tests.sh` and all targeted tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) (or N/A) - [x] I've updated `cli-config.yaml.example` if I added/changed config keys (or N/A) - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows (or N/A) - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) (or N/A) - [x] I've updated tool descriptions/schemas if I changed tool behavior (or N/A)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/44772",
        "createdAt": "2026-06-12T08:22:34Z",
        "updatedAt": "2026-08-13T17:58:27Z",
        "timestamp": "2026-08-13T17:58:27Z",
        "metrics": {
          "reactions": 0,
          "comments": 27
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "tool/browser",
          "P2",
          "sweeper:risk-compatibility",
          "sweeper:risk-platform-windows",
          "sweeper:blast-moderate",
          "comp/desktop",
          "platform/windows",
          "area/install-update"
        ],
        "author": "zakame",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:44878",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(agent): per-call output speed (tokens/sec) for desktop status bar and runtime footer",
        "text": "## Summary Stores per-API-call timing and token counts on the agent so downstream consumers (desktop status bar, gateway runtime footer, CLI status bar) can display real-time tokens-per-second. - **run_agent.py**: Initialize `last_api_duration` / `last_output_tokens` on agent - **agent/conversation_loop.py**: Persist per-call `api_duration` + `output_tokens` after each successful API response - **tui_gateway/server.py**: Compute `output_speed` in `_get_usage()` (read by desktop `session.info` events and `session.usage` RPC) - **cli.py**: Expose `output_speed` in TUI status bar when `display.show_output_speed` is enabled - **gateway/runtime_footer.py**: Add `tps` field for gateway platforms (messaging, desktop response footer) - **gateway/run.py**: Wire per-call stats into agent result + footer call site - **hermes_cli/config.py**: Add `display.show_output_speed` toggle (default off) ## Validation - Verified end-to-end: `conversation_loop.py` SETs `last_api_duration` and `last_output_tokens` (confirmed via debug log) - `tui_gateway/server.py` `_get_usage()` reads them and emits `output_speed` (confirmed 48.0 tok/s for DeepSeek v4 Pro) - Desktop frontend already has `output-speed` status bar item (needs rebuild to display) Closes #9527 Closes #26877",
        "url": "https://github.com/NousResearch/hermes-agent/pull/44878",
        "createdAt": "2026-06-12T11:50:47Z",
        "updatedAt": "2026-08-13T13:18:47Z",
        "timestamp": "2026-08-13T13:18:47Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/cli",
          "comp/gateway",
          "P3",
          "sweeper:risk-compatibility",
          "sweeper:blast-broad"
        ],
        "author": "beresfordsalmon",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:46057",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cli): bound operator quick-command execution",
        "text": "## Summary This PR rescopes the classic CLI quick-command hardening to the execution-safety boundary tracked by #46056. - Runs `quick_commands.<name>.type = exec` through a bounded `Popen` wrapper. - Closes stdin, preserves `TERMINAL_CWD` and the sanitized environment, caps stdout/stderr, terminates the process tree on timeout, hides Windows console windows, and redacts output. - Keeps the documented operator-authored shell-command contract; detector-based blocking is intentionally not part of this change. - Leaves shell/argv compatibility and documentation migration as a separate follow-up (see #59506). ## Validation - `scripts/run_tests.sh tests/cli/test_cli_quick_commands.py tests/test_windows_subprocess_no_window_flags.py -q`: 17 passed, 3 skipped. - Ruff and `py_compile` passed. - `git diff --check` passed. - `git merge-tree --write-tree origin/main HEAD` passed. Fixes #46056",
        "url": "https://github.com/NousResearch/hermes-agent/pull/46057",
        "timestamp": "2026-08-13T12:26:42Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P2",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:risk-platform-windows",
          "sweeper:blast-moderate"
        ],
        "author": "egilewski",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:46504",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(hindsight): surface missing hindsight-all in local_embedded (#7718)",
        "text": "## Summary `local_embedded` imports the top-level `hindsight` module (from the `hindsight-all` package), but `plugin.yaml` only declares `hindsight-client`. Users who configure `local_embedded` by hand — or upgrade from the legacy `\"mode\": \"local\"` — never run the setup wizard that installs `hindsight-all`, so long-term memory is **silently disabled** behind a non-actionable `No module named 'hindsight'` warning. The agent-startup path (`agent/agent_init.py`) gates the provider on `is_available()` and drops it with a silent `logger.debug` **before** `initialize()` runs — so the actionable message has to live where the provider is actually dropped. ## Changes - **`is_available()`** — emits a one-time actionable warning naming `hindsight-all` + the install command when `local_embedded` is configured but the runtime package is missing. Stays silent for intentional graceful-degradation failures (e.g. NumPy CPU-baseline errors). - **`_get_client()`** — the `RuntimeError` raised when the runtime probe fails now carries the same actionable hint (the real raise point), with an `ImportError` backstop around the embedded import. - **`initialize()`** — actionable disable warning + a migration warning when the legacy `\"mode\": \"local\"` is remapped to `local_embedded`. - Shared `_embedded_runtime_hint()` / `_warn_missing_embedded_runtime_once()` helpers keep the messaging DRY and reason-gated. - **Docs** — README troubleshooting note + `plugin.yaml` comment explaining why `hindsight-all` is intentionally not a static dependency (avoids pulling the heavy embedded runtime for cloud-only users). ## Testing ``` scripts/run_tests.sh tests/plugins/memory/test_hindsight_provider.py # 109 passed, 0 failed ``` 7 new regression tests cover: `is_available()` warning + silence + once-per-process; `_get_client()` real-path `RuntimeError` and import backstop; `initialize()` disable + legacy-migration warnings. Fixes #7718",
        "url": "https://github.com/NousResearch/hermes-agent/pull/46504",
        "createdAt": "2026-06-15T06:15:14Z",
        "updatedAt": "2026-08-13T17:46:14Z",
        "timestamp": "2026-08-13T17:46:14Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "tool/memory",
          "P3",
          "sweeper:blast-contained",
          "area/memory"
        ],
        "author": "bionicbutterfly13",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:48567",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix: restore provider-backed memory tools for cron",
        "text": "## Summary Rebased and repaired the cron external-memory fix on current `main`. ## Behavior - Cron keeps the built-in `memory` tool and local `MEMORY.md`/`USER.md` surface disabled. - A job explicitly configured with the raw `memory` toolset receives only external-provider tools (for example `hindsight_recall`, `hindsight_retain`, and `hindsight_reflect`). - `MemoryManager(mode=\"tools\")` centrally disables automatic provider prompt injection, prefetch, sync/retain, session-boundary hooks, compression hooks, built-in-memory mirroring, and delegation hooks. - An administrator `agent.disabled_toolsets: [memory]` still overrides a job opt-in. ## Verification - Focused regression suite: `133 passed` - Real isolated `AIAgent` and `cron.scheduler.run_job` integration probes confirmed tools-only Hindsight schemas are present, built-in memory is absent, local memory store is `None`, and no provider system-prompt block is injected. This refresh replaces the stale prior branch head and is based on current upstream `main` (`03fa32c92`).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/48567",
        "createdAt": "2026-06-18T18:07:50Z",
        "updatedAt": "2026-08-13T15:52:31Z",
        "timestamp": "2026-08-13T15:52:31Z",
        "metrics": {
          "reactions": 0,
          "comments": 6
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/cron",
          "comp/plugins",
          "tool/memory",
          "P2",
          "sweeper:risk-session-state",
          "sweeper:risk-security-boundary",
          "sweeper:blast-moderate",
          "area/memory"
        ],
        "author": "GhastlyJack",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:50456",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(web): add web-native-fetch - local HTTP extract provider (no API key)",
        "text": "## Summary Implements the **native extract provider** concept first discussed in issue #19198 — a zero-dependency local HTTP fetcher with readability-lxml main-content extraction. No API keys required. ## What it does - **New plugin** `plugins/web/native` — HTTP GET via `httpx`, content extraction via `readability-lxml`, markdown conversion via `html2text`. Extract-only (`supports_search() -> False`), pair with any search provider (e.g. `ddgs`, `searxng`) - **Infrastructure fix** — `_is_backend_available()` in `tools/web_tools.py` now falls back to checking the web provider registry, enabling custom/user plugins to pass the availability gate. `_ensure_web_plugins_loaded()` moved before `_get_extract_backend()` so plugins are loaded during backend selection - **Dependencies** — `[native-fetch]` extra in `pyproject.toml` pulls in `readability-lxml` and `html2text` ## Usage ```yaml web: search_backend: ddgs extract_backend: native ``` ## Testing - Provider unit tests included - Verified end-to-end: `web_extract` successfully fetches and parses pages via the plugin - All existing web tool tests pass",
        "url": "https://github.com/NousResearch/hermes-agent/pull/50456",
        "createdAt": "2026-06-21T22:49:24Z",
        "updatedAt": "2026-08-13T17:37:35Z",
        "timestamp": "2026-08-13T17:37:35Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/feature",
          "comp/plugins",
          "tool/web",
          "P3",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "VHSgunzo",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:51466",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(optional-mcps): add Apple macOS MCP servers (mail, notes, numbers, photos)",
        "text": "## What does this PR do? Adds four macOS MCP servers to the `optional-mcps/` catalog so Hermes users can install them with `hermes mcp install <name>`: - **apple-mail** — read, search, send, reply, forward, and organize Apple Mail - **apple-notes** — create, search, read, update, and organize Apple Notes - **apple-numbers** — read, write, search, and format Apple Numbers (`.numbers`) spreadsheets - **apple-photos** — query, search, export, and inspect the macOS Photos library All four are published, MIT-licensed npm packages maintained under [github.com/sweetrb](https://github.com/sweetrb), with CI on `macos-latest` (Node 20 + 22). Each manifest is a plain **stdio** entry that launches the published package via `npx -y <pkg>` — no `install`/clone step (per the npm/uvx note in the n8n manifest) and `auth: none` (everything is local). They already support Hermes today via `hermes mcp add`; this just makes them one-command installable from the catalog. ## Related Issue None — new catalog entries. ## Type of Change - [x] ✨ New feature (non-breaking change that adds functionality) ## Changes Made - `optional-mcps/apple-mail/manifest.yaml` - `optional-mcps/apple-notes/manifest.yaml` - `optional-mcps/apple-numbers/manifest.yaml` - `optional-mcps/apple-photos/manifest.yaml` ## How to Test On **macOS**: 1. `hermes mcp install apple-notes` (or `apple-mail` / `apple-numbers` / `apple-photos`) 2. Start a new Hermes session. 3. The server launches via `npx -y apple-<app>-mcp`. First use prompts for macOS Automation access (AppleScript); `apple-photos` additionally needs Full Disk Access. Tools then load (e.g. `apple-notes`: `create-note`, `search-notes`, `list-notes`, …). **Platforms tested:** macOS (each server's own CI runs on `macos-latest`, Node 20 + 22). `apple-numbers` and `apple-photos` use a Python 3.11+ sidecar (`numbers-parser` / `osxphotos`) that bootstraps a local venv on first run. ## Notes - These are **macOS-only** by nature (AppleScript / the macOS Photos library), which each manifest's `post_install` calls out. - `apple-mail` exposes send/delete/move tools that act on the real mailbox; the install-time checklist lets users prune to a read-only surface. The others are read-mostly (`apple-photos` is read-only except `export`). Happy to adjust naming, descriptions, or split into separate PRs if you'd prefer.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/51466",
        "createdAt": "2026-06-23T17:36:56Z",
        "updatedAt": "2026-08-13T13:04:16Z",
        "timestamp": "2026-08-13T13:04:16Z",
        "metrics": {
          "reactions": 0,
          "comments": 16
        },
        "labels": [
          "type/feature",
          "tool/mcp",
          "P3",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:blast-contained"
        ],
        "author": "sweetrb",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:51849",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): render reasoning effort as a distinct badge in the model menu (#51833)",
        "text": "## Summary The desktop model dropdown no longer makes a model look duplicated when a reasoning effort is set. The effort (and \"Fast\") now renders as a distinct badge chip beside the model name instead of plain grey text appended to it. Fixes #51833. **Root cause:** in `model-menu-panel.tsx` each row built a single `meta` string (`Fast High`, `Med`, …) and rendered it *inside the same truncating span as the model name*, separated only by a leading space and a `text-(--ui-text-tertiary)` color: ```tsx <span className=\"min-w-0 flex-1 truncate\"> {name} {meta ? <span className=\"text-(--ui-text-tertiary)\"> {meta}</span> : null} </span> ``` With `agent.reasoning_effort: high`, a row reads `Qwen3.7 Max High` — the lighter grey isn't enough separation, so users (per the report) read it as a second, differently-named model rather than the same model carrying a \"High\" reasoning setting. ## Changes - `apps/desktop/src/app/shell/model-menu-panel.tsx`: build the fast/effort meta as a `metaTags` array (instead of a joined string) and render each as a discrete bordered badge chip (`rounded-sm border` + uppercase micro-label) beside the model name. The name now lives in its own `truncate` span so a long name still clips without swallowing the badges. No change to *which* tags show or how effort/fast are computed — purely how they're presented. - `apps/desktop/src/app/shell/model-menu-panel.test.tsx`: new regression test — the effort renders in its own bordered badge element (not concatenated into the name node), and the badge is dropped entirely for models without reasoning support. ## Validation | | Before | After | |---|---|---| | `reasoning_effort: high` row | `Qwen3.7 Max High` — grey text on the name, reads as a duplicate model | `Qwen3.7 Max` + a separate `HIGH` badge chip | | Non-reasoning model | trailing `Med` text | no badge | | Long model name | name + meta share one truncating span | name truncates on its own; badge stays visible | - `npx vitest run --environment jsdom src/app/shell/model-menu-panel.test.tsx`: 2/2 passed - Related suites (`model-status-label`, `model-edit-submenu`): 13/13 passed - `npm run typecheck` (apps/desktop): clean - `eslint` on both changed files: clean",
        "url": "https://github.com/NousResearch/hermes-agent/pull/51849",
        "createdAt": "2026-06-24T10:58:05Z",
        "updatedAt": "2026-08-13T15:21:32Z",
        "timestamp": "2026-08-13T15:21:32Z",
        "metrics": {
          "reactions": 0,
          "comments": 5
        },
        "labels": [
          "type/bug",
          "duplicate",
          "P3",
          "sweeper:blast-moderate",
          "comp/desktop"
        ],
        "author": "PRATHAMESH75",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:52000",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(execute_code): forward session_id to nested tool calls via RPC",
        "text": "## What does this PR do? - `_rpc_server_loop` and `_rpc_poll_loop` dispatched nested tool calls (e.g. `read_file` invoked by `execute_code`) via `handle_function_call` without passing `session_id`. Plugin hooks `on_pre_tool_call` / `on_post_tool_call` therefore saw an empty `session_id` and could not correlate nested calls with the originating turn. - Follow-up for review feedback: the public registry path now preserves the explicit dispatcher contract too. `registry.dispatch(\"execute_code\", ..., session_id=\"...\")` forwards that value through the `execute_code` registry handler instead of dropping it. - `execute_code()` now accepts an explicit `session_id`, forwards it into both local (UDS/TCP) and remote (file-based) sandbox RPC paths, and only falls back to the legacy session context when no explicit session id is supplied. - Current main's per-session `rpc_token` authorization remains intact in both loop signatures, thread arguments, request payloads, and constant-time validation. - Added regression tests for the registry-dispatch contract, local RPC forwarding, remote RPC forwarding, and default empty-string compatibility. ## Related Issue Fixes #51931 ## Type of Change - [x] Bug fix - [x] Tests ## Changes Made - `tools/code_execution_tool.py`: threads explicit `session_id` through the registry handler and both nested RPC transports while preserving current `rpc_token` authorization. - `tests/tools/test_code_execution.py`: covers registry and local RPC forwarding. - `tests/tools/test_code_execution_modes.py`: covers remote file-RPC forwarding and the compatibility default. - `tests/tools/test_approved_command_clean_slate.py`: keeps the existing remote-dispatch regression stub aligned with the extended `_execute_remote` signature. ## How to Test - [x] Rebased onto exact `origin/main` `07be37d99`; local ancestry and diff checks are clean - [x] `scripts/check.sh --project hermes-agent --worktree worktrees/hermes-agent/51931` - all blocking gates passed - [x] Changed tests via `scripts/run_tests.sh`: 123/123 passed across all three changed test files - [x] `TestRpcSessionIdForwarding` - 4/4 passed - [x] Base-regression proof: all 4 final regressions fail on exact `origin/main` and pass on this branch - [x] Repository-wide Ruff and `uv lock --check` - clean - [x] `ty check` remains advisory: focused ty output only shows existing diagnostics in the touched file/tool area; full-project ty still panics on pre-existing `tools/checkpoint_manager.py`, outside this change ## Checklist - [x] The PR is limited to session forwarding and its regression tests. - [x] Current-main RPC-token authorization is preserved. - [x] All blocking project checks pass on the rebased branch. - [x] No dependency, lockfile, configuration, or documentation change is required.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/52000",
        "timestamp": "2026-08-13T11:59:54Z",
        "metrics": {
          "reactions": 0,
          "comments": 8
        },
        "labels": [
          "type/bug",
          "comp/tools",
          "tool/code-exec",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:blast-moderate",
          "area/sessions"
        ],
        "author": "Christopher-Schulze",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:52124",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "test(lazy-deps): cover _venv_pip_install tiers, prompt paths, and ensure_and_bind for 100% coverage",
        "text": "## What does this PR do? Adds comprehensive, network-free unit coverage for the lazy dependency install and binding paths in `tools/lazy_deps.py`. The branch has been reconciled with every newer upstream test in the same file; the final test-only diff retains both upstream coverage and this PR's fallback matrix. ## Related Issue Fixes #36565 ## Type of Change - [x] ✅ Tests (adding or improving test coverage) ## Changes Made - Cover specifier extraction and metadata/import fallbacks. - Cover presence checks and every uv/pip/ensurepip install tier, including timeout and exception paths. - Cover unsafe-spec rejection and interactive prompt outcomes. - Cover cache refresh failures and `ensure_and_bind` success/failure boundaries. - Keep all subprocess, filesystem, prompt, and import interactions isolated; no test performs a real install or network call. ## How to Test ```bash scripts/run_tests.sh tests/tools/test_lazy_deps.py ``` Final result on rebased head `700033c0f`: 106/106 passed. The full project harness also passes all blocking gates. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit message follows Conventional Commits - [x] I searched for existing PRs - [x] The PR is test-only and scoped to `tools/lazy_deps.py` - [x] I ran the changed test file through the canonical wrapper - [x] The tests are deterministic and fail on the uncovered/broken paths they assert - [x] Tested on macOS (Apple Silicon), Python 3.11 ### Documentation & Housekeeping - [x] Documentation: N/A - [x] `cli-config.yaml.example`: N/A - [x] `CONTRIBUTING.md` / `AGENTS.md`: N/A - [x] Cross-platform impact reviewed: mocked platform/process boundaries - [x] Tool schema update: N/A ## Screenshots / Logs `scripts/check.sh --project hermes-agent --worktree worktrees/hermes-agent/36565` passes all blocking gates.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/52124",
        "timestamp": "2026-08-13T11:59:55Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/test",
          "comp/tools",
          "P3",
          "sweeper:risk-automation",
          "sweeper:blast-contained"
        ],
        "author": "Christopher-Schulze",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:52289",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): classify provider memory-ceiling 400s as overloaded, not context_overflow",
        "text": "## What does this PR do? Local-inference providers (oMLX / MLX with a memory guard, and similar Metal/CUDA setups) abort a request when the **prefill memory peak** exceeds a GPU/unified-memory ceiling. Their rejection text often suggests \"reduce context length\" / \"reduce context size\", which collides with the context-overflow patterns — so Hermes classifies a *memory-ceiling* 400 as `context_overflow` and routes it into the compress → shrink-context → retry loop. Compression cannot lower a prefill memory peak (the conversation is tiny — ~5.7k tokens in the report), so it exhausts `max_compression_attempts`, the compression call itself re-hits the wedged server, and the loop ends in \"Cannot compress further\" → destructive session reset. This PR adds a `_MEMORY_CEILING_PATTERNS` check that runs **before** the context-overflow check at both classification sites (`_classify_400` and the no-status `_classify_by_message` streaming path), classifying these as `FailoverReason.overloaded` (transient, retry-with-backoff, no compression, no reset) — the same \"checked BEFORE context_overflow\" guard pattern already used for multimodal / image-too-large / request-validation 400s. `overloaded` mirrors the existing 503/529 recovery: `retryable=True`, `should_compress` defaults to `False`, and it is in the retryable set so the loop never enters the client-error abort/reset path. ## Related Issue Fixes #52261 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `agent/error_classifier.py`: add `_MEMORY_CEILING_PATTERNS`; guard both `_classify_400` and `_classify_by_message` before the context-overflow check. - `tests/agent/test_error_classifier.py`: three positive cases (the three verbatim provider wordings, across the 400-status and no-status streaming paths) + one negative invariant case proving a genuine context-window overflow still routes to `context_overflow` + compression. ## How to Test 1. `uv run --with pytest --with pytest-asyncio python3 -m pytest tests/agent/test_error_classifier.py -v` 2. Regression guard (fail-before / pass-after): with the production hunk in `error_classifier.py` reverted, the three positive tests fail (memory-ceiling 400s classify as `context_overflow`/`unknown`); with the hunk restored, all four pass. The negative case (`test_400_genuine_context_window_overflow_still_compresses`) passes in both states, proving the guard does not swallow real context overflows. 3. Full file: 165 passed. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (pure string-classification logic — platform-independent) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## Contract Protected **Invariant:** a provider **memory/resource-ceiling** rejection never enters the compress-and-shrink-context path, regardless of whether it arrives with an HTTP 400 or as a no-status streaming `APIError`. - **Known-bad inputs (now covered):** the three captured oMLX/MLX wordings — \"oMLX prefill memory guard rejected … dynamic ceiling is 13.50 GB … reduce context length\", \"process memory limit exceeded … Reduce context size or lower memory_guard_tier\", and the no-status \"Prefill context too large for available memory\". - **Future-input coverage:** `_MEMORY_CEILING_PATTERNS` keys on memory/allocation/ceiling/guard wording (OOM, llama.cpp/vLLM, Metal/CUDA), disjoint from token/window-count language. - **Negative case:** a genuine `maximum context length … reduce the length` 400 still routes to `context_overflow` + compression. > Sibling follow-up (intentionally out of scope to keep the diff small): a > dedicated `FailoverReason.resource_exhausted` reason — instead of reusing > `overloaded` — would let callers surface a clearer \"free memory / raise the > guard ceiling\" message. Happy to widen if preferred.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/52289",
        "createdAt": "2026-06-25T03:31:44Z",
        "updatedAt": "2026-08-13T15:52:50Z",
        "timestamp": "2026-08-13T15:52:50Z",
        "metrics": {
          "reactions": 1,
          "comments": 9
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "backend/local",
          "P2",
          "sweeper:risk-session-state",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate",
          "area/memory"
        ],
        "author": "briandevans",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:54737",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cli): bound generic model catalog response reads",
        "text": "## Summary - bound the generic custom-provider `/models` probes at 4 MiB - preserve the existing `/models` -> `/v1/models` fallback sequence for oversized or malformed responses - keep the current credential-safe opener and also clamp bodies discarded by `urllib` while following redirects, so an intermediate 3xx response cannot bypass the final-response cap - retarget the regression tests to the current secure transport seam instead of mocking raw `urllib.request.urlopen` Part of #54838. ## Why this boundary This PR now owns the shared credential-safe bounded-read primitive and its generic CLI model-probe consumer. Both are required for the 4 MiB guarantee: bounding only the final catalog response would leave `urllib`'s discarded redirect-body read unbounded. The provider-only `ProviderProfile.fetch_models()` change is deliberately excluded. It remains the canonical contribution in #42930, including that PR's original author and 16 MiB policy. The remaining provider-specific catalog and pricing readers in `hermes_cli/models.py` remain in #54840 under #54838. ## Maintainer feedback addressed - preserved `open_credentialed_url` and its cross-origin credential stripping - applied the bound at the current secure transport call path - updated tests to exercise the bounded-reader seam and real redirect behavior - explicitly scoped the remaining catalog readers to #54840 ## Verification - `scripts/run_tests.sh tests/hermes_cli/test_urllib_security.py tests/hermes_cli/test_model_validation_response_size.py tests/hermes_cli/test_model_validation.py tests/hermes_cli/test_custom_provider_extra_headers.py -q` - 71 passed - Ruff passed for all changed Python files - `compileall` passed for all changed Python files - `git diff --check` passed - merge-tree against current `main` (`fa83af3f9a42790730b8966ff67e7d9fb627899f`) succeeded - structured Codex AutoReview: no actionable findings (`patch is correct`, confidence 0.96) ## Deliberately excluded - `ProviderProfile.fetch_models()` - #42930 / #54735 - provider-specific catalog and pricing readers - #54840 / #54838 - manifest loading - #54793 - slow-drip deadlines, retries, decompression, cache policy, and model-selection changes",
        "url": "https://github.com/NousResearch/hermes-agent/pull/54737",
        "createdAt": "2026-06-29T08:19:18Z",
        "updatedAt": "2026-08-13T15:24:54Z",
        "timestamp": "2026-08-13T15:24:54Z",
        "metrics": {
          "reactions": 0,
          "comments": 5
        },
        "labels": [
          "type/security",
          "comp/cli",
          "area/config",
          "P2",
          "sweeper:blast-moderate"
        ],
        "author": "ooiuuii",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:55042",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(matrix): stream inbound media under cap",
        "text": "﻿Fixes #55041. ## Summary - stream inbound Matrix `mxc://` media through `MATRIX_MAX_MEDIA_BYTES` instead of calling `mautrix.download_media()`'s unbounded `response.read()` path - reject oversized MXC media as soon as the streamed response crosses the cap, without forwarding the HTTP fallback downstream - keep the old `download_media()` path only for test/fake clients, with a defensive post-read size check - add regression tests for under-cap streaming and over-cap stream rejection ## Relation to Existing Work Related but not a duplicate of #52354: - #52354 gates pre-auth Matrix media downloads and checks the real downloaded byte length before cache/use. - This PR closes the remaining earlier boundary: avoid buffering an oversized media response before that length check can run. This mirrors the response-boundary class from openclaw/openclaw#97855, adapted to Hermes' Python `mautrix` client path. ## Duplicate Audit Live checks before opening: - Existing #55009/#55011 covers Matrix standalone sender REST responses, not inbound MXC media downloads. - Existing #52354 covers media authorization and post-download real length checks, but still uses `download_media()`'s full-buffer read. - No open PR/issue found for inbound Matrix MXC streaming caps before #55041. ## Validation - `python -m pytest tests\\gateway\\test_matrix.py -q -k \"MediaNormalization or mxc_media\" --basetemp .pytest-tmp-matrix-inbound-media-cap` (`17 passed, 218 deselected`) - `python -m ruff check plugins\\platforms\\matrix\\adapter.py tests\\gateway\\test_matrix.py` (passed) - `git diff --check` (passed) I also ran the full `tests\\gateway\\test_matrix.py` on Windows: `233 passed, 2 failed`. The two failures are pre-existing on untouched `origin/main` in this environment: POSIX `0600` mode assertion on Windows and default-GBK decoding of `website/docs/user-guide/messaging/matrix.md`.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/55042",
        "createdAt": "2026-06-29T17:30:28Z",
        "updatedAt": "2026-08-13T16:08:10Z",
        "timestamp": "2026-08-13T16:08:10Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/security",
          "platform/matrix",
          "P2",
          "sweeper:risk-message-delivery",
          "sweeper:risk-security-boundary",
          "sweeper:blast-moderate",
          "area/streaming"
        ],
        "author": "ooiuuii",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:55063",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "Cap Teams attachment downloads",
        "text": "## Summary - stream existing Teams attachment downloads through the shared inbound-media size limiter - follow redirects manually so every target is SSRF-validated and every intermediate response is closed without reading its body - cap declared, lengthless, and decoded compressed final bodies before aggregation Fixes #55061. ## Why this boundary - This PR owns the existing generic Teams attachment download lifecycle. - It is independently correct and revertible: its two current consumers become bounded without changing authentication or routing. - Authenticated pasted-image handling has separate token, retry, origin, and cache policy; #72977 is the preferred follow-up and should reuse this foundation after it lands. ## Dependency and merge order - Depends on: none - Merge order: #55063 first; then refresh #72977 on current `main` and retain only its authenticated-image changes. ## Deliberately excluded - Bot Framework bearer acquisition, token caching, protected inline-image routing, retries, Graph downloads, cache policy, or global HTTPX/decompression redesign ## Series status - [x] #55063 — generic Teams bounded-download foundation - [ ] #72977 — authenticated pasted-image consumer ## Validation - six related test files — 113 passed - Ruff, py_compile, and `git diff --check` — passed - structured AutoReview round 1 found the automatic-redirect body bypass; fixed in the same lifecycle - structured AutoReview round 2 — no findings; patch correct (0.98) The redirect regression uses a safe 302 with a 1 MiB body and proves that zero redirect-body chunks are read, the final attachment is returned, and both response streams are closed.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/55063",
        "timestamp": "2026-08-13T11:15:26Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "P3",
          "sweeper:risk-security-boundary",
          "sweeper:blast-moderate"
        ],
        "author": "ooiuuii",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:55149",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): restrict write approval toggles to admins",
        "text": "## Summary Fixes #55147. Gateway `/memory` and `/skills` can be allowlisted for non-admin slash users so they can review pending writes, but `approval on|off` mutates global `memory.write_approval` / `skills.write_approval` in `config.yaml`. This patch adds a shared gateway-side subcommand guard: - `/memory approval` and `/skills approval` with no value still show current state - `/memory approval on|off` and `/skills approval on|off` require slash admin status when slash access gating is enabled - legacy installs without `allow_admin_from` keep the previous unrestricted behavior - CLI behavior is unchanged because local CLI use is already operator-side ## Testing - `python -m pytest tests\\gateway\\test_slash_access_dispatch.py -q -k \"approval_toggle or whoami_non_admin_lists_runnable_commands\"` (`5 passed`) - `python -m pytest tests\\gateway\\test_slash_access_dispatch.py -q -k \"not quick_command\"` (`22 passed, 3 deselected`; the deselected quick-command tests use POSIX `printf`, which is an existing Windows-only test portability issue) - `python -m pytest tests\\gateway\\test_slash_access.py -q` (`21 passed`) - `ruff check gateway\\slash_commands.py tests\\gateway\\test_slash_access_dispatch.py` - `git diff --check`",
        "url": "https://github.com/NousResearch/hermes-agent/pull/55149",
        "createdAt": "2026-06-29T20:14:59Z",
        "updatedAt": "2026-08-13T16:07:01Z",
        "timestamp": "2026-08-13T16:07:01Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/security",
          "comp/gateway",
          "area/auth",
          "P2",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "ooiuuii",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:56929",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(guardrails): reset failure and no-progress counts on successful file mutation",
        "text": "Currently, if the agent runs a terminal command (like a unit test runner) that fails, and then successfully modifies files to fix the issue (using `patch` or `write_file`), the terminal failure counts are not reset. After `hard_stop_after.exact_failure` (default: 5) attempts, even with different code patches in between, the guardrail blocks the terminal command as a duplicate failure. This PR fixes this issue by resetting the guardrail counters whenever a successful file mutation lands (detected via `file_mutation_result_landed` helper).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/56929",
        "createdAt": "2026-07-02T08:48:39Z",
        "updatedAt": "2026-08-13T13:35:51Z",
        "timestamp": "2026-08-13T13:35:51Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P2",
          "sweeper:risk-session-state",
          "sweeper:blast-contained"
        ],
        "author": "dichvuhuuich",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:57674",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(google-chat): scope service-account env per profile",
        "text": "## What does this PR do? Routes Google Chat's profile-scoped service-account and platform env reads through `agent.secret_scope.get_secret()` so multiplex gateways do not pick up credentials or routing config from another profile's process environment. This is a sibling hardening follow-up to #56680 (Vertex `GOOGLE_APPLICATION_CREDENTIALS` isolation) and the same credential-isolation class as #57624 for cloud browser providers. In multiplex mode, `os.environ` is process-global while the active profile boundary is `agent.secret_scope`; direct env reads can therefore authenticate or route a secondary profile with another profile's Google Chat service account/project/subscription. ## Related Issue Follow-up sibling hardening for #56680. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [x] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `plugins/platforms/google_chat/adapter.py` - Uses `get_secret()` for Google Chat service-account credentials and profile-scoped platform config: - `GOOGLE_CHAT_SERVICE_ACCOUNT_JSON` - `GOOGLE_APPLICATION_CREDENTIALS` - `GOOGLE_CHAT_PROJECT_ID` / `GOOGLE_CLOUD_PROJECT` - `GOOGLE_CHAT_SUBSCRIPTION_NAME` / `GOOGLE_CHAT_SUBSCRIPTION` - `GOOGLE_CHAT_HOME_CHANNEL*` - `GOOGLE_CHAT_HTTP_EVENTS_URL` - `GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE` - `GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL` - Applies the HTTP-events scope at env enablement, registry detection, and adapter construction so no sibling path can re-read another profile's process environment. - Keeps single-profile behavior unchanged because `get_secret()` falls back to `os.environ` outside multiplex secret-scope enforcement. - Adds an ADC guard mirroring the Vertex hardening: in multiplex mode, do not call `google.auth.default()` when process-global Google Chat credentials are present but this profile has no scoped value. - `tests/plugins/platforms/google_chat/test_secret_scope.py` - Covers scoped Google Chat env enablement winning over foreign process env. - Covers fail-closed behavior for unscoped multiplex env reads. - Covers HTTP-events registry detection and adapter fallback with an empty or scoped profile boundary. - Covers `_load_sa_credentials()` using scoped inline service-account JSON over foreign process env. ## How to Test Focused regression tests: ```bash uv sync --extra dev --extra google scripts/run_tests.sh \\ tests/plugins/platforms/google_chat/test_secret_scope.py \\ tests/gateway/test_google_chat.py -q ``` Lint changed files: ```bash uv run --frozen --extra dev ruff check \\ plugins/platforms/google_chat/adapter.py \\ tests/plugins/platforms/google_chat/test_secret_scope.py ``` Type-check command run on changed files: ```bash uv run --frozen --extra dev ty check \\ plugins/platforms/google_chat/adapter.py \\ tests/plugins/platforms/google_chat/test_secret_scope.py ``` Observed locally: - Google Chat focused and related tests: 185 passed - Ruff: all checks passed - Ty: failed on pre-existing Google Chat optional-dependency / adapter typing diagnostics in `plugins/platforms/google_chat/adapter.py` (unresolved optional imports, existing override signatures, existing `Any | None` adapter fields). No new test-file diagnostic remained after updating the test instance construction. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Ubuntu 24.04 / Linux ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## Screenshots / Logs Focused tests: ```text 8 focused secret-scope tests passed 177 Google Chat gateway tests passed ``` Ruff: ```text All checks passed! ``` Duplicate search: ```text \"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON\" \"secret_scope\" \"Google Chat\" \"GOOGLE_APPLICATION_CREDENTIALS\" \"multiplex\" \"google_chat\" \"profile secret\" ``` No open duplicate PR found.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/57674",
        "createdAt": "2026-07-03T10:51:30Z",
        "updatedAt": "2026-08-13T15:51:33Z",
        "timestamp": "2026-08-13T15:51:33Z",
        "metrics": {
          "reactions": 0,
          "comments": 8
        },
        "labels": [
          "type/security",
          "comp/plugins",
          "area/auth",
          "P2",
          "sweeper:risk-message-delivery",
          "sweeper:risk-security-boundary",
          "sweeper:blast-moderate",
          "area/profiles"
        ],
        "author": "rayjun",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:5838",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(memory): add sync_recall option for current-turn relevance",
        "text": "## Summary Closes #5820. Adds a `memory.sync_recall: true` config option that switches memory providers from background prefetch to synchronous recall at turn start. ## Problem The current model queues a background recall at turn end using that turn's query. The next turn consumes this result — which is based on the *previous* message. When topics change between turns the injected context is irrelevant or misleading. ## Solution When `sync_recall: true`, each turn performs a live recall with the actual current message before the first LLM call. Background prefetch is skipped entirely at turn end. Default is `false` — no behavior change for existing users. ## Implementation `MemoryProvider.recall_sync()` default: fires `queue_prefetch()` then immediately calls `prefetch()`, which joins the background thread. This works for all providers that join inside `prefetch()` (honcho, hindsight, mem0, openviking). RetainDB overrides `recall_sync()` directly because its `prefetch()` reads shared state without joining threads. 4 files changed, 54 lines added. ## Config ```yaml memory: provider: hindsight # or honcho, mem0, etc. sync_recall: true",
        "url": "https://github.com/NousResearch/hermes-agent/pull/5838",
        "createdAt": "2026-04-07T12:42:20Z",
        "updatedAt": "2026-08-13T17:46:18Z",
        "timestamp": "2026-08-13T17:46:18Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/plugins",
          "tool/memory",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate",
          "area/memory"
        ],
        "author": "heathley",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:58687",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(update): honor configured bootstrap state",
        "text": "## What does this PR do? Update/bootstrap orchestration now respects existing configuration before running heavyweight browser dependency installs or interactive setup stages. ## Related Issue ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `hermes_cli/dep_ensure.py`: skip `install.sh --ensure browser` when the browser toolset is disabled via `agent.disabled_toolsets` or primary CLI toolset config. - `hermes_cli/main.py`: skip `hermes update` Node/browser/web refresh when the primary browser toolset is disabled, while preserving the existing Termux Python dependency path. - `scripts/install.sh`: make staged bootstrap manifests incremental by omitting setup/gateway stages when an existing config and credential file are present; non-interactive skipped stage JSON now reports the skip reason. - Tests cover disabled browser lazy ensure, update node-refresh skipping, and configured `install.sh --manifest --include-desktop` behavior. ## Shared root cause - Update/bootstrap orchestration treated optional dependency and setup stages as unconditional follow-up work after code/dependency refresh. - The shared fix is to consult the already-existing configuration surfaces before spawning heavyweight browser installers, Node/web refreshes, or interactive staged setup. ## How this fixes each issue - #37231: `hermes tools disable browser` now prevents lazy browser dependency ensure from spawning the browser installer, and `hermes update` skips Node/browser refresh when the browser toolset is disabled. - #37874: staged `install.sh --include-desktop` manifests omit setup/gateway stages for already configured installs, so desktop/bootstrap drivers do not rerun interactive setup work just to add the desktop build. - #39411: the update path keeps the existing Termux-specific Python dependency handling intact while preventing unrelated optional Node/browser stages from running unconditionally after Python dependency refresh. ## Supersedes N/A - no member PRs were provided for this spanning group. ## How to Test 1. `/opt/homebrew/bin/timeout -k 30 480 sh -c 'pytest tests/hermes_cli/test_dep_ensure.py tests/hermes_cli/test_cmd_update.py tests/test_install_sh_browser_install.py -q --timeout=60'` 2. `{ git diff --name-only --diff-filter=d origin/main; git ls-files --others --exclude-standard; } | grep -E '\\.pyi?$' | sort -u | xargs ruff check` 3. `bash -n scripts/install.sh && git diff --check` 4. `/opt/homebrew/bin/timeout -k 30 480 sh -c 'pytest tests/ -q -x --timeout=60 \"$@\"' sh` was attempted; collection aborted before changed tests because this local Python environment lacks `fastapi`, and lazy install failed under Homebrew's PEP 668 externally-managed environment. ## What platforms tested on - macOS on darwin-arm64 (local) ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched existing context/source for related update/bootstrap behavior - [x] My PR contains only changes related to this fix/feature - [ ] I've run `pytest tests/ -q` and all tests pass (attempted; blocked during collection by missing `fastapi`/PEP 668 local environment) - [x] I've added tests for my changes - [x] I've tested on my platform: macOS on darwin-arm64 ### Documentation & Housekeeping - [x] Documentation update N/A - [x] `cli-config.yaml.example` update N/A - [x] `CONTRIBUTING.md` / `AGENTS.md` update N/A - [x] Cross-platform impact considered - [x] Tool descriptions/schemas update N/A ## Screenshots / Logs Focused regression tests: 60 passed, 12 pre-existing warnings. --- _This coordinated PR bundles a fix that spans several issues. Happy to split it back into focused per-issue PRs if you'd prefer to review them separately._ Refs #37231 Refs #37874 Refs #39411 <!-- autocontrib:worker-id=pr-spanning-c406a0b5 kind=pr-open -->",
        "url": "https://github.com/NousResearch/hermes-agent/pull/58687",
        "createdAt": "2026-07-05T06:34:10Z",
        "updatedAt": "2026-08-13T13:17:20Z",
        "timestamp": "2026-08-13T13:17:20Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "area/config",
          "P3",
          "sweeper:risk-compatibility",
          "sweeper:blast-broad",
          "area/install-update"
        ],
        "author": "konsisumer",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:59162",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "Redact secrets in global log records",
        "text": "## Summary - Sanitize `LogRecord.msg` and `LogRecord.args` in Hermes' global record factory using the existing `agent.redact.redact_sensitive_text` path. - Walk string values inside tuple/list/dict logging args while leaving numeric values intact for `%d` and similar formatting. - Add plain `logging.Formatter` coverage for non-Hermes handlers attached to non-propagating loggers. Fixes #59061 ## Tests - `scripts/run_tests.sh tests/test_hermes_logging.py tests/agent/test_redact.py -q` - `uv run ruff check hermes_logging.py tests/test_hermes_logging.py`",
        "url": "https://github.com/NousResearch/hermes-agent/pull/59162",
        "createdAt": "2026-07-05T21:21:16Z",
        "updatedAt": "2026-08-13T16:06:13Z",
        "timestamp": "2026-08-13T16:06:13Z",
        "metrics": {
          "reactions": 0,
          "comments": 6
        },
        "labels": [
          "type/security",
          "comp/agent",
          "area/auth",
          "P2",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:blast-broad"
        ],
        "author": "doncazper",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:59233",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(delegate): cap total subagent children per parent session (#52484)",
        "text": "## What does this PR do? Adds a per-parent-session cap (`delegation.max_children_per_session`, default 10) on the total number of subagent children that `delegate_task` may spawn across all calls in a session. This directly addresses the \"Token Incinerator\" scenario in #52484, where a runaway parent spawned 44+ subagent sessions in 53 minutes with no throttling. The existing guards (`max_concurrent_children` for per-batch parallelism, `max_spawn_depth` for nesting depth) do not bound the **total** number of sessions a parent can create across turns. A model that repeatedly calls `delegate_task`, especially when the async pool falls back to synchronous execution, can spawn an unbounded number of independent sessions, each consuming tokens. **Why this approach:** The per-session total cap is the throttling mechanism the issue asks for. Once reached, further `delegate_task` calls return a clear error instead of spawning more sessions. It is additive and safe: existing users who never hit the default of 10 see no behavior change, and setting `0` disables it entirely. **Memory cleanup:** The module-level budget dict is cleaned up when sessions are deleted (`delete_session`, `delete_sessions`, `delete_empty_sessions`, `prune_sessions`) so it does not grow unbounded in long-running gateway processes. ## Related Issue Fixes #52484 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `tools/delegate_tool.py`: Added `_DEFAULT_MAX_CHILDREN_PER_SESSION`, per-session budget tracking (`_session_children_counts` + `_session_children_lock`), `_get_max_children_per_session()`, `_session_budget_key()`, `_reserve_session_children_budget()`, `_release_session_children_budget()`, and `cleanup_session_budget()`. The budget is reserved immediately before child construction and the complete reservation is released if construction fails before dispatch. Child construction uses main's shared `_build_child_preserving_parent_tools()` helper, preserving its lock and parent-tool restoration guarantees. The dynamic `delegate_task` schema description advertises the per-session cap without regressing current-main's compact concurrency/depth parameter guidance; disabled caps are explicit. - `hermes_cli/config_defaults.py`: Added `max_children_per_session: 10` to `DEFAULT_CONFIG[\"delegation\"]`. - `hermes_state.py`: `delete_session`, `delete_sessions`, `delete_empty_sessions`, and `prune_sessions` now call `cleanup_session_budget(session_id)` via lazy import + try/except to prevent unbounded memory growth. - `cli-config.yaml.example`: Documented the new `max_children_per_session` knob. - `website/docs/user-guide/configuration.md`, `website/docs/user-guide/features/delegation.md`, and `skills/autonomous-ai-agents/hermes-agent/references/configuration.md`: Documented the new limit, its independence from concurrency, and the `0` opt-out. - `tests/tools/test_delegate.py` and `tests/test_hermes_state.py`: Added regression coverage for repeated calls, batch accounting, build-failure rollback, budget cleanup, and nested cascade deletion. The schema description test now asserts \"SESSION BUDGET\" is present. - `tests/agent/test_subagent_stop_hook.py`: Gives each synthetic parent its own session ID so the session-total budget cannot leak between otherwise isolated tests. ## How to Test 1. **Regression proof:** Current `origin/main` has no per-session total budget; the focused regressions require repeated calls and batches to stop at the configured cap and require failed construction to release the complete reservation. 2. **Unit tests:** `tests/agent/test_subagent_stop_hook.py`, `tests/tools/test_delegate.py`, and `tests/test_hermes_state.py`: 255/255 pass on the final head `82abb090632eba082047481658e88ea31b6cb1ca`. 3. **Project gate:** `scripts/check.sh --project hermes-agent --worktree worktrees/hermes-agent/52484` passes all blocking gates on `main` `f5be9236e00ddf2f2a412697f267078fc4ee068e`; `uv lock --check`, Ruff, `git diff --check`, and the advisory ty comparison are clean with only the known upstream diagnostics/panic. 4. **Manual:** Set `delegation.max_children_per_session: 2` in config.yaml, call `delegate_task` 3 times in the same session: the 3rd call returns a clear error mentioning the cap. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] N/A locally for the full `pytest tests/ -q` suite; the focused 255-test suite and all local blocking gates pass, and the full suite runs in GitHub CI - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings), or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys - [x] N/A: this change does not modify contributor architecture or workflows - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility), or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior ## For New Skills N/A ## Screenshots / Logs N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/59233",
        "timestamp": "2026-08-13T11:59:53Z",
        "metrics": {
          "reactions": 0,
          "comments": 8
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "tool/delegate",
          "P2",
          "sweeper:risk-session-state",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate",
          "area/sessions"
        ],
        "author": "Christopher-Schulze",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:61166",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(langfuse): use update_trace so turn Input/Output columns fill",
        "text": "## Bug Description Self-hosted Langfuse list view shows many `Hermes turn` rows with **blank Input/Output** while Observation Levels and Latency are still populated. Drilling in shows GENERATION/TOOL observations but **no CHAIN root**. ## Root Cause Langfuse Python SDK **v3** exposes `span.update_trace()`, not `set_trace_io()`. `_finish_trace` called the missing method inside a broad `try`: ```python state.root_span.set_trace_io(output=final_output) # AttributeError state.root_span.update(output=final_output) # never reached state.root_span.end() # never reached ``` The exception was swallowed by fail-open logging. Child generations/tools had already called `end()`, so they still exported — producing the half-empty list UI. ## Fix - Use `update_trace()` for TRACE-level Input/Output - Isolate `update_trace` / `update` failures so `end()` always runs - Last-chance `end()` if the outer try still fails - Tests mock the real SDK method; add regression coverage ## How to Verify 1. With `langfuse==3.15.0` and the plugin enabled, run any multi-step turn. 2. Langfuse list row should show Input + Output text, not empty cells. 3. Trace detail should include a `CHAIN` observation named `Hermes turn`. ## Test Plan - [x] `pytest tests/plugins/test_langfuse_plugin.py` — 50 passed - [x] Live repro: AttributeError on `set_trace_io`; `update_trace` works - [x] Manual diagnostic traces against self-hosted Langfuse confirm CHAIN+I/O when root ends ## Risk Assessment **Low** — observability plugin only; fail-open behavior preserved; `end()` is more defensive than before.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/61166",
        "createdAt": "2026-07-08T23:00:46Z",
        "updatedAt": "2026-08-13T17:40:57Z",
        "timestamp": "2026-08-13T17:40:57Z",
        "metrics": {
          "reactions": 1,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "P3",
          "sweeper:risk-compatibility",
          "sweeper:blast-contained"
        ],
        "author": "Per0-1",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:61882",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(security): fail closed when terminal config bridge is unavailable",
        "text": "Closes #54354 ## What Problem This Solves When a cold-start terminal tool call arrives before the config bridge has run, stale env vars (TERMINAL_ENV=local, TERMINAL_DOCKER_IMAGE=old-image) can cause the terminal to silently run on the host instead of in the configured Docker container. This is a sandbox escape: the user thinks code runs in Docker but it actually runs on the host. ## Why This Change Was Made Adds `_terminal_env_snapshot()` to bridge config.yaml into terminal env resolution during cold-start, and a fail-closed guard in `_get_env_config()`: - When the config bridge fails, stale TERMINAL_* container vars are stripped (preserving explicit TERMINAL_ENV so env-only configuration is not regressed) - `_get_env_config()` checks whether config.yaml intends an isolated backend (docker, modal, etc.) - If yes → `RuntimeError` (fail closed, refuse to run on host) - If config.yaml is also unreadable → `RuntimeError` (refuse to run without confirmed backend) - If config intends local → safe to proceed ## User Impact Users with Docker/container terminal backends are now protected against silent sandbox downgrades during cold-start tool calls. Previously, an unavailable config bridge could cause code to execute on the host without the user knowing. ## Evidence - `pytest tests/tools/test_docker_cold_start_guard.py` — 12 tests passed - Codex review approved by egilewski (\"fully addressed\") - New tests cover: Docker bridge failure → RuntimeError, local bridge failure → safe proceed, env-only TERMINAL_ENV preserved, double-failure → RuntimeError ## Previous iterations - Supersedes #54982 (stale branch, rebased cleanly on current main) 🤖 Generated with [Claude Code](https://claude.com/claude-code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>",
        "url": "https://github.com/NousResearch/hermes-agent/pull/61882",
        "createdAt": "2026-07-10T05:26:30Z",
        "updatedAt": "2026-08-13T15:19:07Z",
        "timestamp": "2026-08-13T15:19:07Z",
        "metrics": {
          "reactions": 0,
          "comments": 12
        },
        "labels": [
          "type/security",
          "comp/tools",
          "tool/terminal",
          "backend/docker",
          "area/config",
          "P2",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "Xingkai98",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:61982",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat: add sanitized Kanban REST API",
        "text": "# Sanitized Kanban REST API for external control planes ## Summary Adds a narrow, authenticated, safe-by-default REST API over the existing Hermes Kanban store, mounted at `/api/plugins/kanban`. It gives external control planes a stable integration boundary for creating, routing, inspecting, and managing Kanban tasks — without reading raw SQLite state, touching internal dashboard routes, or executing profiles directly. Design constraints, all preserved end to end: - Reuses `hermes_cli.kanban_db` — same database, WAL/transaction behavior, task state machine, dependency promotion rules, and dispatcher lifecycle. No second schema, no second queue. - No profile-execution endpoint. Work is routed by task assignment; the existing gateway dispatcher claims and launches it. - Sanitized DTOs only — never raw internal rows. - No product-specific workflow types or business logic. ## API surface | Area | Endpoints | |---|---| | Status | `GET /health`, `GET /capabilities` | | Boards | `GET /boards`, `GET /boards/{id-or-name}` | | Profiles | `GET /profiles` — sanitized assignee roster (read-only) | | Tasks | `GET /tasks`, `POST /tasks`, `GET /tasks/{id}`, `PATCH /tasks/{id}` | | Actions | `POST /tasks/{id}/comment`, `/complete`, `/block`, `/unblock`, `/archive` | | Dependencies | `POST /tasks/{parent}/links/{child}`, `DELETE /tasks/{parent}/links/{child}` | | Observability | `GET /tasks/{id}/events`, `/runs`, `/log` | All request models are strict Pydantic (`extra=\"forbid\"`); unknown fields are rejected. ## Authentication External controllers authenticate with a dedicated service credential, wired through the dashboard-auth framework rather than bolted onto the routes: - The generic token-auth seam gains **prefix registration** (exact-path matching cannot cover parameterised routes) and **required scopes**: a verified principal lacking the route's scope is refused with 403 + an audit event. With multiple service credentials stacked, one credential can no longer open another surface's routes — the drain secret cannot drive the kanban API and vice versa. - A new bundled `dashboard_auth` plugin (mirroring the drain plugin) verifies `Authorization: Bearer $HERMES_KANBAN_API_SECRET` with a constant-time compare and vouches for a `kanban`-scoped principal. A weak/short/low-entropy secret (< 256 bits) fails closed at load; the shared entropy gate now lives in `dashboard_auth.secret_strength` and is reused by both plugins. - The interactive dashboard subtree (see below) is excluded from the token seam and stays on cookie/session auth. Without the secret set, the plugin is a no-op and prior behavior is unchanged. ## Idempotency (concurrency-safe) `POST /tasks` accepts an `Idempotency-Key` header or `idempotency_key` body field. A repeat returns the existing live task with HTTP 200 and `created: false`; a fresh insert returns HTTP 201 and `created: true`. The guarantee holds under concurrency and is enforced at the storage layer: - A **partial UNIQUE index** on `idempotency_key` (live, non-archived tasks; legacy duplicates deduped during migration) closes the check-then-insert race. Archiving a task frees its key. - `create_task_idempotent` resolves a lost race to the winner's row instead of surfacing `IntegrityError` — every caller (CLI, tools, dispatcher, REST) gets one task id, satisfying the multi-process stress contract (`idempotency_key_race`). ## Sanitization Task and run DTOs expose workflow state plus routing attribution — `assignee`, `created_by` (which profile/surface created the card), and the executing `profile` per run — and deliberately omit: task bodies and results, comment text, workspace paths, branch names, claim locks, worker PIDs, session IDs, idempotency keys, run summaries/metadata/errors, and raw event payloads. `GET /profiles` returns only `name`, `description`, and `has_description` — no models, providers, paths, env, or skill inventories. `GET /tasks/{id}/log` returns a bounded excerpt (8 KiB default, 32 KiB max) after Hermes secret redaction, `Authorization`-header scrubbing, and absolute-path replacement with `[PATH]`. Error details follow an allowlist: known-safe validation messages pass through verbatim; anything else collapses to a stable generic detail with the raw error logged server-side only. ## Dashboard compatibility The first-party dashboard needs a richer API than should be exposed externally, so its existing routes are namespaced under `/api/plugins/kanban/dashboard` (bundled dashboard JS updated). Both routers share the same `kanban_db` implementation. Third-party consumers of the previously undocumented dashboard routes should migrate to the sanitized API or adopt the new namespace. ## Documentation `website/docs/user-guide/features/kanban-rest-api.md` — credential provisioning, the full endpoint contract, idempotency semantics, and a worked example (parent operation → dependent children → dependency links → completion → polling sanitized events/runs/logs). ## Testing - Affected suites (REST adapter, kanban store, token-auth seam, auth middleware, both auth plugins): ```bash python -m pytest tests/hermes_cli/test_kanban_api.py tests/hermes_cli/test_kanban_db.py \\ tests/hermes_cli/test_dashboard_token_auth.py tests/hermes_cli/test_dashboard_auth_middleware.py \\ tests/plugins/dashboard_auth/ -q # 509 passed - Auth is covered end-to-end through the real mounted dashboard app in gated mode: valid bearer drives the external surface, missing/wrong token → 401, a foreign-scoped service credential → 403, and the dashboard subtree never accepts the bearer. - Idempotency race coverage: a deterministic lost-race regression at the storage layer, a threaded concurrent-create regression, and the multi-process stress scenario. Exercised live as well: 6 concurrent POST /tasks with one key → exactly one 201 + five 200s, one row persisted. - Full atypical stress suite on this branch: python tests/stress/test_atypical_scenarios.py # 28 scenarios, 0 failures, 0 skips ▎ Note: one commit (test(stress): catch atypical scenarios up to current kanban contracts) repairs two stress ▎ scenarios that fail on current main independently of this PR — they had gone stale against the spawn_failures → ▎ consecutive_failures rename (#20410) and the archived-parents-are-terminal dependency fix. Included so the suite is ▎ green on this branch; happy to split it into its own PR. Out of scope Profile execution or configuration endpoints, raw worker/session state, product-specific workflow types, and any second Kanban schema or storage layer. Follow-ups - Cursor pagination for large boards - Finer-grained RBAC within the kanban scope (e.g. read-only credentials) - Explicit API versioning if additional external contracts are introduced",
        "url": "https://github.com/NousResearch/hermes-agent/pull/61982",
        "createdAt": "2026-07-10T09:25:51Z",
        "updatedAt": "2026-08-13T15:05:51Z",
        "timestamp": "2026-08-13T15:05:51Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/feature",
          "comp/cron",
          "comp/plugins",
          "P3",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "andriy4k07",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:62239",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): unify local OpenAI-compatible provider aliases to custom (#62213)",
        "text": "## What does this PR do? Local OpenAI-compatible provider aliases (`vllm`, `llamacpp`, `llama.cpp`, `llama-cpp`) were normalized **inconsistently** across the three provider-name tables Hermes uses, which is the \"custom, local, custom:local\" confusion reported in the issue and made it hard to configure a working local endpoint from `config.yaml`: | alias | `hermes_cli.providers` | `hermes_cli.models` | `hermes_cli.auth` | |-------|------------------------|---------------------|-------------------| | `vllm` / `llamacpp` (before) | `local` (orphan id, no `ProviderDef`) | unchanged (`vllm` — unknown) | `custom` ✅ | | `ollama` (before) | `custom` | `custom` | `custom` | `auth.resolve_provider` already treated all of these as the generic `custom` provider (the documented intent — see the #27132 fix and `runtime_provider._config_base_url_trustworthy_for_bare_custom`), but the other two tables disagreed, so the model picker / web server saw an unknown provider and `providers.normalize_provider` produced an orphan `\"local\"` id that has no `ProviderDef`. This aligns all three tables on `\"custom\"` so a `provider: vllm` (or `llamacpp`) in `config.yaml` normalizes identically everywhere. Bare `local` is intentionally left untouched — it's a legitimate user-defined custom-provider *name* (matched in `resolve_runtime_provider`), not an alias. ## Related Issue Fixes #62213 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_cli/providers.py` — `ALIASES`: `vllm`/`llamacpp`/`llama.cpp`/`llama-cpp` now map to `custom` (were `local`); clarified the comment. - `hermes_cli/models.py` — `_ALIASES`: added `vllm`/`llamacpp`/`llama.cpp`/`llama-cpp` → `custom` (were absent, so left unresolved). - `tests/hermes_cli/test_local_provider_alias_parity.py` — new invariant test asserting these aliases resolve to `custom` in all three layers. ## How to Test ``` scripts/run_tests.sh tests/hermes_cli/test_local_provider_alias_parity.py tests/hermes_cli/test_runtime_provider_resolution.py ``` Result: parity test passes; the existing #27132 alias-routing tests still pass. Broader sweep (`test_model_normalize.py`, `test_provider_catalog.py`, `test_user_providers_model_switch.py`, `test_custom_provider_identity.py`, `test_web_server.py`, `test_custom_provider_model_switch.py`) — 680 tests, 0 failures. Manual check: ```python from hermes_cli.providers import normalize_provider as p from hermes_cli.models import normalize_provider as m from hermes_cli.auth import resolve_provider as a for x in (\"vllm\",\"llamacpp\",\"llama.cpp\",\"llama-cpp\",\"ollama\"): assert p(x) == m(x) == a(x) == \"custom\" ``` ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix - [x] I've run the affected tests and they pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 26.5 ### Documentation & Housekeeping - [x] I've updated relevant documentation (docstrings/comments) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A (no new config keys) - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A - [x] I've considered cross-platform impact (Windows, macOS) — N/A (pure string normalization) - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/62239",
        "createdAt": "2026-07-10T18:28:35Z",
        "updatedAt": "2026-08-13T15:13:44Z",
        "timestamp": "2026-08-13T15:13:44Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "area/config",
          "P3",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "PRATHAMESH75",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:62373",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(auth): reject scheme-downgraded portal_base_url in resolve_nous_access_token",
        "text": "## What does this PR do? `resolve_nous_access_token()` and `resolve_nous_runtime_credentials()` are twin allowlist checks on the same stored/network-sourced `portal_base_url` field (both `hermes_cli/auth.py`) — both POST the user's `refresh_token` bearer to this URL on refresh, so both must reject a poisoned value before using it. A same-day fix to `resolve_nous_runtime_credentials()` hardened the allowlist check to validate the URL **scheme**, not just the hostname (require `https`, or `http` only for `localhost`/`127.0.0.1`). That hardened one twin but not the other: `resolve_nous_access_token()` still only checks `parsed_portal_url.hostname not in _NOUS_PORTAL_ALLOWED_HOSTS`, so an allowlisted hostname served over plain HTTP (e.g. a cross-profile shared-state value poisoned to `http://portal.nousresearch.com`) passes through unchanged — sending the refresh_token bearer in cleartext, exploitable by a network-level MITM per the original `#27612`/`#30611` threat model (\"prevent bearer token exfiltration\"). This also closes a secondary gap: the old check only fired `if parsed_portal_url.hostname` (truthy), so a malformed `portal_base_url` with no parseable hostname passed through unvalidated. The new check rejects that too, matching the sibling function exactly. `resolve_nous_access_token()` is not an edge case — it backs `tools/managed_tool_gateway.py`, `hermes_cli/nous_account.py`, `hermes_cli/dashboard_register.py`, `hermes_cli/nous_billing.py`, the Chronos cron provider (`plugins/cron_providers/chronos/_nas_client.py`), and the relay self-provisioning enroll flow (`gateway/relay/__init__.py`). ## Related Issue N/A — found while investigating today's `resolve_nous_runtime_credentials()` scheme-validation commit and checking for sibling call sites reading the same `portal_base_url` field. Not a duplicate of open PR #57644, which targets a third, distinct function (`refresh_nous_oauth_pure`) and explicitly mirrors this function's *old* (pre-hardening) pattern rather than modifying it. ## Type of Change - [x] 🐛 Bug fix - [x] 🔒 Security fix ## Changes Made - `hermes_cli/auth.py`: `resolve_nous_access_token()`'s allowlist check now validates scheme in addition to hostname, mirroring `resolve_nous_runtime_credentials()` exactly. - `tests/hermes_cli/test_auth_nous_provider.py`: add `test_runtime_rejects_http_for_production_portal`. ## How to Test ``` pytest tests/hermes_cli/test_auth_nous_provider.py -v ``` All 74 tests in the file pass. Mutation-verified: reverting the fix reproduces the failure — `http://portal.nousresearch.com` is passed straight through to the refresh call instead of healing to `DEFAULT_NOUS_PORTAL_URL`. Also ran the full related test surface (`test_dashboard_register.py`, `test_nous_portal_staging_allowlist.py`, `test_nous_account.py`, `test_nous_inference_url_validation.py`, `test_auth_commands.py`, `test_billing_scope_stepup.py`, `test_proxy.py`, `test_status*.py`, `gateway/relay/test_identity_token_resolver.py`, `gateway/relay/test_self_provision.py`) — 209 passed, 1 skipped, no regressions. ## Checklist - [x] Read the Contributing Guide - [x] Conventional Commits format - [x] No duplicate PR - [x] Single logical change - [x] Tests added - [x] Cross-platform: N/A (pure Python URL parsing)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/62373",
        "createdAt": "2026-07-11T00:27:38Z",
        "updatedAt": "2026-08-13T15:08:52Z",
        "timestamp": "2026-08-13T15:08:52Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/security",
          "comp/cli",
          "provider/nous",
          "area/auth",
          "P2",
          "sweeper:risk-security-boundary",
          "sweeper:blast-moderate"
        ],
        "author": "pierrenode",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:63084",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(tools): don't report interrupted read_file as File not found (#63069)",
        "text": "## What does this PR do? `read_file` / `read_file_raw` in `tools/file_operations.py` stat the target with `wc -c` before reading it. **Any** non-zero exit fell straight through to `_suggest_similar_files()`, which returns `File not found: <path>`. When a client disconnects mid-turn (`SSE client disconnected; interrupted agent task ...`), the interrupt kills the in-flight `wc -c` subprocess with exit code **130** (SIGINT). An existing, readable file is then misreported as *missing*. Agents that treat `read_file` failures as \"file genuinely deleted\" (e.g. governance/constitution files read at task start) can wrongly conclude a critical file vanished and escalate/block unnecessarily. This branches on the stat probe's exit code **before** the not-found fallback: - `130` → `Read interrupted while checking file: <path>` - `124` (timeout) → `Read timed out while checking file: <path>` - any other non-zero → unchanged `_suggest_similar_files()` path, so genuinely missing files still return `File not found`. The mapping lives in a small `_aborted_stat_read_result()` helper, colocated with the existing `_search_stdout_and_limit()` that already special-cases exit `124` — matching the module's established exit-code-semantics pattern. The fix stays inside the shell abstraction (no host-side `os.path.exists`), so it holds for every backend (local, docker, ssh, modal, daytona). ## Related Issue Fixes #63069 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `tools/file_operations.py`: add `_aborted_stat_read_result(path, exit_code)` helper; call it in `read_file()` and `read_file_raw()` before falling back to `_suggest_similar_files()`. - `tests/tools/test_file_operations.py`: add `TestReadStatProbeAborted` covering exit 130 (interrupted) and 124 (timeout) for both `read_file` and `read_file_raw`, plus a regression that other non-zero codes still return `File not found`. ## How to Test ``` scripts/run_tests.sh tests/tools/test_file_operations.py -q ``` Result: **95 passed** (5 new). The new cases mock the terminal env's `wc -c` probe to exit `130`/`124`/`1` and assert the surfaced error string. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin 25.5) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) — fix is exit-code branching inside the existing shell abstraction, no platform-specific paths - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/63084",
        "createdAt": "2026-07-12T08:54:26Z",
        "updatedAt": "2026-08-13T15:16:48Z",
        "timestamp": "2026-08-13T15:16:48Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "duplicate",
          "tool/file",
          "P2",
          "sweeper:blast-broad"
        ],
        "author": "PRATHAMESH75",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:63899",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(delegate): surface child's resolved toolsets in async completion block (#63887)",
        "text": "## What does this PR do? Async delegation records each subagent's full task source (goal, context, role, model) in the completion block that re-enters the parent conversation — but **not the toolsets the child actually held** after parent-intersection and blocked-tool stripping. Because of that, when a delegation goal structurally requires a toolset the child can never have (e.g. a Telegram parent whose `platform_toolsets` excludes `file`/`terminal` delegates *\"Write a bridge request file to ~/.hermes/bridge/requests/<name>.md\"*), dispatch proceeds silently. The child never has the tool to do the work, and a budget-tier child model then reports `completed` with a **fabricated verification narrative** (\"Confirmed file contents match via `cat`\") — nothing anywhere signals the capability mismatch. This PR implements the issue's suggested minimum: it records each subagent's **resolved** toolset list on its result entry and renders it per-subagent in the batch completion block, so the parent (and human) can see the child never held the required capability and catch the mismatch. The intersection rule itself is intentionally left unchanged — as the issue notes, \"the child correctly cannot have file tools\" is good design, not the bug. ## Related Issue Fixes #63887 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `tools/delegate_tool.py`: add the resolved `child_toolsets` (post parent-intersection + blocked-tool stripping) to each per-task result `entry` — both the normal and the exception-path entry. - `tools/process_registry.py`: in `_format_async_delegation`, render a `Toolsets available: …` line per subagent in the batch completion block. - `tests/tools/test_delegate_completion_block.py`: new test asserting the resolved toolsets appear per-task and the impossible capability is visibly absent from the rendered block. ## How to Test ``` scripts/run_tests.sh tests/tools/test_delegate_completion_block.py tests/tools/test_process_registry.py ``` Result: all pass. The new test reproduces the issue scenario — a `completed` child whose resolved toolset is `[\"todo\"]` — and asserts the block now surfaces `Toolsets available: todo` with `file` visibly absent, exposing the mismatch to the parent. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits (`fix(scope):`) - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix (no unrelated commits) - [x] I've run the affected tests and they pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 26 (Darwin 25.5) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A (no config keys) - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) — or N/A (pure string/dict rendering) - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/63899",
        "createdAt": "2026-07-13T18:04:12Z",
        "updatedAt": "2026-08-13T15:16:18Z",
        "timestamp": "2026-08-13T15:16:18Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "tool/delegate",
          "P2",
          "sweeper:risk-session-state",
          "sweeper:blast-contained"
        ],
        "author": "PRATHAMESH75",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:64292",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(langfuse): include system prompt in generation input (Anthropic system + Codex instructions)",
        "text": "## Summary Langfuse **LLM call** generation observations show conversations without the agent's system prompt (skills, memory, SOUL, tool guidance) for every provider that moves the system prompt out of `messages`: - **Anthropic Messages API** → separate `system` kwarg (`agent/anthropic_adapter.py`), reported in #32175 - **Responses/Codex API** → top-level `instructions` (`agent/transports/codex.py:132-139` extracts `messages[0]` into `instructions` and drops it from the payload) — the sibling call path, previously uncovered The `pre_api_request` hook only forwarded `request_messages`, so traces for both paths looked like conversations with no instructions at all — e.g. a Codex-backed `Hermes turn` whose `LLM call 1` shows 18k prompt tokens but only a one-line user message as input. This PR salvages #32175 by @db-aeon onto current `main` (cherry-picked so authorship survives, per the AGENTS.md salvage etiquette), then fixes the whole bug class: - `agent/conversation_loop.py` derives the system prompt the provider actually receives — `api_kwargs[\"system\"]` → `api_kwargs[\"instructions\"]` → `messages[0]` fallback, extracted into a `_system_prompt_for_hooks()` helper — and passes it to `pre_api_request` as `system_prompt` - `plugins/observability/langfuse` prepends a serialized `role: system` entry to the generation input (after the last-12 window, so long conversations never drop it), flattens Anthropic content-block lists, skips the prepend when `messages[0]` already carries the system role (chat_completions), and records `system_prompt_chars` metadata ## Commits - **test(langfuse): system prompt must reach generation input across providers** — RED-first coverage: plugin contract (prepend, block-list flatten, no duplication, window-safe, metadata) **plus the cross-hook-boundary regression the #32175 review asked for** — real `ResponsesApiTransport().build_kwargs()` / `AnthropicTransport().build_kwargs()` output flows through the loop's derivation into the plugin hook with no Hermes internals mocked (only the Langfuse client is faked) - **fix(langfuse): include Anthropic system prompt in generation input** (@db-aeon, cherry-picked from #32175; conflicts with the current middleware/hook block resolved) - **fix(langfuse): carry Codex/Responses instructions into hook system_prompt** — the Codex sibling path + `_system_prompt_for_hooks()` extraction ## Test plan - [x] `tests/plugins/test_langfuse_plugin.py` — 59 passed (48 pre-existing + 9 new + 2 from #32175) - [x] Affected surface: `tests/plugins/`, `tests/hermes_cli/test_plugins.py`, `tests/run_agent/test_run_agent_codex_responses.py` — no regressions vs clean `main` - [x] `scripts/run_tests.sh` full suite — remaining failures reproduce identically on clean `main` (environment-specific, macOS), zero delta from this change - [x] E2E against a self-hosted Langfuse: enabled `observability/langfuse`, ran `hermes chat` on an `openai-codex` model — generation input now starts with `role: system` (102,659 chars, `system_prompt_chars` metadata matches); chat_completions sessions unchanged (single system entry, no duplication) Closes #32175 (supersedes it while preserving its commit; happy to rebase if @db-aeon prefers to land his PR first — this one then shrinks to the Codex path + tests).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/64292",
        "createdAt": "2026-07-14T08:56:10Z",
        "updatedAt": "2026-08-13T17:40:51Z",
        "timestamp": "2026-08-13T17:40:51Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/plugins",
          "P3",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "FnExpress",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:64345",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(sanitize): drop tool_calls key when dedup removes all calls",
        "text": "## What does this PR do? Fixes an HTTP 400 error from DeepSeek v4 and newer OpenAI providers when `sanitize_api_messages()` produces an empty `tool_calls: []` array after deduplicating all tool_calls in a message. The dedup pass introduced by #58327 removes duplicate `tool_call_id`s to comply with provider requirements. However, when ALL tool_calls in a message are duplicates of earlier messages in a long conversation history, the pass writes `tool_calls: []`. Strict providers reject this with HTTP 400: \"Invalid 'messages[N].tool_calls': empty array.\" This fix drops the `tool_calls` key entirely when all tool_calls are removed by deduplication, treating the empty key as \"no tool calls\" rather than an invalid empty array. ## Related Issue Fixes #64335 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - **agent/agent_runtime_helpers.py** (~line 2638): Modified dedup logic - When `kept_tcs` is non-empty: update `tool_calls` with deduplicated list (preserves existing behavior) - When `kept_tcs` is empty and original message had tool_calls: remove `tool_calls` key (new fix) - When `kept_tcs` is empty and original message had no tool_calls: no change (preserves existing behavior) - **tests/run_agent/test_message_sequence_repair.py**: Added regression test - `test_sanitize_dedup_drops_tool_calls_key_when_all_removed()` validates the fix ## How to Test 1. Run the new regression test: ```bash python -m pytest tests/run_agent/test_message_sequence_repair.py::test_sanitize_dedup_drops_tool_calls_key_when_all_removed -xvs ``` Expected: Test passes, confirming tool_calls key is dropped when dedup removes all calls. 2. Run all sanitize-related tests to ensure no regressions: ```bash python -m pytest tests/run_agent/test_message_sequence_repair.py -k \"sanitize\" -xvs ``` Expected: All 8 tests pass. 3. Verify the fix logic manually: ```python from agent.agent_runtime_helpers import sanitize_api_messages messages = [ {\"role\": \"assistant\", \"content\": \"first\", \"tool_calls\": [{\"id\": \"A\", \"type\": \"function\", \"function\": {\"name\": \"foo\"}}]}, {\"role\": \"tool\", \"tool_call_id\": \"A\", \"content\": \"result\"}, # Later message with duplicate tool_call_id {\"role\": \"assistant\", \"content\": \"retry\", \"tool_calls\": [{\"id\": \"A\", \"type\": \"function\", \"function\": {\"name\": \"foo\"}}]}, ] out = sanitize_api_messages(messages) # First assistant should have tool_calls (first occurrence kept) # Second assistant should have NO tool_calls key (all were duplicates) ``` Expected: Second assistant message has no `tool_calls` key, content preserved as \"retry\". ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS (development environment) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/64345",
        "createdAt": "2026-07-14T10:54:01Z",
        "updatedAt": "2026-08-13T13:41:31Z",
        "timestamp": "2026-08-13T13:41:31Z",
        "metrics": {
          "reactions": 1,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/deepseek",
          "P2",
          "sweeper:risk-session-state",
          "sweeper:blast-broad"
        ],
        "author": "liuhao1024",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:64797",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(langfuse): export canonical generation total",
        "text": "## What does this PR do? Fixes Langfuse generation/trace/session cost rollups when Hermes exports cache or other custom cost buckets. The bug was reproduced on unchanged `main` at `d2c81eb681dea1382fbd1ed403f58320d5aef575`; the branch is now rebased onto current `main` at `9baa7d4673ce89f09378daa3660530f8bf142708`. Hermes already computes the right request-level total, but both Langfuse export paths discard it and send only component buckets. Current Langfuse ingestion treats any supplied `cost_details` as authoritative and skips model-price inference. It derives `total` only for the exact built-in `input` + `output` case; adding `cache_read_input_tokens` leaves `total` absent. Downstream generation conversion treats an absent total as zero, and trace/session aggregation consumes that total. The contract was verified against the current [ingestion schema](https://github.com/langfuse/langfuse/blob/b445e2ff64141a5eab928122405c24f0576f8106/fern/apis/server/definition/ingestion.yml#L207-L229), [ingestion implementation](https://github.com/langfuse/langfuse/blob/b445e2ff64141a5eab928122405c24f0576f8106/worker/src/services/IngestionService/index.ts#L1457-L1482), [Python SDK serialization](https://github.com/langfuse/langfuse-python/blob/85208902bad63899352742bb85103ac97983b012/langfuse/_client/attributes.py#L116-L165), [observation conversion](https://github.com/langfuse/langfuse/blob/b445e2ff64141a5eab928122405c24f0576f8106/packages/shared/src/server/repositories/observations_converters.ts#L443-L464), [trace rollup](https://github.com/langfuse/langfuse/blob/b445e2ff64141a5eab928122405c24f0576f8106/packages/shared/src/server/services/traces-ui-table-service.ts#L293-L317), and [session rollup](https://github.com/langfuse/langfuse/blob/b445e2ff64141a5eab928122405c24f0576f8106/packages/shared/src/server/queries/clickhouse-sql/event-query-builder.ts#L1363-L1389). This patch exports Hermes's canonical `estimate_usage_cost(...).amount_usd` as `cost_details.total` in both the response-object and sanitized-summary paths. It deliberately does not sum component buckets: the canonical amount also includes per-request pricing (`request_count * request_cost`). Unknown or partially priced usage still emits no guessed cost, and subscription-included routes retain their existing component-only semantics without an explicit total. This cannot be repaired reliably with Langfuse model-price configuration or Hermes environment settings. Once Hermes supplies any cost bucket, current Langfuse ingestion does not run model inference; removing all supplied costs would also discard Hermes-specific cache/custom/request pricing and is not equivalent to completing the payload. ### Reproduction on unchanged main Local Langfuse server 3.178.0 with Python SDK 4.7.1: | Runtime path | Case | Hermes canonical total | Cost payload before | Stored generation total | Trace rollup | | --- | --- | ---: | --- | ---: | ---: | | response object | no cache | $1.60 | input=$1.00, output=$0.60 | $1.60 | $1.60 | | response object | cache | $0.88 | input=$0.20, output=$0.60, cache=$0.08 | $0.00 / absent | $0.00 | | sanitized summary | no cache | $1.60 | input=$1.00, output=$0.60 | $1.60 | $1.60 | | sanitized summary | cache | $0.88 | input=$0.20, output=$0.60, cache=$0.08 | $0.00 / absent | $0.00 | After this patch, the same four calls export explicit totals of $1.60, $0.88, $1.60, and $0.88. Each generation and trace stores the matching value, and the session metrics roll up to $4.96. ## Related Issue Related: #49932, #43130 Credit to @rdguidry: draft PR #49932 first identified the missing-explicit-total problem. This is a separate, provider-agnostic plugin-only fix because #49932 also owns Venice pricing/catalog changes and derives total by summing buckets, which omits request-level pricing. No code was copied from #49932; its Venice work remains there. #43130 is orthogonal subscription-included policy work: it removes all explicit cost details so Langfuse may estimate nominal list price. This patch is behaviorally compatible because it never adds `total` for an `included` result. If #43130 lands first, only a mechanical rebase around the shared helper may be needed. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `plugins/observability/langfuse/__init__.py`: share canonical usage/cost export between both call paths and include the authoritative request-level total only when pricing is complete and not subscription-included. - `tests/plugins/test_langfuse_plugin.py`: cover cache/no-cache, request-priced, request-only, partial/unknown pricing, and included-route parity through both real exporter paths. ## How to Test 1. Run `scripts/run_tests.sh tests/plugins/test_langfuse_plugin.py tests/agent/test_usage_pricing.py -q`. 2. Run `python -m ruff check plugins/observability/langfuse/__init__.py tests/plugins/test_langfuse_plugin.py` and `python scripts/check-windows-footguns.py plugins/observability/langfuse/__init__.py tests/plugins/test_langfuse_plugin.py`. 3. Point the plugin at a Langfuse v3 instance and compare otherwise identical calls with and without cached input tokens across the response-object and sanitized-summary hooks. Generation, trace, and session totals should match Hermes's canonical amount. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26.5.1 arm64 Full wrapper run: 41,121 passed and 39 failed in unrelated host/environment-sensitive tests (live credential guards, Linux service/systemd assumptions, macOS `/tmp` path normalization, AWS credentials, and gateway SDK/race tests). The changed Langfuse tests passed in the full run. Focused wrapper: 70 passed. ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — N/A; no public configuration or API change - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — Windows footgun check passed - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A ## Screenshots / Logs - Focused tests: 70 passed. - Langfuse plugin tests: 55 passed. - Ruff, Python compile, `git diff --check`, and Windows footgun checks: passed. - Before-fix E2E run: `codex-langfuse-rollup-1784094329-099614`. - After-fix E2E run: `codex-langfuse-fix-1784095229-0cbc8f`.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/64797",
        "createdAt": "2026-07-15T06:13:00Z",
        "updatedAt": "2026-08-13T17:41:01Z",
        "timestamp": "2026-08-13T17:41:01Z",
        "metrics": {
          "reactions": 0,
          "comments": 5
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "P3",
          "telemetry",
          "sweeper:risk-compatibility",
          "sweeper:blast-contained",
          "area/usage-cost"
        ],
        "author": "NaMinhyeok",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:64891",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(auxiliary): keep /anthropic base_url for anthropic_messages custom endpoints",
        "text": "## What does this PR do? The `custom` + `explicit_base_url` branch of `resolve_provider_client()` (in `agent/auxiliary_client.py`) rewrote a trailing `/anthropic` to `/v1` via `_to_openai_base_url()` and then passed that rewritten base to the wrap-decision (`_wrap_if_needed` → `_maybe_wrap_anthropic`) — **even when `api_mode` was `anthropic_messages`**. The Anthropic wrapper therefore never saw the real `/anthropic` path, so for any `custom` provider whose `base_url` ends in `/anthropic` (a proxied Anthropic gateway — MiniMax, Zhipu/GLM, a LiteLLM proxy, or a self-hosted LLM proxy) every auxiliary task (title generation, compression, vision, `web_extract`, `session_search`) hit `.../v1/chat/completions` on a Messages-only endpoint and failed. ## Fix Guard the wrap base on `api_mode`: for `api_mode == \"anthropic_messages\"` the Anthropic wrapper is built on the **raw** `/anthropic` base, while the plain OpenAI client — and the OpenAI-wire fallback taken when the `anthropic` SDK is unavailable — still uses the `/v1`-rewritten base so it never lands on `/anthropic/chat/completions`. The change is scoped: it adds a `wrap_base` that equals `custom_base` on every existing path (including the `main_runtime` named-custom path) and only differs for the explicit-base `anthropic_messages` case. Non-anthropic paths are unchanged. Ships with focused regression tests (`tests/agent/test_auxiliary_explicit_base_anthropic.py`): raw `/anthropic` base is preserved for the wrapper, the OpenAI-wire fallback stays on `/v1`, and the non-anthropic `/anthropic`→`/v1` rewrite is untouched. ## Related Root cause: #16254 (duplicate: #17086). A few open PRs already target the same `_to_openai_base_url()` `/anthropic`→`/v1` rewrite: - #61333 — skip the rewrite when `api_mode` is `anthropic_messages` (closest to this PR) - #60753 — preserve `/anthropic` base_url for the custom vision provider with `anthropic_messages` - #61394 — skip the rewrite for sub-application paths like `/apps/anthropic` **How this PR differs:** it guards the wrap base in the `custom` + `explicit_base_url` branch (the path `_resolve_auto` Step 1 uses for a `custom` main provider), and it **keeps** the `/v1` rewrite for the OpenAI-wire fallback so that fallback never lands on `/anthropic/chat/completions`. Refs #16254 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/64891",
        "createdAt": "2026-07-15T10:07:47Z",
        "updatedAt": "2026-08-13T17:33:07Z",
        "timestamp": "2026-08-13T17:33:07Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/anthropic",
          "area/config",
          "P3",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "AlexanderPrendota",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:65102",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(api): resolve configured session identities early",
        "text": "## Summary Resolve configured API session-key aliases into canonical native-platform identities before agent construction. This lets API-originated turns use the same platform identity, toolset selection, prompt context, and session namespace as the configured Telegram or Discord destination without trusting caller-supplied routing fields. ## Behavior - Loads `gateway.session_key_aliases` from the real `config.yaml` gateway section. - Resolves aliases request-locally with no shared mutable identity cache. - Threads canonical identity through all five API turn surfaces: - session chat - session chat stream - Chat Completions - Responses - Runs - Uses the canonical native platform for toolset and prompt-context selection. - Keeps unknown keys on existing API-server behavior. - Rejects configured aliases that resolve to recursive or unsupported destinations. - Rejects `profile` aliases until API turns can enter the native profile runtime scope. - Rejects `scope_id` aliases until canonical session keys represent workspace scope without collisions. - Ignores caller-supplied platform, chat, and profile fields for canonical routing. Aliases are scoped to the currently running Hermes profile. Stable session-ID overrides are intentionally not added because current API session semantics do not provide a safe, consistent override contract across these surfaces. ## Tests Current-main identity/API/config matrix: **177 passed** after independent security review. The review's final blocker—missing `chat_type` in API-bound native session context—was repaired at the shared binding seam and covered for direct group context, `/v1/runs`, streaming aliases, and ordinary API defaults. Coverage includes: - Telegram and Discord aliases - all five API surfaces - authenticated fail-closed handling for profile and scoped-platform aliases - spoof resistance - toolset and prompt-context selection - malformed aliases - default compatibility Also verified with Ruff, compileall, `git diff --check`, committed-tree checks, and exact-SHA re-review. Fresh required CI is green on the published head.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/65102",
        "timestamp": "2026-08-13T12:09:35Z",
        "metrics": {
          "reactions": 0,
          "comments": 6
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "area/config",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate",
          "area/sessions"
        ],
        "author": "rungmc357",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:65515",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): default context lookup for empty model IDs",
        "text": "## Summary This is the focused current-main replacement for the unique safety fix in #26873. `get_model_context_length()` can be called while a runtime/model selection is still empty. Before this guard, an empty or `None` model flowed into provider-prefix normalization and then into cache/probe logic. Return the documented 256K fallback immediately instead. The replacement intentionally excludes the unrelated runtime-switch, Feishu, cron, auxiliary-client, metadata, and compressor changes from #26873. Those concerns overlap with later upstream work and should not be resurrected as one conflicted patch. ## Verification - `scripts/run_tests.sh tests/agent/test_model_metadata.py -q` — 122 passed - `ruff check agent/model_metadata.py tests/agent/test_model_metadata.py` - `python -m py_compile agent/model_metadata.py tests/agent/test_model_metadata.py` - `git diff --check` Closes the unique empty-model failure mode extracted from #26873; the historical PR remains separate until maintainers decide how to handle its now-overlapping runtime-switch changes.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/65515",
        "createdAt": "2026-07-16T08:47:19Z",
        "updatedAt": "2026-08-13T17:49:10Z",
        "timestamp": "2026-08-13T17:49:10Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P2",
          "sweeper:risk-compatibility",
          "sweeper:blast-contained"
        ],
        "author": "whirmill",
        "state": "closed",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:66163",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(slack): configurable slash-command namespace prefix",
        "text": "## What does this PR do? Slack slash commands are **workspace-global and not namespaced**. When two gateway apps live in the same workspace — or Hermes sits alongside any other app using generic names like `/model`, `/new`, `/help` — Slack routes each shared command to whichever app was installed most recently, silently breaking the others. There is no per-app namespace in Slack's model, so the only fix is to make the registered names themselves distinct. This adds an **opt-in namespace prefix**: set `platforms.slack.extra.command_prefix: \"myorg-\"` and Hermes registers `/myorg-model`, `/myorg-hermes`, … instead. The prefix is prepended when generating the app manifest and stripped again on the receive side, so the two sides always agree and dispatch behaviour is unchanged. **Why this approach:** it keeps all ~50 commands as first-class native slashes rather than collapsing them behind a single catch-all, so nothing is lost from the Slack command picker. Default is `\"\"` — a no-op for every existing install. ## Related Issue Fixes #66156 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ 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 - **`hermes_cli/commands.py`** - `slack_command_prefix(extra=None)` — resolves the prefix from `platforms.slack.extra.command_prefix`, passed in by the adapter or read from `config.yaml` when generating the manifest. Defaults to `\"\"`. - `_sanitize_slack_prefix()` — same charset as `_sanitize_slack_name` (a-z, 0-9, `-`, `_`, 32 chars) but preserves a trailing `-`/`_` so `myorg-` yields `/myorg-model`, not `/myorgmodel`. A prefix that sanitizes to empty logs a warning instead of silently disabling namespacing. - `slack_native_slashes(prefix=\"\")` — the Slack built-in reserved-name check now runs on the **full registered name**, so `/myorg-status` is no longer treated as colliding with Slack's `/status` and gains a native slot. - `slack_app_manifest()` — prepends the prefix; names that exceed Slack's 32-char limit are skipped so the manifest stays valid, with a warning naming them. - **`plugins/platforms/slack/adapter.py`** — the prefix is baked into the slash-routing regex and stripped from `slash_name` before the `{\"hermes\", \"\"}` check and registry dispatch, so `/myorg-model` → `model` and `/myorg-hermes` → the legacy catch-all. The in-thread `!` fallback strips it too. - **`hermes_cli/slack_cli.py`** — manifest generation threads the prefix through. - **`cli-config.yaml.example`** — documents the new `platforms.slack.extra.command_prefix` key. - **`website/docs/user-guide/messaging/slack.md`** — user-facing setup docs. - **`tests/gateway/test_slack.py`, `tests/hermes_cli/test_slack_cli.py`** — new `TestSlackCommandPrefix` coverage. ## How to Test **Default is unchanged (regression check)** 1. Leave `command_prefix` unset. Run `hermes slack manifest`. 2. Confirm the output is identical to before this PR: 50 commands, `/hermes` first, no prefixes. **Namespaced mode** 1. In `~/.hermes/config.yaml`: ```yaml platforms: slack: extra: command_prefix: \"myorg-\" ``` 2. `hermes slack manifest > slack-manifest.json` — every `command` is now `/myorg-…`. 3. Apply the manifest to your Slack app (App settings → App Manifest → paste → Save) and reinstall to the workspace. 4. In Slack, run `/myorg-model` — it resolves to the same handler `/model` used to. `/myorg-hermes help` reaches the catch-all. Unprefixed `/model` no longer routes to this app, which is the point: another app may now own it. 5. Sanity-check the edges: `command_prefix: \"!!!\"` (sanitizes to empty) logs a warning and leaves commands unprefixed; a very long prefix logs which commands were dropped for exceeding Slack's 32-char limit. **Automated** ```bash scripts/run_tests.sh tests/gateway/test_slack.py tests/hermes_cli/test_slack_cli.py tests/hermes_cli/test_commands.py # => 3 files, 649 tests passed, 0 failed ``` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run the test suite via `scripts/run_tests.sh` — see *Test results and pre-existing noise* below for exactly what was run and what was compared against clean `main` - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin 24.6.0), Python 3.13 — automated suite and `hermes slack manifest` output verified locally ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A (no architecture or workflow change) - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — the change is string/regex/config only, with no file I/O, process management, or terminal handling - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A (no tool behaviour change) ## Notes for reviewers ### Sync with `main` Rebased onto `202140db5`, cleanly, still a single commit. Exactly one upstream commit since the last sync touches a file this PR modifies: `f9cd57791` *feat(approvals)*, which adds `/approvals` to `COMMAND_REGISTRY` and — to stay under Slack's 50-command cap — demotes `update` into `_SLACK_VIA_HERMES_ONLY`. Since that lands on the manifest surface this PR reworks, the interaction points were re-verified on the rebased tree: - **Manifest exclusion list.** `_SLACK_VIA_HERMES_ONLY` is now `{topup, moa, debug, egress, init, version, diff, update}`. This still composes cleanly: the exclusion check runs on the *registry* name (unprefixed), while the Slack built-in reserved-name check runs on the *full registered* name (`prefix + name`). The two are deliberately at different altitudes and the rebase preserved that. - **50-command cap.** Verified with and without a prefix: the registry sits at exactly 50 in both, `/hermes` is first in both, `/approvals` keeps its native slot in both, and no generated name is duplicated or exceeds 32 chars. - **Which commands get clamped.** The prefix un-reserves Slack built-ins, so `status` and `topic` gain native slots and clamp off `insights` and `platform` (still reachable via `/<prefix>hermes <command>`). This trade-off is inherent to the feature, not new to the rebase — only *which* commands sit at the margin shifted, because `update` is now demoted upstream. - **Prefix routing.** Registry-driven and unchanged, so `/approvals` and its `manual|smart|off` subcommands are namespaced automatically with no code change here. ### Review history - **2026-07-21:** the `HERMES_SLACK_COMMAND_PREFIX` environment-variable override was **removed** per review feedback — `AGENTS.md` reserves `HERMES_*` env vars for secrets, and behavioral settings belong in `config.yaml`. `platforms.slack.extra.command_prefix` is now the sole configuration surface. ### Test results and pre-existing noise Run with the canonical runner (`scripts/run_tests.sh`, per-file subprocess isolation) on macOS 15 / Python 3.13: | Scope | Result | |---|---| | `test_slack.py` + `test_slack_cli.py` + `test_commands.py` | **649 passed, 0 failed** | | `tests/gateway` + `tests/hermes_cli` (the scopes this PR touches) | 1 failure — `test_background_command.py::TestRunBackgroundTask::test_media_files_routed_by_type` | That single failure is unrelated and **pre-existing**: it fails identically on a clean `upstream/main` worktree under the same command, so it is not introduced here. For transparency: the repo-wide `scripts/run_tests.sh` run also surfaces unrelated failures outside this PR's scope (e.g. under `tests/agent/`). Those areas are untouched by this diff — no file in `agent/` or the tool layer is modified — and I have not attempted to triage them here. ### Manifest output Default (`command_prefix` unset) — unchanged from `main`: ``` /hermes /btw /bg /start ... (50 commands total) ``` With `command_prefix: \"myorg-\"`: ``` /myorg-hermes /myorg-btw /myorg-bg /myorg-start ... (50 commands total) ``` ### Relationship to existing proposals This is the \"namespace **every** command\" approach — all ~50 commands stay first-class native slashes, just prefixed. It's an alternative to (not a conflict with): - #29081 / #11836 — rename the top-level catch-all only. - #48642 — configurable catch-all name + single-command mode (one command per instance). Since this area is marked `needs-decision`, happy to adjust or align with whichever direction maintainers prefer. ## Infographic <img width=\"1024\" height=\"768\" alt=\"image\" src=\"https://github.com/user-attachments/assets/cc4a46fb-6761-446f-bdaa-e5dc5b7d25db\" />",
        "url": "https://github.com/NousResearch/hermes-agent/pull/66163",
        "createdAt": "2026-07-17T08:02:09Z",
        "updatedAt": "2026-08-13T16:04:37Z",
        "timestamp": "2026-08-13T16:04:37Z",
        "metrics": {
          "reactions": 0,
          "comments": 10
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "comp/plugins",
          "platform/slack",
          "area/config",
          "P3",
          "needs-decision",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "chenwei791129",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:67017",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "anthropic_prompt_cache_policy: missing positional agent parameter",
        "text": "When the positional `agent` parameter is removed from `agent/agent_runtime_helpers.py::anthropic_prompt_cache_policy`, all call sites that pass `agent` positionally raise at runtime: - `agent/moa_loop.py:247` (positional) - `agent/agent_runtime_helpers.py` (recursive call) - `run_agent.py::_anthropic_prompt_cache_policy` (forwarder) This was traced from a user-initiated Hermes chat crash. The function itself lives on `origin/main` with the correct signature; the regression was introduced in an in-flight feature commit. A guarded regression test (in the related PR) prevents recurrence. ## Reproduction ```bash # Checkout a ref missing the `agent` parameter, then run: ~/.hermes/hermes-agent/venv/bin/python -m unittest tests.run_agent.test_anthropic_prompt_cache_policy_signature -v # Broken ref: TypeError about positional argument # Fixed ref: passes ``` Signed-off-by: Georg Kühnberger <[REDACTED]>",
        "url": "https://github.com/NousResearch/hermes-agent/pull/67017",
        "timestamp": "2026-08-13T11:33:17Z",
        "metrics": {
          "reactions": 0,
          "comments": 6
        },
        "labels": [
          "type/test",
          "comp/agent",
          "P3",
          "sweeper:blast-contained"
        ],
        "author": "kuehnberger",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:67163",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(termux): complete native install, TUI, and Desktop support",
        "text": "## What does this PR do? Completes Hermes's native Android/Termux support end to end: immutable Python dependencies, safe update behavior, narrow-terminal TUI UX, and the current Desktop renderer through Termux:X11 plus the phone's normal localhost browser. This is self-contained and rebased directly on current upstream `main`. It does not require another open Hermes PR to land first. ## Native install and update chain The original device failure chain was a combination of Termux moving its system Python beyond Hermes's supported range, Android/Bionic wheel-tag mismatches, optional native accelerators, index leakage, and stale venv/launcher state. This PR keeps `scripts/install.sh` intact for non-Termux hosts and dispatches native Termux installs to `scripts/install-termux.sh`. The native path: - uses Termux packages and `uv`, not Ubuntu/proot or pip; - selects the supported CPython 3.13 Android runtime and preserves the old venv until smoke tests pass; - resolves the curated Termux dependency profile from `uv.lock` with Android markers; - consumes an immutable, SHA-256-pinned Android arm64 wheelhouse instead of compiling native dependencies on every phone; - installs with source distributions disabled and validates the full environment with `uv pip check` + native imports; - preserves the same binary-only invariant during `hermes update`: after a pull, the freshly resolved graph is verified against the immutable wheelhouse before the new checkout is accepted, and the update rolls back on pin/integrity/binary-coverage failure. The current immutable release was built and verified in the canonical `adybag14-cyber/termux-hermes` tooling and is pinned by exact release commit, `SHA256SUMS`, and wheel hashes: - release: `wheelhouse-cp313-android24-arm64-20260811.1` - builder commit: `5184b2059257918e9a98993186b57f6aaf9037b9` - target: CPython 3.13 / `android_24_arm64_v8a` - graph: 90 exact packages, including 10 Android-native wheels - native release workflow: all ten wheels built, binary-only clean-env install passed, native imports passed, and `uv pip check` passed The canonical wheelhouse tooling PR was merged after that native release gate passed. ## Narrow Termux TUI UX The phone-width bounce came from hard minimum geometry floors that could exceed the real terminal width. Termux now uses a terminal-aware floor across the transcript/composer and secondary overlays rather than only fixing the idle chat view. Coverage includes: - transcript/composer and main layout; - session switcher; - prompts and approval/masked-prompt surfaces; - Skills, Plugins, Model and Pet pickers; - Journey, queued messages, branding, grid overlays, and ticker. The regression suite includes deterministic narrow-layout tests plus a native CI smoke that launches the installed `hermes` in a real 48x18 PTY, requires meaningful output without fatal startup markers, sends a terminal Ctrl-C, and verifies clean interactive shutdown. ## Current Desktop on Termux `hermes desktop` now has a native Termux transport instead of attempting to run Electron binaries built for desktop Linux. On Termux it: 1. installs/builds only the real current Desktop Vite renderer with lifecycle scripts disabled, so Electron/node-pty glibc-native install hooks are never executed; 2. installs the official `x11-repo`, `termux-x11-nightly`, and Chromium packages; 3. detects/acquires the official Termux:X11 Android companion when needed, resolving the current GitHub release asset and verifying its published SHA-256 before invoking Android package installation; 4. starts or reuses `DISPLAY=:1` and opens Chromium in app mode under Termux:X11; 5. serves the same Desktop session on loopback, so `http://127.0.0.1:9119` can also be opened directly in the phone browser. The server remains loopback-only. The regression policy forbids wildcard binding, Chromium `--no-sandbox`, privileged PR regression containers, and seccomp-disabled PR containers. ## Browser-hosted Desktop compatibility The real Desktop renderer installs a compatibility bridge only when Hermes's loopback server injected its session token and no Electron preload bridge exists. Electron hosts remain unchanged. The Termux browser host maps the Desktop contract onto the authenticated Hermes backend, including: - `/api/*` and `/api/ws` with profile scoping; - backend filesystem + in-app path selection and default-CWD discovery; - Git/worktree/review operations; - browser clipboard/microphone/notification equivalents; - image attachment persistence through the existing validated chat image-upload API; - safe browser-local HTML preview staging; - a real interactive Desktop terminal rail over a separate authenticated, loopback-only shell PTY WebSocket (`start/write/resize/onData/onExit/cwd/dispose`). The terminal transport buffers early output, streams UTF-8 correctly across split frames, and uses the native Termux shell rather than an Electron/node-pty binary. ## CI / regression coverage A blocking Termux footgun checker is now part of normal Python lint CI. It catches Android-specific regressions such as host package managers, fake platform leakage, accidental Electron execution, public Desktop binding, unsafe Chromium flags, unverified X11 APK acquisition, and privileged Termux PR containers. A dedicated `Termux regression` workflow runs on native `ubuntu-24.04-arm` and the exact pinned official Termux aarch64 Docker image used by the canonical wheelhouse builder. It runs the real native installer and exercises: - installed CLI/version/help; - environment detection; - Desktop renderer-only build through `hermes desktop --build-only`; - Ink/TUI build, typecheck, narrow-layout tests, and real 48x18 PTY launch/shutdown; - Desktop typecheck and browser bridge/filesystem/preview tests; - Termux X11/Chromium package availability; - a live authenticated Desktop server in Termux Chromium at phone dimensions; - zero horizontal overflow/root errors/request failures; - the actual Desktop terminal rail executing a unique marker command and returning its cwd. ## Final validation (current head `a8e647964b`) - **Native Termux regression is green on current head:** run `31593752221`. It completed the real native installer, immutable 90-package wheelhouse verification, 91-package compatibility/import smoke, renderer-only Desktop build, TUI build/typecheck, 9/9 narrow-layout tests, a real 48x18 PTY launch/shutdown, Desktop typecheck + focused UI tests, Termux:X11/Chromium package checks, and sandboxed Termux Chromium at 390x844 and 320x568. The browser-hosted Desktop terminal executed a unique shell marker successfully and returned its native Termux cwd. - **Full CI executable checks are green on current head:** run `31593753330`. The two regressions found during finalization were fixed and revalidated: Python slice 8/12 (managed-runtime resolution policy) is green, and the full Node 26 `ui-tui / check` is green. Desktop UI, Windows/macOS, docs, Ruff/ty, both footgun gates, OSV, supply-chain, lockfile, installer, and the remaining Python/JS jobs are green. - The full CI workflow is marked failed only because `Review label gate / Review label gate` requires the maintainer-owned `ci-reviewed` label; `All required checks pass` inherits that policy failure. The PR also retains the maintainer `needs-decision` label. This change does not attempt to bypass either review gate. - **Docker Build, Test, and Publish is green on current head:** run `31593752252`, including the repository's amd64/arm64 integration path. - Local/Devbox regression work additionally passed the focused Python Termux/update/backend suites, full Termux and Windows footgun scans, workflow/YAML/diff hygiene, and a clean Linux Node 26/npm 11 parity run of `npm run --prefix ui-tui check` with 139 test files / 1,547 tests passed (1 skipped). - The browser smoke no longer imports Playwright (which rejects Node's native Android platform). It drives the installed sandboxed Chromium directly through Chrome DevTools Protocol using Node built-ins; a blocking Termux footgun rule prevents Playwright or `--no-sandbox` from being reintroduced there. - No `uv.lock` or `package-lock.json` drift was introduced. ## Type of change - [x] Bug fix - [x] Feature / platform support - [x] Tests / CI - [x] Security hardening - [x] Documentation ## How to test on a phone ```bash TERMUX_VERSION=0.118 PREFIX=\"$PREFIX\" \\ bash scripts/install.sh \\ --branch fix/termux-native-install-chain \\ --skip-setup hermes --version hermes hermes desktop ``` For Desktop, install/approve the Termux:X11 Android companion if Android prompts on the first run. Hermes opens the real Desktop renderer through Termux:X11 and also prints the loopback browser URL for the phone browser.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/67163",
        "createdAt": "2026-07-18T22:00:32Z",
        "updatedAt": "2026-08-13T17:34:52Z",
        "timestamp": "2026-08-13T17:34:52Z",
        "metrics": {
          "reactions": 0,
          "comments": 10
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/tui",
          "P3",
          "needs-decision",
          "sweeper:risk-compatibility",
          "sweeper:risk-automation",
          "sweeper:blast-contained",
          "comp/desktop",
          "area/install-update"
        ],
        "author": "adybag14-cyber",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:67336",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): recover Codex reasoning-only stalls with a nudge + fallback ladder (#67321)",
        "text": "## What does this PR do? Codex/Responses turns can return several consecutive `status=\"incomplete\"` responses that carry only **encrypted** reasoning items — no final text, no tool call. The merged reasoning-only nudge (#, grok-4.20 on xai-oauth) only fires when the interim has *nothing to replay* (plain-text reasoning, no `encrypted_content`). Encrypted reasoning items **do** replay byte-for-byte, so a bare retry is identical to the request that just failed and the model deterministically repeats the stall. After three attempts the turn dies with: ``` Codex response remained incomplete after 3 continuation attempts ``` …and never reaches the configured fallback provider. This is distinct from context overflow, output-token exhaustion, or a transport exception, and distinct from the merged #64764 (which handled reasoning-only responses marked `completed`) — this is the remaining `status=incomplete` recovery/fallback path. The fix tracks a per-turn **consecutive reasoning-only streak** (no visible answer, no tool call) separate from the aggregate incomplete counter and drives a recovery ladder off it: - **streak 1** → replay the provider state once (unchanged) - **streak 2** → append the existing continuation nudge, now also for **encrypted** reasoning-only interims (previously nudged only when nothing was replayable) - **streak 3** → hand the turn to the configured fallback via `_try_activate_fallback(reason=FailoverReason.incomplete_response)` — a new semantic failover reason — instead of returning the terminal sentinel Visible/replayable partial progress **resets the streak**, so the mixed-partial sequence (one visible partial, then an encrypted reasoning-only streak) reaches its own threshold instead of dying early on the aggregate counter; the turn-wide iteration budget stays the hard bound. When the triggering response consumed the iteration budget, exactly one **bounded grace call** is granted (reusing the existing dormant `_budget_grace_call` hook) so the fallback actually runs rather than the loop exiting first. Cross-protocol cleanup is handled by existing machinery, not new code: `_try_activate_fallback` rewrites the `Model:`/`Provider:` system-prompt identity (synced into the in-flight system message), and crossing to a non-Codex provider flips `agent.api_mode`, which makes the existing `drop_codex_reasoning_items` gate strip the opaque replay state from the wire while preserving tool evidence and role ordering. When no fallback is configured, the terminal sentinel is preserved (no regression). ## Related Issue Fixes #67321 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `agent/conversation_loop.py` — add the reasoning-only streak, extend the nudge to encrypted interims (streak ≥ 2), and route streak ≥ 3 to the fallback provider with a bounded grace call when the budget was consumed; sync the failover system identity. - `agent/error_classifier.py` — add `FailoverReason.incomplete_response`. - `agent/turn_context.py` — reset `_codex_reasoning_only_streak` per turn. - `tests/run_agent/test_run_agent_codex_responses.py` — 5 tests: encrypted-nudge-after-replay, streak→fallback, no-fallback sentinel, visible-partial resets streak (mixed-partial), and bounded grace call. - `tests/agent/test_error_classifier.py` — cover the new enum member. ## How to Test ``` scripts/run_tests.sh tests/run_agent/test_run_agent_codex_responses.py tests/agent/test_error_classifier.py tests/agent/test_turn_context.py ``` Result: all pass (110 in the codex_responses file, including the 5 new tests). Also ran, all green: `tests/run_agent/test_run_agent.py`, `tests/agent/test_codex_responses_adapter.py`, `tests/gateway/test_incomplete_gateway_turns.py`, `tests/run_agent/test_24996_fallback_exhaustion_cooldown.py`, `tests/run_agent/test_32646_fallback_429_after_timeout.py`, `tests/run_agent/test_message_sequence_repair.py`. New-test coverage maps to the issue's scenarios: 1. Pure encrypted reasoning-only → nudge after the state has replayed once, then recover. 2. Streak 3 activates the fallback with the `incomplete_response` reason. 3. Mixed partial-then-reasoning-only → streak resets on visible progress and still reaches its own threshold despite the aggregate counter ≥ 3. 4. Bounded grace call when the third reasoning-only response lands on the last budgeted iteration (exactly one extra call). 5. No fallback configured → terminal sentinel preserved. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix - [x] I've run the affected tests and they pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin 25.5.0) ### Documentation & Housekeeping - [x] I've updated relevant documentation (docstrings/comments) — or N/A - [x] `cli-config.yaml.example` — N/A (no config keys) - [x] `CONTRIBUTING.md`/`AGENTS.md` — N/A (no architecture/workflow change) - [x] Cross-platform impact — N/A (pure control-flow, no OS-specific code) - [x] Tool descriptions/schemas — N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/67336",
        "createdAt": "2026-07-19T05:42:13Z",
        "updatedAt": "2026-08-13T15:19:32Z",
        "timestamp": "2026-08-13T15:19:32Z",
        "metrics": {
          "reactions": 2,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/openai",
          "P2",
          "codex",
          "sweeper:risk-session-state",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "PRATHAMESH75",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:67426",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(write_file): detect cwd-shaped relative paths missing leading slash",
        "text": "## Problem When a model emits a *relative* path that textually mirrors the working directory (e.g. `home/user/dev/notes/x.md` — an absolute path missing its leading `/`), `write_file` silently creates a doubled path like `/home/user/dev/home/user/dev/notes/x.md` instead of failing or writing to the intended location. Closes #67185. ## Root cause `_resolve_path_for_task()` in `tools/file_tools.py` joins relative paths with the task base directory. A path like `home/user/dev/notes/x.md` passes through unchanged, and the join produces a doubled path because the path already contains the base dir as a prefix. ## Fix Added a **structural check** inline in `_resolve_path_for_task()` before the base-dir join: if a relative path reproduces the base directory tail as its own prefix, prepend `/`. No hard-coded allowlist — the check works for any root directory the model might drop the slash on. Example: `base_dir = /home/user/dev`, `filepath = \"home/user/dev/notes/x.md\"` → `base_dir.lstrip(\"/\") = \"home/user/dev\"` → `filepath.startswith(\"home/user/dev/\")` → True → prepend `/`. ## Changes per maintainer review (teknium1) - Removed `_ABSOLUTE_PATH_ROOTS` frozenset (brittle allowlist) - Removed `_coerce_missing_leading_slash()` helper - Added inline structural check in `_resolve_path_for_task()` before the base_dir join - Added regression tests through `write_file_tool` in `test_file_tools_cwd_resolution.py` ## Testing - Regression tests in `test_file_tools_cwd_resolution.py`: - `test_cwd_shaped_relative_path_prepends_slash` — unit test for the structural check - `test_cwd_shaped_relative_path_through_write_file` — integration test through write_file_tool - `test_legitimate_relative_path_not_affected` — normal relative paths are untouched - All 42 existing tests in the suite continue to pass ## Checklist - [x] Bug fix (non-breaking change which fixes an issue) - [x] No new dependencies - [x] Tests added that prove the fix is effective - [x] Commit references the issue",
        "url": "https://github.com/NousResearch/hermes-agent/pull/67426",
        "createdAt": "2026-07-19T10:31:13Z",
        "updatedAt": "2026-08-13T15:23:44Z",
        "timestamp": "2026-08-13T15:23:44Z",
        "metrics": {
          "reactions": 0,
          "comments": 16
        },
        "labels": [
          "type/bug",
          "tool/file",
          "P2",
          "needs-decision",
          "sweeper:blast-moderate"
        ],
        "author": "rkfshakti",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:67471",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cli): run -w/--worktree in one-shot mode instead of silently ignoring it (#67458)",
        "text": "## What does this PR do? `hermes -z \"…\" -w` accepts `-w/--worktree` but silently ignores it. One-shot mode had no worktree lifecycle — `run_oneshot` (`hermes_cli/oneshot.py`) never set one up, and both `-z` dispatch sites in `hermes_cli/main.py` forwarded neither the flag nor any isolation. So the agent runs in the caller's cwd and its commits land on the checked-out branch, instead of the isolated branch `-w` implies — a data-loss-adjacent surprise for scripts that rely on `-z … -w` for isolation. This PR **honors** `-w` in one-shot mode instead of dropping it, matching the documented contract (`website/docs/user-guide/git-worktrees.md`, `cli.md` both advertise `hermes -w -z`) and the interactive `hermes -w` behavior. It reuses the existing worktree lifecycle rather than adding a parallel one. ## Related Issue Fixes #67458 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_cli/oneshot.py`: `run_oneshot()` gains a `worktree: bool` parameter. When set, it creates a disposable worktree on a dedicated `hermes/…` branch, `chdir`s into it for the run, and tears it down on exit (kept only if it has unpushed commits) — reusing `cli._setup_worktree` / `_cleanup_worktree`, the same helpers the interactive path uses. Setup/cleanup notices are routed to **stderr** so stdout still carries only the final response; a setup failure exits `2` without running the agent. - `hermes_cli/main.py`: both one-shot dispatch sites (Termux fast-CLI path and main dispatch) now forward `worktree=getattr(args, \"worktree\", False)`. The previous hard-reject helper is removed. - Tests: - `tests/hermes_cli/test_oneshot_worktree.py` (new): lifecycle coverage — agent runs inside the worktree, cwd restored afterward, cleanup runs even when the agent raises, setup failure returns `2` without running the agent, and no-op when `-w` is absent. - `tests/hermes_cli/test_tui_resume_flow.py`: dispatch-level coverage that `-w` forwards `worktree=True` through **both** the main and Termux one-shot routes. ## How to Test 1. `hermes -z \"make a commit adding a line to README\" -w` now runs the agent in a fresh `.worktrees/hermes-<hash>` worktree on branch `hermes/hermes-<hash>`; the caller's checked-out branch is left untouched. If the run produces unpushed commits, the worktree is preserved and its path printed to stderr. 2. `scripts/run_tests.sh tests/hermes_cli/test_oneshot_worktree.py tests/hermes_cli/test_tui_resume_flow.py` → all pass. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix - [x] I've run the affected tests and they pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin 25.5) ### Documentation & Housekeeping - [x] I've updated relevant documentation — N/A (this makes the already-documented `hermes -w -z` behavior actually work; no doc change needed) - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A - [x] I've updated `CONTRIBUTING.md`/`AGENTS.md` — N/A - [x] I've considered cross-platform impact — reuses the existing cross-platform worktree helpers - [x] I've updated tool descriptions/schemas — N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/67471",
        "createdAt": "2026-07-19T11:38:42Z",
        "updatedAt": "2026-08-13T15:16:52Z",
        "timestamp": "2026-08-13T15:16:52Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/cli",
          "P2",
          "sweeper:risk-compatibility",
          "sweeper:blast-contained"
        ],
        "author": "PRATHAMESH75",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:67495",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(signal): add policy-aware read receipts",
        "text": "## Summary Add policy-aware outbound Signal read receipts as a focused slice of #39043. - keep read receipts **disabled by default** - expose the behavior through `signal.send_read_receipts` in `config.yaml` (no new behavioral env var) - schedule `sendReceipt` only after Signal's story/content/group/mention filters pass and the gateway's authorization callback returns true - use the original message author's ACI/UUID (phone number fallback) and the data-message timestamp, including for group messages - skip unauthorized senders, explicit routes to unserved or different transport profiles, Note to Self, and malformed/missing timestamps - track the best-effort receipt task through adapter shutdown without delaying message handling ## Why this shape signal-cli v0.14.6 supports `sendReceipt`; Hermes uses its canonical typed `targetTimestamps: Vec<u64>` JSON-RPC shape. Its daemon-level `--send-read-receipts` flag runs before Hermes can apply adapter filters or gateway authorization. The Hermes option therefore stays opt-in and the docs warn operators not to combine the two policies. An accepted RPC or empty `results` list is not treated as proof that the remote sender saw a receipt: signal-cli still respects the account's Signal read-receipt privacy setting. ## Policy decision Proceed with Hermes-owned, opt-in receipts at the adapter layer. This matches current Hermes practice: WhatsApp exposes `send_read_receipts` disabled by default and sends only after adapter policy filters, while BlueBubbles likewise keeps receipt behavior in adapter configuration. Signal's daemon-level switch is a worse fit because it runs before Hermes authorization and filtering. Keeping Signal disabled by default preserves existing behavior and the privacy-safe default. The implementation direction is resolved and the PR is ready for normal upstream review. ## Multiplex profile boundary The built-in Signal adapter receives the existing GatewayRunner back-reference so profile routing is resolved before receipt authorization. Primary and secondary Signal transports are stamped with their owner profile. A receipt is never sent when an explicit route is rejected or selects a different transport profile, because that adapter's authorization callback cannot establish the other profile's policy. This fixes a production-ordering edge found in final review: before the runner wiring, a primary Signal adapter could authorize and schedule a receipt under the default profile, after which normal ingress would resolve the route and drop the message as targeting an unserved profile. ## Prior art and credit This branch preserves Nigel Gibbs' original commit/authorship from #3705, then modernizes it for current main. It also incorporates lessons from the closed #1958, #4769, and #5014 attempts: - consistent opt-in default - no non-secret behavior flag in `.env` - authorization and adapter-filter gating - Note-to-Self protection - correct group-author semantics - lifecycle-managed background work - current-main tests and documentation ## Configuration ```yaml signal: send_read_receipts: true ``` ## Validation Refreshed conflict-free against `main@88ab589f6` on 2026-08-13. - focused Signal + profile-routing/multiplex/hook matrix after refresh: **123 passed, 0 failed** - contract coverage pins the canonical plural-array receipt payload - real factory/envelope-path tests cover active-profile, served cross-profile, and unserved-profile routes plus secondary transport stamping - Ruff and `git diff --check`: clean - independent, adversarial, and Claude Opus 4.8 final reviews: PASS after reproducing and fixing the factory/routing-order edge - no live gateway message was sent",
        "url": "https://github.com/NousResearch/hermes-agent/pull/67495",
        "createdAt": "2026-07-19T12:58:00Z",
        "updatedAt": "2026-08-13T13:25:57Z",
        "timestamp": "2026-08-13T13:25:57Z",
        "metrics": {
          "reactions": 0,
          "comments": 9
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "platform/signal",
          "area/config",
          "P3",
          "sweeper:risk-message-delivery",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "poisdahl",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:67667",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(codex): preserve azure reasoning replay ids with hostname-aware matching",
        "text": "## What does this PR do? Fixes Azure AI Foundry/OpenAI Responses replay failures by preserving reasoning item `id` fields only on Azure Foundry-style endpoints, while keeping the existing OpenAI/Codex behavior that strips those ids from general Responses replay. Based on the original PR #63264 by @LeonSGP43. This version applies the review feedback from @teknium1: uses `base_url_host_matches()` from `utils.py` instead of bare `in` substring matching to avoid false-positive Azure detection when Azure domain text appears in a relay/proxy URL path. ## Related Issue Closes #63257 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Preserve replayed reasoning `id` fields for Azure AI Foundry/OpenAI Responses endpoints in `agent/codex_responses_adapter.py` - Thread Azure endpoint detection through `agent/transports/codex.py`, `agent/conversation_loop.py`, `agent/chat_completion_helpers.py`, `agent/auxiliary_client.py`, and `run_agent.py` - Uses `base_url_host_matches()` (hostname-aware) instead of substring `in` matching for security — prevents relay URLs with \"services.ai.azure.com\" in their path from false-positive detection - Add regression tests covering adapter replay normalization, transport build/preflight behavior, and auxiliary Codex replay behavior ## How to Test 1. Reproduce with an Azure AI Foundry/OpenAI Responses endpoint and a prior assistant turn containing replayable `codex_reasoning_items` 2. Run `uv run pytest tests/agent/test_codex_responses_adapter.py tests/agent/transports/test_codex_transport.py tests/agent/test_auxiliary_client.py -q` 3. Confirm replayed reasoning items sent to Azure retain `{type, id, encrypted_content, summary}` while non-Azure Responses paths continue stripping the reasoning `id` ## Checklist - [x] Bug fix - [x] Tests added - [x] Uses `base_url_host_matches()` per review feedback on PR #63264",
        "url": "https://github.com/NousResearch/hermes-agent/pull/67667",
        "createdAt": "2026-07-19T20:19:00Z",
        "updatedAt": "2026-08-13T17:43:30Z",
        "timestamp": "2026-08-13T17:43:30Z",
        "metrics": {
          "reactions": 0,
          "comments": 9
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/openai",
          "P2",
          "needs-decision",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "vidarak",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:67934",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix: use native Ollama tags for local model discovery",
        "text": "## Current upstream state - Current published head: `0fdc254cb1cd50eeec62871267c3e6dae4ea2ad6` - Base `main`: `6564f319a647b47de391cab2f608660323804a2b` - Server-side `update-branch --rebase`: already up-to-date - Current GitHub state: `MERGEABLE / BLOCKED`; no checks reported and no review is attached to this head. ## Follow-up update Started from the exact GitHub head `7e154c8c98022892391f8c642a21994c9db31d96`, locally rebased onto current upstream `main` at `0a62610f10cc34d696b2239b2c69fa1ba0f1ca63`, resolved one conflict in `hermes_cli/models.py` while preserving current-main logging and cache-age behavior, and force-pushed the exact tested head `596195c2eb9e64216bb4ed112c5119b8054e5a93`: - Non-native `ollama` endpoints, including Ollama Cloud, retain generic `/v1/models` discovery instead of returning an empty catalog. - Native `/api/tags` requests resolve configured `api_key`/`key_env` credentials into an Authorization header while preserving endpoint-specific header isolation. - Empty native Ollama catalogs remain cacheable as authoritative results for the picker. - Focused and overlapping-upstream suite: **234 passed, 0 failed** across 18 files. - `uv run` Ruff check: passed; `git diff --check`: passed. The earlier head/base and test references below are historical evidence from superseded heads. The two blocking review findings are addressed in the current published tree; fresh maintainer review is still requested. ## What does this PR do? Local Ollama's authoritative installed-model catalog is `GET /api/tags`. Hermes previously relied on the OpenAI-compatible `/v1/models` path in discovery, validation, picker, and setup flows; that endpoint is not always available or authoritative, so Hermes could show an empty picker or warn that an installed model was missing. This PR routes verified local Ollama endpoints through `/api/tags`. It does not add hard-coded model defaults: the local server remains the source of truth. Ollama Cloud and ordinary custom OpenAI-compatible endpoints keep their existing catalog paths. This is the active Vadelma-authored continuation of closed PR #49728. It has received additional cache, URL, origin, header, and credential-isolation hardening since that PR was closed, so it intentionally no longer claims byte-for-byte identity with the old head. ## Related Issue No standalone issue. Supersedes closed PR #49728. ## Type of Change - [x] 🐛 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 - Add native local Ollama probing through `/api/tags` and normalize common local URL forms, including scheme-less `OLLAMA_HOST`, `/v1`, and pasted `/v1/models` URLs. - Share a conservative `should_use_ollama_native_catalog()` decision across discovery, validation, picker, and interactive setup. - Restrict native probing to configured or positively verified Ollama roots; cloud and generic custom endpoints remain on OpenAI-compatible discovery. - Preserve explicit `models:` catalogs while treating singular `model:` as the active selection rather than a catalog restriction. - Preserve endpoint-specific headers and credentials only across matching normalized origins, including alias and fallback paths; prevent stale credentials from crossing origins. - Cache successful and failed probes with bounded invalidation behavior, preserving a stale catalog only when the endpoint is unreachable rather than authoritatively empty. - Add regression coverage for local discovery, validation, picker/setup paths, URL and origin handling, cache state, header suppression, alias credentials, and cloud/custom exclusions. ### Reviewable commit structure The original 18-commit development history has been regrouped into three dependency-ordered commits: 1. `fix(models): discover local Ollama catalogs via native tags` — native transport, URL normalization, cache/stale-result semantics, endpoint/header safety, validation, and core tests. 2. `fix(cli): use native Ollama catalogs in setup and pickers` — setup and provider-picker integration, explicit/saved model behavior, endpoint verification, and hermetic picker tests. 3. `fix(cli): protect credentials when switching Ollama aliases` — runtime switching and fallback behavior, with credentials and configured headers retained only for matching endpoint origins. ## How to Test 1. Run the focused PR and overlapping-upstream regression suite: ```bash testhome=$(mktemp -d) HOME=\"$testhome\" HERMES_HOME=\"$testhome/.hermes\" \\ GH_CONFIG_DIR=\"$testhome/gh\" CODEX_HOME=\"$testhome/codex\" \\ CLAUDE_CONFIG_DIR=\"$testhome/claude\" XDG_CONFIG_HOME=\"$testhome/xdg\" \\ HERMES_PYTHON=.venv/bin/python scripts/run_tests.sh -j 4 \\ tests/hermes_cli/test_models.py \\ tests/hermes_cli/test_model_validation.py \\ tests/hermes_cli/test_model_switch_custom_providers.py \\ tests/hermes_cli/test_custom_provider_model_switch.py \\ tests/hermes_cli/test_list_picker_providers.py \\ tests/hermes_cli/test_inventory.py \\ tests/gateway/test_context_ref_expansion_runtime.py \\ tests/gateway/test_model_command_flat_string_config.py \\ tests/gateway/test_model_picker_persist.py \\ tests/gateway/test_session_info.py \\ tests/hermes_cli/test_apply_model_switch_result_context.py \\ tests/hermes_cli/test_context_switch_guard.py \\ tests/hermes_cli/test_custom_provider_context_length.py \\ tests/hermes_cli/test_custom_provider_extra_headers.py \\ tests/hermes_cli/test_custom_provider_tls.py \\ tests/hermes_cli/test_model_switch_context_display.py \\ tests/run_agent/test_credential_rotation_route_settings.py \\ tests/run_agent/test_switch_model_context.py ``` 2. Run Ruff and whitespace validation on the PR files: ```bash .venv/bin/ruff check hermes_cli/model_setup_flows.py hermes_cli/model_switch.py hermes_cli/models.py \\ tests/hermes_cli/test_custom_provider_model_switch.py \\ tests/hermes_cli/test_list_picker_providers.py \\ tests/hermes_cli/test_model_switch_custom_providers.py \\ tests/hermes_cli/test_models.py git diff --check origin/main...HEAD ``` 3. Optional live check: point `providers.ollama.base_url` at a local Ollama endpoint where `/api/tags` succeeds and `/v1/models` is unavailable, then confirm the picker and model validation use the native catalog. The hermetic suite does not require a live service. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass — focused and overlap suites were run instead; see below - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Raspberry Pi OS / Linux, with a real Hailo-Ollama-backed Ollama-compatible adapter; separate stock Ollama and llama.cpp canaries were run on an NVIDIA DGX Spark ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — N/A; behavior and docstrings are covered in code - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A; no config keys changed - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A; no model tool schema changed ## Screenshots / Logs Current published head `5cd88f66e07ddf53aef375e94fa2c60dfdac61fe (superseded)` on upstream base `a97b6ff8f646f197efa14d405a1130c9951dcdd9 (superseded)`: ```text focused PR + overlapping-upstream suite: 484 passed, 0 failed ruff check: pass git diff --check: pass ``` The full `pytest tests/ -q` suite was collected (`47,179` tests, with `63` deselected), but its run was not used as PR evidence because the environment produced unrelated `.hermes/logs/agent.log` logging errors. It is not reported as passing. Real-endpoint dogfood was kept separate from the hermetic pytest suite: - On a Raspberry Pi 5 / Raspberry Pi OS host, Hermes connected to Hailo-Ollama through the public [Hailo-Ollama-to-OpenClaw adapter](https://github.com/tishyk/hailo-ollama-openclaw-adapter), using the adapter's Ollama-compatible HTTP API. Native `/api/tags` returned the installed catalog and `/v1/models` returned `404`. - On an independent NVIDIA DGX Spark, the official `ollama/ollama` container returned `qwen3:1.7b` through native `/api/tags`; Hermes' Ollama discovery returned the same model, and a native `/api/chat` smoke test returned `OLLAMA_CANARY_OK`. - On an NVIDIA DGX Spark, the official upstream llama.cpp container returned the model through generic `/v1/models`; Hermes' generic discovery returned the model, while `/api/tags` correctly returned `404`. The stock Ollama result is independent of both Hailo-Ollama and the adapter, confirming that `/api/tags` is an Ollama-native local catalog path rather than a Hailo-specific workaround. The llama.cpp result separately confirms the generic OpenAI-compatible fallback path. ## Rebase follow-up — 2026-08-03 Resolved the local conflict against live Hermes `main` and published the final rebased head: - Base: `4e698cd471164cb70ebf9f8e5d640f9b7eaf1f07` - Head: `b94ded6942eee6ccd6f94747698c878231a42295` - Validation: 239 focused tests passed; Ruff passed; `git diff --check` passed; no unresolved conflict markers - The native Ollama catalog behavior and current-main picker timeout contract are both preserved. Please review this exact head; earlier review comments refer to superseded commits.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/67934",
        "timestamp": "2026-08-13T12:08:09Z",
        "metrics": {
          "reactions": 0,
          "comments": 9
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/cli",
          "provider/ollama",
          "P2",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "vadelma-agent",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:67944",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): scope GET /api/config to config-only profile to unwedge the event loop (#67936)",
        "text": "## What does this PR do? `GET /api/config` is an `async def` handler that runs on the asyncio event-loop thread, but it entered `_profile_scope(profile)` — the skills-aware scope that acquires the process-global `_SKILLS_PROFILE_LOCK` **synchronously**. When a worker thread holds that lock (e.g. slow model-option / skills discovery during Desktop setup), the event loop blocks inside `get_config()` and can no longer flush queued JSON-RPC/WebSocket responses or serve HTTP probes — remote Desktop startup times out at \"Loading Hermes settings\". `get_config()` only resolves configuration through the task-local `HERMES_HOME` override; it never touches the skills module globals that `_SKILLS_PROFILE_LOCK` protects. The sibling `get_schema()` handler directly below it already uses the await-safe, config-only `_config_profile_scope()`. This aligns `get_config()` with that pattern. ## Related Issue Fixes #67936 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_cli/web_server.py`: `get_config()` now uses `_config_profile_scope(profile)` instead of `_profile_scope(profile)`, so the async handler never waits on `_SKILLS_PROFILE_LOCK`. Added a comment explaining the event-loop hazard. - `tests/hermes_cli/test_web_server_profile_unification.py`: new `TestConfigEndpointNonBlocking` regression — while another thread holds `_SKILLS_PROFILE_LOCK`, `get_config()` still returns promptly. On the old `_profile_scope()` path this blocked for the full lock-hold duration. ## How to Test 1. `scripts/run_tests.sh tests/hermes_cli/test_web_server_profile_unification.py -q` → passes (includes the new `TestConfigEndpointNonBlocking::test_get_config_does_not_wait_on_skills_lock`). 2. Manual: hold `_SKILLS_PROFILE_LOCK` in a background thread and call `asyncio.run(web_server.get_config())` — returns in ~0.001s with the fix vs. the full hold duration before it. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix - [x] I've run the affected tests and they pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS (arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — N/A (internal handler scope change) - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — N/A - [x] I've considered cross-platform impact — fix is platform-independent (asyncio + threading lock semantics)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/67944",
        "createdAt": "2026-07-20T08:38:48Z",
        "updatedAt": "2026-08-13T15:07:33Z",
        "timestamp": "2026-08-13T15:07:33Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P2",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate",
          "comp/dashboard",
          "area/profiles"
        ],
        "author": "PRATHAMESH75",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:68248",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat: add needs review tracking for skills and update status output",
        "text": "## Summary Adds recent outcome telemetry for skills and surfaces a `needs_review` signal in curator reporting. ## Why The curator's automatic transitions (active → stale → archived) are driven purely by recency. A skill that is invoked constantly and fails constantly looks identical to one that works fine. This PR adds an outcome dimension so unreliable skills can be flagged. ## What changed ### `tools/skill_usage.py` - Added `bump_outcome(skill_name, success)` to record recent success/failure events - Introduced bounded `recent_outcomes` (last 20), `needs_review`, and `needs_review_since` - `needs_review` flips on at ≥50% failure rate with minimum 4 samples; flips off on recovery - Added `failure_rate()` read-only helper - `set_state(..., STATE_ARCHIVED)` clears `needs_review` ### `agent/curator.py` - Counts `needs_review` candidates during automatic transitions - Includes `needs_review` in curator run summaries ### CLI reporting - Shows `needs_review` skill list in `hermes curator status` - Prints `needs_review=&lt;count&gt;` in `hermes curator run` summaries ### Tests - `test_skill_usage.py`: window capping, threshold crossing, min-sample floor, recovery, archiving clears flag - `test_curator_status.py`: needs_review counting and reporting ## Validation ```bash python -m pytest test_skill_usage.py test_curator_status.py -q",
        "url": "https://github.com/NousResearch/hermes-agent/pull/68248",
        "createdAt": "2026-07-20T22:23:29Z",
        "updatedAt": "2026-08-13T12:58:48Z",
        "timestamp": "2026-08-13T12:58:48Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/cli",
          "tool/skills",
          "P3"
        ],
        "author": "Gabrielnkl",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:68744",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): downscale large images in composer preview to prevent UI freeze",
        "text": "## What does this PR do? Prevents the Desktop renderer from freezing when pasting large images (Retina screenshots >5MB / 6000×4000px) into the chat composer. ### Root cause The paste pipeline reads the full-resolution image into memory, writes it to disk unchanged, then reads it back as a base64 data URL and assigns it directly to an `<img>` element for the attachment thumbnail. Chromium decodes images synchronously on the main thread — macOS ImageIO/vImage blocks for seconds on Retina screenshots, freezing the entire UI. ### Fix Add canvas-based downscaling (`createImageBitmap` + `OffscreenCanvas`) that scales the *thumbnail* to max 2048px on the longest side before it is assigned to the attachment card's `<img>`. The full-resolution data URL stays on the attachment as `previewUrl` — the field the lightbox and download consume — so opening or downloading a large attachment keeps full resolution, and the model still receives the full-resolution bytes via the attached-image upload pipeline. **New file:** `apps/desktop/src/lib/image-resize.ts` — `downscaleDataUrlForPreview(dataUrl, maxLongEdge?)` **Modified:** - `apps/desktop/src/app/chat/hooks/use-composer-actions.ts` — `attachImagePath` now stores a downscaled `thumbnailUrl` alongside the full-resolution `previewUrl` - `apps/desktop/src/store/composer.ts` — `ComposerAttachment` gains `thumbnailUrl?: string` - `apps/desktop/src/app/chat/composer/attachments.tsx` — the pill `<img>` renders `thumbnailUrl ?? previewUrl`; lightbox/download keep `previewUrl` untouched - `apps/desktop/src/lib/chat-runtime.ts` — the in-flight bubble display ref prefers the thumbnail (display-only; model bytes still go via the upload pipeline) ### Why this is safe - Graceful fallback: if `createImageBitmap` / `OffscreenCanvas` are unavailable (jsdom, old Chromium), the thumbnail returns the original data URL unchanged - If downscaling fails, the thumbnail falls back to a 1×1 transparent PNG so the pill never renders a multi-MB data URL; the pill stays clickable and opens the full-resolution lightbox - Only the composer pill thumbnail and the in-flight bubble display ref are downscaled — the full-resolution image is still written to disk, sent to the model, and used by the lightbox and download action - Non-image data URLs (text, PDF) pass through untouched - Images already under 2048px pass through untouched ## Related Issue Fixes #41169 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving tests) ## Test plan - [x] `npx vitest run src/lib/image-resize.test.ts` — 8/8 - [x] `npx vitest run src/app/chat/hooks/use-composer-actions.test.ts` — 15/15 (incl. attachment-level regression: full-res `previewUrl` + separate downscaled `thumbnailUrl`) - [x] `npx vitest run src/lib/chat-runtime.test.ts` — 30/30 (incl. bubble display ref preferring the thumbnail) - [x] `npx vitest run` (full suite) — passed - [x] `npx tsc -p . --noEmit` — clean ### Manual verification (not automated) - Paste a Retina screenshot (Cmd+Shift+3, full screen) into the Desktop composer - Verify the attachment thumbnail appears without freezing - Verify clicking the attachment opens the lightbox at full resolution and the download action saves the full-resolution image - Verify the full-resolution image is still on disk in `~/*/composer-images/` - Verify sending the message includes the full-resolution image to the model ## Notes **Open question:** The same main-thread decode issue can also affect `GeneratedImage` and inline `MEDIA:` references in messages (see #42109). This PR only fixes the composer paste path. A follow-up could apply the same downscaling to those components if needed.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/68744",
        "timestamp": "2026-08-13T12:17:03Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "P3",
          "sweeper:blast-moderate",
          "comp/desktop"
        ],
        "author": "DavidMetcalfe",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:68810",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(whatsapp): gate group media downloads",
        "text": "## Summary - consolidate the focused group-ingress classifier from #73465 while preserving its original authorship - enforce WhatsApp group policy before inbound media extraction - couple the access decision to the media-download callback so rejected groups cannot call `downloadMediaMessage()` or write media locally - pass the adapter's effective DM/group policies and allowlists explicitly to the Node subprocess - fingerprint bridge access-policy state and restart a reused bridge when its code or policy is stale - preserve downstream Python authorization as defense in depth ## Problem The Python adapter applies group-policy checks after the Node bridge has already extracted an incoming event. Media extraction can invoke `downloadMediaMessage()`, so media from a blocked group could touch the local Hermes cache before Python rejects the event. A long-running bridge also survives gateway restarts. Reusing it based only on the `bridge.js` source hash can retain stale authorization after a policy or allowlist change. ## Behavior - `group_policy: open` permits ordinary inbound group events. - `group_policy: allowlist` permits only configured group IDs. - `disabled`, `pairing`, empty, and unsupported policies deny group events at the bridge boundary. - Existing early rejection of all `fromMe` group messages remains unchanged. - DM behavior remains on the existing DM-policy path. - Allowed-group mention/wake-phrase behavior remains downstream and unchanged. - `/health` reports `scriptHash` and `policyHash`; missing or mismatched values force a bridge restart before reuse. - The policy fingerprint includes mode, DM/group policies, effective allowlists, and owner-forwarding state without exposing allowlist contents through `/health`. - Profile-scoped environment resolution and explicit config precedence are preserved when spawning the bridge. ## Consolidation The first commit is #73465's focused ingress change, retaining Waldo's authorship. The subsequent commits add only the distinct safeguards requested in the cross-PR triage: the dispatch-boundary guard, stale-policy fingerprint/restart behavior, and behavior-level no-download regression. ## Verification - `node --check scripts/whatsapp-bridge/{allowlist,bridge}.js` - `node --test scripts/whatsapp-bridge/*.test.mjs` — 26 passed - `pytest tests/gateway/test_whatsapp*.py tests/gateway/test_pairing_allowlist_bypass.py -q` — 142 passed - `ruff check plugins/platforms/whatsapp/adapter.py tests/gateway/test_whatsapp_stale_bridge.py` - Python/Node policy-hash parity check - static security scan of added lines - `git diff --check`",
        "url": "https://github.com/NousResearch/hermes-agent/pull/68810",
        "timestamp": "2026-08-13T12:26:22Z",
        "metrics": {
          "reactions": 0,
          "comments": 12
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "platform/whatsapp",
          "area/auth",
          "P2",
          "needs-decision",
          "sweeper:risk-message-delivery",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "afk1997",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:69252",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix: propagate caption-fallback warning and add voice/audio retry in Telegram caption parse path",
        "text": "## Problem Two error-handling gaps in the Telegram caption parse retry path: 1. **Caption-fallback failure is invisible to the agent.** When the caption-fallback send fails (the file is gone and the caption text is sent alone), the error is logged via `logger.warning` but never appended to the `warnings` list — the agent never knows it happened. 2. **Voice/audio files silently downgraded on caption parse error.** The caption parse retry block was missing the `_VOICE_EXTS` and `_TELEGRAM_SEND_AUDIO_EXTS` type checks that exist in the primary `try` block and the thread-not-found retry block. If an audio or voice file encounters a caption parse error, it falls through to `send_document` instead of `send_voice`/`send_audio`. ## Fix Two changes in `tools/send_message_tool.py`: 1. **Caption-fallback warning propagation:** Added `warnings.append(_sanitize_error_text(_cap_err))` alongside the existing `logger.warning` call in the caption-fallback exception handler. 2. **Voice/audio handling in caption parse retry:** Added the missing `elif ext in _VOICE_EXTS and is_voice:` and `elif ext in _TELEGRAM_SEND_AUDIO_EXTS:` branches to the caption parse retry block, matching the structure of the primary and thread-not-found retry blocks. Split from #69169 (closed as duplicate of #67565 for the send_document basename changes).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/69252",
        "createdAt": "2026-07-22T10:55:43Z",
        "updatedAt": "2026-08-13T15:23:38Z",
        "timestamp": "2026-08-13T15:23:38Z",
        "metrics": {
          "reactions": 0,
          "comments": 8
        },
        "labels": [
          "type/bug",
          "comp/tools",
          "platform/telegram",
          "P3",
          "sweeper:risk-message-delivery",
          "sweeper:blast-moderate"
        ],
        "author": "rkfshakti",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:70184",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix: pass target_model to resolve_runtime_provider in model override rehydration",
        "text": "When the gateway restarts, _rehydrate_session_model_override calls _resolve_runtime_agent_kwargs_for_provider which calls resolve_runtime_provider without passing the model name. The api_mode is resolved from the provider's default transport, not the specific model's required transport. For opencode-go models like mimo-v2.5 that need chat_completions (OpenAI client), they get anthropic_messages (Anthropic client) because that's the provider's default. The resolve_runtime_provider function already has a target_model parameter designed for this purpose — it just wasn't being used in the rehydration path. Fixes #70153",
        "url": "https://github.com/NousResearch/hermes-agent/pull/70184",
        "createdAt": "2026-07-23T16:18:46Z",
        "updatedAt": "2026-08-13T15:21:45Z",
        "timestamp": "2026-08-13T15:21:45Z",
        "metrics": {
          "reactions": 0,
          "comments": 10
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "P2",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery",
          "sweeper:blast-moderate"
        ],
        "author": "rkfshakti",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:70667",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "test(kanban): cover delegated CLI refusal exit status",
        "text": "## Current upstream state - Current published head: `15cec68ab8c83a866aedd61f4a773e7bac706e2e` - Base `main`: `6564f319a647b47de391cab2f608660323804a2b` - Server-side `update-branch --rebase`: already up-to-date - Current GitHub state: `MERGEABLE / BLOCKED`; no checks reported and no review is attached to this head. ## Follow-up update Rebased onto upstream `main` at `4a798f4bce29302c9e981c877753e083f80fe533` and addressed review feedback on the exact published head `133e48c84adb67fb0b58976dd87ded5d19096de8`: - `_run_hermes()` now bounds every CLI subprocess with `timeout=30`. - Process-boundary regression: **1 passed**. - Kanban CLI overlap suite: **3 passed**; delegated-isolation suite: **6 passed**. - `py_compile` and `git diff --check`: passed. The earlier head/base and test references below are superseded by this update. ## What does this PR do? Adds a process-boundary regression test for delegated-child Kanban CLI mutation refusals. The current `main` branch already propagates non-zero command-handler results. This rebased PR intentionally removes the redundant production change and preserves only useful coverage for the real CLI process boundary. ## Bug / contract covered A delegated child must not mutate Kanban. The guard prints the refusal and the process must report failure to the shell rather than success: ```text $ HERMES_DELEGATED_CHILD_CONTEXT=1 hermes kanban comment <task-id> \"text\" kanban: delegate_task child contexts cannot mutate Kanban tasks via the CLI $ echo $? 1 ``` The regression invokes `python -m hermes_cli.main` in separate subprocesses, creates a disposable task in a temporary Hermes home, then verifies the refused comment returns status 1 and retains the refusal message. ## Scope - Test-only change; no production behavior change remains in this PR. - Clears `HERMES_KANBAN_BOARD`, `HERMES_KANBAN_DB`, and `HERMES_KANBAN_WORKSPACES_ROOT` from the subprocess environment so ambient parent settings cannot override the temporary test home. - Existing sibling guard coverage remains in `tests/tools/test_delegate_kanban_isolation.py`; this test adds distinct process-boundary coverage. ## How to test ```text env -u HERMES_DELEGATED_CHILD_CONTEXT -u HERMES_DELEGATED_CHILD_PID \\ UV_PROJECT_ENVIRONMENT=/home/vadelma/projects/hermes-agent-pr49728/.venv \\ uv run --no-sync python -m pytest \\ tests/hermes_cli/test_kanban_cli_exit_status.py -q 1 passed env -u HERMES_DELEGATED_CHILD_CONTEXT -u HERMES_DELEGATED_CHILD_PID \\ UV_PROJECT_ENVIRONMENT=/home/vadelma/projects/hermes-agent-pr49728/.venv \\ uv run --no-sync python -m pytest \\ tests/hermes_cli/test_kanban_cli.py -q 47 passed env -u HERMES_DELEGATED_CHILD_CONTEXT -u HERMES_DELEGATED_CHILD_PID \\ UV_PROJECT_ENVIRONMENT=/home/vadelma/projects/hermes-agent-pr49728/.venv \\ uv run --no-sync python -m pytest \\ tests/tools/test_delegate_kanban_isolation.py -q 16 passed uv run --no-sync python -m py_compile hermes_cli/main.py tests/hermes_cli/test_kanban_cli_exit_status.py git diff --check origin/main...HEAD pass ``` The full repository suite was not run; the focused regression, Kanban CLI overlap suite, delegated-isolation suite, syntax compilation, and diff check passed on the exact published head. ## Provenance - Published head: `53d6cf2478bcd1eaae822d566b26b1a5385ef8f0 (superseded)` - Rebased base: `3070de19637da8868b6bd5d2f60f8d6008f2040f (superseded)` - Source fork: `vadelma-agent/hermes-agent` - Human sponsor and review-response owner: @tmielika No external service or production Kanban database was modified by the test.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/70667",
        "timestamp": "2026-08-13T12:07:59Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P3",
          "sweeper:blast-contained"
        ],
        "author": "vadelma-agent",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:71849",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(tui): add exact code block copying",
        "text": "\"Addition of CopyBlocks I got tired of manually formatting the code block commands Hermes writes. This feature preserves raw fenced content and gives exact-copy blocks in both TUI and CLI frontends. ## What it does ### TUI - Clickable copy controls on every fenced code block - Copies raw content between fences — no box-drawing, no decorations - /cc: single block copies immediately, multiple shows numbered list - Backward search through conversation history - Rejects malformed args like /cc 2abc - Excludes unclosed/streaming fences ### CLI - /copy-code and /cc commands matching TUI behavior - Backward search, numbered list preview, whitespace-accurate clipboard ## Testing - 1421 TUI tests pass, 32 Python tests pass, lint clean, typecheck clean\" ## Infrastructure acknowledgment CopyBlox was independently designed and implemented. While completing and validating this contribution, I used model-inference access provided by [[FreeInference.org](https://freeinference.org/)](https://freeinference.org/), following my report of a caching issue affecting one of its Qwen deployments. For clarity, FreeInference did not commission, direct, review, or pay me for this work. This is not a sponsored contribution or an endorsement by FreeInference, Nous Research, or the Hermes Agent project. I am acknowledging FreeInference because access to capable inference infrastructure can enable meaningful open-source work by developers and researchers who do not have the hardware or budget to run these models themselves. Organizations able to provide GPU capacity, hardware, cloud credits, research funding, or other infrastructure support should consider supporting the FreeInference project so that it can continue making these resources available for open-source development, research, and education.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/71849",
        "createdAt": "2026-07-26T08:24:39Z",
        "updatedAt": "2026-08-13T15:42:09Z",
        "timestamp": "2026-08-13T15:42:09Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "comp/tui",
          "P3",
          "sweeper:risk-compatibility",
          "sweeper:risk-platform-windows",
          "sweeper:blast-moderate"
        ],
        "author": "B-A-M-N",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:72200",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(prompt): agent.skills_catalog_mode to compact the skills catalog on chat surfaces",
        "text": "## What does this PR do? The messaging/chat system prompt re-sends a large stable cache prefix on every turn, and the `<available_skills>` catalog block is its single largest compressible line item. The renderer already has a names-only demotion lever (`build_skills_system_prompt(compact_categories=...)`), but it is gated behind the coding posture and the interactive-coding surface set, so it never fires on chat/messaging surfaces, which is exactly where the tokens burn. This adds `agent.skills_catalog_mode` (`full` | `compact` | `names-only`), resolved once from session-fixed inputs (platform, config) and unioned with the existing coding-posture demotion set before the single render call, so the catalog stays a single cache-safe LRU entry that is byte-identical for the life of a conversation. - `full`: no demotion (default for interactive coding surfaces; today's behaviour). - `compact`: demote the non-coding category deny-list to names-only (default for messaging surfaces). - `names-only`: demote every present category to names-only (max compaction; opt-in). Never hides a skill: names-only demotion drops the description but keeps every name visible and loadable via `skill_view` / `skills_list`. ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Refactor (no behavior change) - [x] Tests (adding or improving test coverage) ## Changes Made - `agent/coding_context.py`: new pure resolver `resolve_skills_catalog_compaction(*, platform, config)` + `_skills_catalog_mode()` (config precedence over per-surface default). Reads no turn-varying state. - `agent/prompt_builder.py`: `ALL_SKILL_CATEGORIES` sentinel (a distinct empty `frozenset` subclass) for names-only; renderer short-circuits \"all present categories demoted\" and keys the LRU cache distinctly from `full`. - `agent/system_prompt.py`: resolve the catalog set alongside the coding-posture set and union both into the single `build_skills_system_prompt(compact_categories=...)` call. - `cli-config.yaml.example`: documents the new `agent.skills_catalog_mode` key. No new `HERMES_*` env var, config-only. - `tests/agent/test_coding_context.py`, `tests/agent/test_prompt_builder.py`: behavior-contract tests T1 to T7. ## How to Test ``` pytest tests/agent/test_coding_context.py tests/agent/test_prompt_builder.py -q # 237 passed ``` Contract tests: - **T1** chat default is `compact`, coding surface is `full`. - **T2** every name present under `full` is still present under `compact` and `names-only` (never-hide invariant). - **T3** `compact` drops only non-coding descriptions; `names-only` drops all, each category rendered as a `[names only]` line. - **T4** determinism: resolver + renderer are byte-identical for identical inputs (cache-safety). - **T5** config precedence: profile/base override the per-surface default; empty string falls through. - **T6** measured saving gate (see below). - **T7** no `HERMES_*` env var / `os.environ` read for the mode. ## T6, measured catalog reduction Measured against a representative 204-skill catalog, `<available_skills>` block only, tokens via `tiktoken` `cl100k_base`: | mode | chars | tokens | token reduction | |---|---|---|---| | `full` | 19,786 | 4,678 | n/a | | `compact` | 16,176 | 3,859 | **17.5%** | | `names-only` | 5,295 | 1,390 | **70.3%** | The reduction from `compact` varies with the category mix. On a coding-heavy catalog (many `github` / `gitlab` / `devops` / `software-development` skills that `compact` deliberately keeps full), `compact` reclaims ~17.5%; the full cut comes from `names-only` at ~70.3%, which strips coding descriptions too and is therefore opt-in, not the chat default. The synthetic-catalog unit test (`test_t6_compact_shrinks_block_at_least_50pct`) asserts >=50% on a non-coding-heavy catalog to guard the mechanism; the table above is a real-profile figure. ## Checklist - [x] Tests added and passing (237 passed) - [x] `cli-config.yaml.example` updated for the new config key - [x] No new env var introduced (config-only, per repo `.env`-is-secrets-only rule) - [x] Cache-safety preserved: resolver reads only session-fixed inputs; result joins the existing LRU cache key",
        "url": "https://github.com/NousResearch/hermes-agent/pull/72200",
        "timestamp": "2026-08-13T11:22:54Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "tool/skills",
          "area/config",
          "P3",
          "needs-decision",
          "sweeper:risk-compatibility",
          "sweeper:risk-caching",
          "sweeper:blast-moderate"
        ],
        "author": "wernerhp",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:72367",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(web): validate provider-reported final URLs",
        "text": "## Summary - Validate provider-reported final or canonical URLs before returning web content across Firecrawl, Tavily, Exa, and Parallel. - Fail closed for malformed, private, internal, and unresolved final destinations while suppressing unsafe provider metadata and content. - Preserve safe public results and leave Oxylabs behavior unchanged. ## Verification - Focused final-URL safety suite: 26 passed. - Relevant web-tool regression suite: 333 passed. - Ruff, syntax, diff-scope, and static-security checks passed. - Reconciled cleanly as one commit directly on current upstream main. ## Scope Exactly eight files: four provider integrations, the shared URL-safety helper, and three test files. No dependency, lockfile, deployment, service, production, or Oxylabs changes.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/72367",
        "createdAt": "2026-07-27T01:26:33Z",
        "updatedAt": "2026-08-13T17:59:53Z",
        "timestamp": "2026-08-13T17:59:53Z",
        "metrics": {
          "reactions": 0,
          "comments": 8
        },
        "labels": [
          "type/security",
          "comp/plugins",
          "tool/web",
          "area/auth",
          "P2",
          "needs-decision",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "shaneblick",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:72638",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(openai): configure Responses verbosity",
        "text": "## What does this PR do? Adds first-class `agent.text_verbosity` configuration for OpenAI Responses requests. Valid values are `low`, `medium`, and `high`; empty, unset, or malformed values preserve the provider default. The setting is emitted as top-level `text.verbosity` only for supported GPT-5 requests through Codex OAuth or the exact `api.openai.com` host. It is not sent to chat-completions, xAI/Grok, GitHub Models, custom endpoints, non-GPT models, or unrelated providers. This is a corrected continuation of #63543, not an independent rewrite. Its original two commits and author attribution are preserved. The follow-up commit aligns the config key with #20203, handles malformed config safely, and fails closed across provider-routing edge cases. ## Related Issue Fixes #20203 Related implementations reviewed while preparing this version: #20258, #29574, #59844, #63543. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] 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 - Added centralized parsing and provider capability checks in `agent/text_verbosity.py` and agent initialization. - Merged `text.verbosity` into Responses requests without replacing `request_overrides`, `service_tier`, or sibling `text` fields. - Added Responses adapter validation and gateway cache invalidation for config changes. - Documented the new setting in `cli-config.yaml.example` and the configuration guide. - Added transport, initialization, adapter, config, and gateway-cache regression coverage. ## How to Test 1. Run the focused Responses/config/cache suite: ```bash python -m pytest -q \\ tests/agent/transports/test_codex_transport.py \\ tests/agent/test_codex_responses_adapter.py \\ tests/run_agent/test_run_agent_codex_responses.py \\ tests/hermes_cli/test_config.py \\ tests/gateway/test_agent_cache.py ``` 2. Run the initialization and provider-impact coverage: ```bash python -m pytest -q tests/run_agent/test_run_agent.py -k TextVerbosityConfig python -m pytest -q \\ tests/run_agent/test_provider_parity.py \\ tests/run_agent/test_strict_api_validation.py \\ tests/honcho_plugin/test_async_memory.py \\ tests/honcho_plugin/test_session.py ``` 3. Set the configuration below and confirm a GPT-5 OpenAI Responses request contains `text: {\"verbosity\": \"low\"}`. Confirm the field is omitted when unset and for xAI/Grok, GitHub Models, custom endpoints, non-GPT models, and chat-completions requests. ```yaml agent: text_verbosity: low ``` Tested on macOS 26.5.2 with Python 3.13.11. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't an unnoticed duplicate - [x] My PR contains only changes related to this feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass (focused and impact suites pass; a broader run encountered six unrelated failures reproducible on current `main` in credential/Anthropic tests) - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 26.5.2, Python 3.13.11 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - configuration guide updated - [x] I've updated `cli-config.yaml.example` because this adds a config key - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows - N/A - [x] I've considered cross-platform impact (Windows, macOS) - no platform-specific behavior - [x] I've updated tool descriptions/schemas if I changed tool behavior - N/A ## Screenshots / Logs N/A - this changes request construction and configuration rather than UI.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/72638",
        "createdAt": "2026-07-27T12:12:28Z",
        "updatedAt": "2026-08-13T17:26:47Z",
        "timestamp": "2026-08-13T17:26:47Z",
        "metrics": {
          "reactions": 0,
          "comments": 7
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/cli",
          "comp/gateway",
          "provider/openai",
          "area/config",
          "P3",
          "needs-decision",
          "sweeper:risk-message-delivery",
          "sweeper:risk-compatibility",
          "sweeper:risk-caching",
          "sweeper:blast-contained"
        ],
        "author": "Diaspar4u",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:72671",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "test(gateway): make unset model override explicit in background fixture",
        "text": "## Summary Make the `/background` gateway tests exercise the real **no model override** path instead of a truthy mock value that looks like a persisted override. ## Why this matters `GatewayRunner` rehydrates a session's model override before starting a background task. In production, `SessionStore.get_model_override()` returns `None` when the user has not set a `/model` override. The test fixture used an unconfigured `MagicMock` session store. Its default return value is truthy, so the test could accidentally create a fake model override and pass a `MagicMock` into the session-scoped reasoning resolver. That means the test was not reliably exercising the normal no-override path, and its result could depend on mock behavior rather than the production contract. This is a test correctness issue: the fixture can mask regressions or fail for the wrong reason even though production behavior is unchanged. ## Fix Set: ```python mock_store.get_model_override.return_value = None ``` in `_make_runner()`, matching the real `SessionStore` contract. ## Scope - One test fixture in `tests/gateway/test_background_command.py`. - Four added lines; no production-code, dependency, configuration, or formatter changes. - Existing background-command scenarios now run with an explicit absent override. This should merge because it is a minimal, behavior-preserving correction that makes the gateway regression tests represent the production no-override state they intend to cover. ## Verification Against upstream `main` at `8d4b1e4b0e240af67c1f4eb6601aee99eac0bc49`, exact head `903c867cf8ba0d1737ae52d4ee296a71b09dd0aa`: ```text scripts/run_tests.sh tests/gateway/test_background_command.py -q 7 passed uv run --extra dev ruff check tests/gateway/test_background_command.py All checks passed git diff --check refs/remotes/upstream/main...HEAD passed ``` The canonical test harness ran with its clean environment (`TZ=UTC`, `LANG=C.UTF-8`, `PYTHONHASHSEED=0`). ## Checklist - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md). - [x] The change is narrowly scoped to one test fixture. - [x] The focused regression tests pass with `scripts/run_tests.sh`. - [x] Focused Ruff and whitespace checks pass. - [x] No production behavior, dependency, configuration, or platform adapter changes are included. - [x] The current upstream overlap and existing PR history were checked. No issue link: this is a narrowly scoped upstream test-fixture correction. Human sponsor and review-response owner: @tmielika",
        "url": "https://github.com/NousResearch/hermes-agent/pull/72671",
        "createdAt": "2026-07-27T13:16:14Z",
        "updatedAt": "2026-08-13T17:41:40Z",
        "timestamp": "2026-08-13T17:41:40Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/test",
          "comp/gateway",
          "P3",
          "sweeper:risk-automation",
          "sweeper:blast-contained"
        ],
        "author": "vadelma-agent",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:72756",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(provider/deepseek): apply peak-hour rate multiplier (#72662)",
        "text": "## Summary Implements #72662: Apply DeepSeek peak-hour rate multiplier. DeepSeek announced peak-hour pricing on 2026-07-26 with 2x rate multiplier during: - **01:00 – 04:00 UTC** (daily) - **06:00 – 10:00 UTC** (daily) All other hours: standard rate. ## Changes ### `agent/usage_pricing.py` - Added `_deepseek_peak_hour_multiplier()` — returns `Decimal(\"2\")` when current UTC hour falls in either peak window, otherwise `Decimal(\"1\")`. - Modified `estimate_usage_cost()` — applies the 2x multiplier to the computed amount when `route.provider == \"deepseek\"` and the current time is in a peak window. Adds a note to `CostResult.notes` when the multiplier is active. ### `tests/agent/test_usage_pricing.py` Added 6 new tests: - `test_deepseek_peak_hour_multiplier_peak_window_1` — verifies hours 1–4 return 2x - `test_deepseek_peak_hour_multiplier_peak_window_2` — verifies hours 6–10 return 2x - `test_deepseek_peak_hour_multiplier_off_peak` — verifies hours outside both windows return 1x - `test_estimate_usage_cost_deepseek_peak_hour_applies_multiplier` — end-to-end: DeepSeek call during peak hour costs 2x - `test_estimate_usage_cost_deepseek_off_peak_standard_rate` — end-to-end: same call during off-peak costs standard rate - `test_estimate_usage_cost_non_deepseek_not_affected_by_peak_hour` — invariant: non-DeepSeek providers are unaffected ## Testing All 38 tests pass.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/72756",
        "createdAt": "2026-07-27T16:30:36Z",
        "updatedAt": "2026-08-13T15:24:23Z",
        "timestamp": "2026-08-13T15:24:23Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "provider/deepseek",
          "P3",
          "sweeper:incoherent",
          "area/usage-cost"
        ],
        "author": "webtecnica",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:72761",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(honcho): sync config (supersedes #33440)",
        "text": "Running gateways could keep cached Honcho managers and SDK clients after construction-frozen settings changed, while active session peers retained prior observation settings. This change: - fingerprints every resolved Honcho setting frozen into the gateway agent/provider/manager; - fingerprints secrets without storing raw key material in cache signatures; - rebuilds the process-wide SDK client when its effective workspace, credential, environment, base URL, or timeout changes; - invalidates SDK peer/session objects atomically across configuration changes; - synchronizes explicit observation policy by updating only mismatched peers, disabling observers before enabling replacements; and - preserves server/UI-managed observation settings when no local policy is explicit. Supersedes #33440. Co-authored-by: Dusk1e <yusufalweshdemir@gmail.com> Validation: - `scripts/run_tests.sh tests/honcho_plugin tests/gateway/test_agent_cache.py tests/test_honcho_client_concurrency.py -q` — 373 passed - focused Ruff — passed - worktree and upstream-range `git diff --check` — passed - Windows footgun diff scan — passed - all upstream-range commits SSH-signature verified",
        "url": "https://github.com/NousResearch/hermes-agent/pull/72761",
        "createdAt": "2026-07-27T16:41:29Z",
        "updatedAt": "2026-08-13T13:22:02Z",
        "timestamp": "2026-08-13T13:22:02Z",
        "metrics": {
          "reactions": 0,
          "comments": 5
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "comp/plugins",
          "tool/memory",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-compatibility",
          "sweeper:risk-caching",
          "sweeper:blast-contained",
          "area/memory"
        ],
        "author": "Diaspar4u",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:73052",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(kanban): preserve chat_type and delivery_metadata in subscription inheritance (#73030)",
        "text": "## Problem `_inherit_notify_subs()` in `hermes_cli/kanban_db.py` copies a parent task's notification subscription to a child via `INSERT … SELECT`, but omits the `chat_type` and `delivery_metadata` columns. Inherited child rows silently lose available routing metadata. This affects all three call paths that route through the helper: `create_task(parents=…)`, `link_tasks()`, and `decompose_triage_task()`. **Impact:** - Creator-wake reconstruction falls back to `chat_type=\"group\"`, so a DM-originated child completion wakes a fresh group-scoped session instead of the originating DM session. - Telegram DM-topic subscriptions lose their persisted reply-fallback and topic metadata; `message_thread_id` alone can render outside the intended visible lane. Closes #73030. ## Fix Add `chat_type` and `delivery_metadata` to both the INSERT column list and the SELECT projection in `_inherit_notify_subs`. One logical change — the stored TEXT value is copied as-is and decoded on read by `list_notify_subs` as before. No re-encoding needed. ## Verification - **RED:** 3 regression tests fail on `upstream/main` before the fix (`assert None == 'dm'` — `chat_type` not inherited). - **GREEN:** All 4 targeted tests pass with the fix. - **Nearby suite:** 246 passed (`test_kanban_notify.py` + `test_kanban_db.py`), 0 failed. ## Tests - Extended `_assert_inherited_notify_sub()` to assert `chat_type` and `delivery_metadata` are preserved. - Updated all 3 existing inheritance tests (create_task, link_tasks, decompose_triage_task) to supply `chat_type=\"dm\"` and realistic `delivery_metadata`. - Added `test_inherited_notify_sub_preserves_null_routing_fields` — a parent subscription created without routing metadata must inherit cleanly (NULL → None). ## Competitor analysis No competing open PRs found (issue-number search, topic-overlap keyword search, same-author check — all clean). --- Auto-published by Moonsong via Path B automated pipeline.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/73052",
        "createdAt": "2026-07-28T02:27:07Z",
        "updatedAt": "2026-08-13T17:59:23Z",
        "timestamp": "2026-08-13T17:59:23Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "comp/cron",
          "platform/telegram",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery",
          "sweeper:blast-moderate"
        ],
        "author": "Tranquil-Flow",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:74379",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(hindsight): memory provider improvements — recall_sync, retain_source, setup templates, memory indicators, error hints",
        "text": "Bundles previously-separate Hindsight/memory PRs into a single review surface. Each was independently mergeable; combining them avoids parallel reviews of the same file (`plugins/memory/hindsight/__init__.py`, which most of them touch). ## What's included | Supersedes | Change | Fixes | |---|---|---| | #70278 | **Opt-in synchronous recall** for the current turn (`recall_sync`) — recall the injected memory in-turn instead of next-turn prefetch | #5820 | | #70295 | **Actionable error when the `local_embedded` runtime is missing** — tells the user exactly which package to install instead of a bare import failure | #7718 | | #72926 | **Default `retain_source` to `\"hermes\"`** so every stored memory self-identifies its provenance (`metadata.source: \"hermes\"`) | — | | #73415 | **Offer a starter memory template during `hermes memory setup`**, plus **warn before overwriting** an already-configured bank | — | | #70257 | **Warn when a configured memory provider reports unavailable** (provider-agnostic) | #2765 | | _new_ | **Deterministic \"recalled N memories\" recall indicator** — Hermes itself emits `👁️ Hindsight — recalled N memories` via the status channel when auto-recall injects memory, so the user SEES memory working regardless of whether the model mentions it (`recall_indicator`, on by default) | — | | _new_ | **\"saving to memory\" retain indicator** — `👁️ Hindsight — saving to memory…` emitted the moment a turn is dispatched to the writer, only on real writes (`retain_indicator`, on by default) | — | ## Why the indicators Auto-recall injects memory into the prompt, but whether the user can *see* that Hindsight contributed was previously left to the model — and models routinely decline to mention it, so memory looks like it isn't working even when it is. These indicators are rendered by Hermes itself (`_emit_status`, the same model-independent channel as compression/idle notices), so they always show and can't be silently dropped. Both have off switches for customer-facing agents. ## Scope - Most changes are confined to `plugins/memory/hindsight/**` (+ their tests). - **#70257 and the indicators** are (partly) provider-agnostic and also touch `agent/agent_init.py`, `agent/memory_manager.py`, `agent/memory_provider.py`, `agent/turn_context.py`, and `hermes_cli/memory_setup.py` — the recall indicator adds an opt-in `recall_status()` hook to the base `MemoryProvider` and a `describe_recall()` aggregator on `MemoryManager`; the status channel is wired to the provider via `initialize(status_callback=…)`. ## Tests New/updated deterministic tests across all changes (provider behaviour incl. the recall_sync × indicator interaction, retain-indicator dispatch/off/buffered paths, template setup, local-runtime hint, provider-unavailable warning, env-hint, manager formatting, turn-loop emit wiring). Full affected suite green; `ruff` clean.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/74379",
        "createdAt": "2026-07-29T20:32:53Z",
        "updatedAt": "2026-08-13T17:46:09Z",
        "timestamp": "2026-08-13T17:46:09Z",
        "metrics": {
          "reactions": 0,
          "comments": 7
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/cli",
          "comp/plugins",
          "tool/memory",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:blast-moderate",
          "area/memory"
        ],
        "author": "benfrank241",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:74658",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(slack): read real SDK responses instead of gating on isinstance dict",
        "text": "## What does this PR do? Slack user names, channel names and bot detection are silently broken on `main`: the agent receives `[U0BCE4NRVKN | Slack user <@U0BCE4NRVKN>]` instead of `[Nikita | Slack user <@U0BCE4NRVKN>]`, every user resolves as a non-bot, and some send paths report success as failure. **Root cause.** `slack_sdk` Web API calls return `SlackResponse` / `AsyncSlackResponse`. Those objects are mapping-like (they expose `.get()` and `.data`) but they are **not** `dict` subclasses. Commit `3f08201ba` (\"Fix Slack peer bot status routing loops\", #51627) added `isinstance(result, dict)` guards around those responses, so at runtime the guard is **always** False and every guarded call site takes its \"unexpected shape\" degradation branch: - `_resolve_user_name` → the name becomes the raw user id, and that wrong value is cached for the lifetime of the gateway process. This is the user-visible symptom. - `_resolve_user_is_bot` → every user resolves as a non-bot, which defeats the `allow_bots` peer-bot loop guard that `3f08201ba` was written to add. - `_resolve_channel_name` → the channel name degrades to `C0…`. - `_post_ephemeral_fallback` → a successful ephemeral reply is reported as an `unexpected_response` failure. - `_standalone_upload_file` and the standalone `chat.postMessage` path → `message_id` is lost (breaking threading of follow-up sends) and the caption fallback is never marked delivered. **Fix.** Normalize every Slack response through one helper, `_slack_response_payload()`: a plain `dict` passes through, an SDK response yields `.data`, and anything else (including a binary `.data`, which `files_*` can return) yields `{}` so callers keep their existing fallbacks. This fixes the whole bug class rather than the one reported site — all eight guarded call sites are converted. **Why the test suite did not catch it.** The existing Slack tests inject plain `dict`s into the mocked client, so the guard is True in tests and False in production. The new suite parametrizes every behavioral case to run against a real `AsyncSlackResponse` as well, which is what makes the defect (and any future recurrence) visible. The premise was verified against the runtime, not assumed: - `SlackResponse`/`AsyncSlackResponse` MRO ends at `object` in both `slack_sdk` 3.40.1 and 3.43.0 (the version pinned in `pyproject.toml`); the return type is fixed in the SDK signatures (`users_info(...) -> AsyncSlackResponse`), and Bolt hands out that same `AsyncWebClient`. No bot setting, OAuth scope, or Slack-side API change can flip this. - The symptom was captured on a live gateway (the request dump sent to the model contained the id-for-name prefix), and a direct `users.info` with the same bot token returned the correct `display_name`, ruling out scopes/transport. - Pre-fix behavior was reproduced against a real `AsyncSlackResponse`: `_resolve_user_name` returned `U_HUMAN` instead of `Nikita`; after the fix it returns the name. ## Related Issue No open issue — the symptom (\"Slack display names became user IDs\") does not appear to be reported. The only PR with the same diagnosis, #72062, was closed by its own author without maintainer review; it also bundled two unrelated changes (`_apply_yaml_config` and `success_reaction` / `other_agent_patterns`). This PR is the isolated fix, extended to the remaining call sites of the same bug class and to a test that exercises the real SDK response shape. Fixes the regression introduced by #51627 (`3f08201ba`). ## Type of Change - [x] 🐛 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 - `plugins/platforms/slack/adapter.py`: new module-level `_slack_response_payload()` normalizer; all Slack Web API call sites read through it — `_resolve_user_name`, `_resolve_user_is_bot`, `_resolve_channel_name`, `_post_ephemeral_fallback`, the thread seed-post `ts` read, `_standalone_upload_file`, and the standalone `chat.postMessage` path including the caption fallback. Every `isinstance(resp, dict)` gate on an SDK response is removed. - `tests/gateway/test_slack_sdk_response.py` (new, 23 tests): the normalizer's contract (dict passthrough, `.data`, binary `.data` → `{}`, unknown shape → `{}`), the identity/channel resolution paths, and the send paths — each parametrized over a hand-rolled stand-in **and** a real `AsyncSlackResponse` (skipped automatically when the `slack` extra is not installed). Includes an explicit assertion of the bug's premise (the runtime object fails an `isinstance` dict gate) and keeps coverage for the intended degradation on a genuinely unreadable response. ## How to Test 1. With a real Slack workspace, run the gateway in a shared channel on `main` and send a message: the text handed to the model is prefixed with `[U… | Slack user <@U…>]` — the display name has been replaced by the id. 2. Apply this PR, restart the gateway (the wrong name is cached per process) and start a new session: the prefix becomes `[Alice | Slack user <@U…>]`. 3. Automated: `scripts/run_tests.sh tests/gateway/test_slack_sdk_response.py tests/gateway/test_slack.py tests/gateway/test_slack_mention.py tests/tools/test_send_message_slack.py -q` → **212 passed, 0 failed** (branch cut from current `main`). ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(slack): …`) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate — closest is the author-closed #72062, see \"Related Issue\" - [x] My PR contains **only** changes related to this fix (no unrelated commits) — 1 commit, 2 files - [x] I've run the affected suites via `scripts/run_tests.sh` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — N/A (restores documented behavior; the new helper is documented in its docstring) - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A (no config keys) - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A - [x] I've considered cross-platform impact (Windows, macOS) — N/A (pure Python response handling) - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A (no tool schema changed) ## Screenshots / Logs **Before** (prefix the model receives, from a live gateway's request dump): ``` [U0BCE4NRVKN | Slack user <@U0BCE4NRVKN>] ... ``` **After:** ``` [Nikita | Slack user <@U0BCE4NRVKN>] ... ``` Behavioral side effect worth calling out for review: with the guard removed, `_resolve_user_is_bot` actually recognizes bots again, so the `allow_bots` policy starts enforcing as #51627 intended.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/74658",
        "createdAt": "2026-07-30T06:53:51Z",
        "updatedAt": "2026-08-13T17:32:18Z",
        "timestamp": "2026-08-13T17:32:18Z",
        "metrics": {
          "reactions": 0,
          "comments": 6
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "platform/slack",
          "P3",
          "sweeper:risk-message-delivery",
          "sweeper:blast-moderate"
        ],
        "author": "nikitaBarkov",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:74743",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(junie-acp): JetBrains Junie via ACP — with the self-improvement loop intact (supersedes #69207)",
        "text": "## What does this PR do? Adds **JetBrains Junie** as a native coding-agent provider (`junie-acp`), driven over the **Agent Client Protocol** using the `agent-client-protocol` SDK (already vendored as the `[acp]` extra). Hermes spawns `junie --acp=true` and talks to it as an ACP *client*, exposing Junie through the standard chat surface so it can be selected like any other model/provider. Junie is an autonomous agent that runs its **own** tools inside the ACP session, and this integration still treats it that way: its native `tool_call` notifications are never fabricated as *pending* OpenAI `tool_calls`, so Hermes never re-runs finished work. What changed since #69207 is that this no longer costs Hermes its own agentic surface: - Hermes' agent-level tools (the memory/skills self-improvement loop, `todo`) now reach Junie through the ACP **text bridge** — the pattern `copilot-acp` already used, factored out so both clients share it. - Junie's completed work is **projected into the transcript** as `assistant(tool_calls=[…])` + `tool(result)` rows, so the background-review fork replays real calls, results and errors instead of a one-line activity feed. - The skill-review nudge counter now moves for iterations that happen inside the provider, and the review fork either works or doesn't spawn. That addresses @teknium1's review on #69207 in full (details under *Changes Made*). This PR **supersedes #69207**, which was auto-closed under the in-tree-provider policy while the review fixes were being written — see the last section. Rebased on current `main`. ## Related Issue N/A — new optional provider integration. Supersedes #69207 (closed by hermes-sweeper, not by review). Happy to open a tracking issue if preferred. ## Type of Change - [x] ✨ New feature (non-breaking change that adds functionality) ## Changes Made **Provider integration (as in #69207)** - **New provider client** `agent/junie_acp_client.py`: drives `acp.ClientSideConnection` from a dedicated background asyncio loop, bridged to Hermes' synchronous chat-completions call path. Persistent subprocess reuse and a no-replay-after-prompt-dispatch retry guard (Junie may already have applied side effects). - **Safety bridge** (client-side ACP callbacks): deny-by-default `session/request_permission` (configurable); sandboxed `fs/read_text_file` + `fs/write_text_file` (path-within-cwd enforcement, write-deny for protected files, secret redaction on read). - **Provider wiring**: `plugins/model-providers/junie-acp/` profile + aliases (`junie`, `jetbrains-junie-acp`, `junie-acp-agent`); routing in `agent/agent_runtime_helpers.py` / `agent/auxiliary_client.py`; setup / model-picker / dashboard in `hermes_cli/` (`models.py`, `providers.py`, `model_setup_flows.py`, `model_switch.py`, `setup.py`, `web_server.py`, `auth.py`, `model_normalize.py`, `runtime_provider.py`, `main.py`); `agent/model_metadata.py`, `agent/agent_init.py`, `agent/conversation_loop.py`. - **Model handling**: `-m <id>` forwarded to Junie via `session/set_config_option{model}`; optional Brave Mode override. The `/model` picker lists Junie's **live** advertised models (read from `session/new` `config_options`) layered over the provider sentinel — **without** adding real ids to the static `_PROVIDER_MODELS` reverse-map, so `detect_provider_for_model` still resolves `claude/gemini/gpt` ids to their real providers. The live catalog is cached per account (hashed token, never stored raw) and negatively cached in-memory to avoid re-spawning the JVM on every picker open. - **Observability/robustness**: token usage from `PromptResponse.usage` (with a ~4-chars/token estimate fallback so the context gauge + compressor work), 50 MB stdio buffer, a process-death watcher that rejects in-flight requests instead of hanging, per-call `session/new` + `set_config_option` timeouts, and captured subprocess stderr in failure messages. - **Skill**: adds a `junie` autonomous-ai-agents delegation skill and cross-links it from the sibling skills' `related_skills`. **Review fixes from #69207 — keeping the self-improvement loop alive** - **Hermes tools are reachable again.** `_create_chat_completion` no longer drops `tools` or pins `tool_calls=[]`. Schemas travel into the prompt as text and `<tool_call>` blocks are parsed back out. The copilot-acp bridge moved to a shared **`agent/acp_openai_bridge.py`** (text bridge + stream-chunk shaping); `agent/copilot_acp_client.py` loses ~180 lines of duplication and now imports it. - **Forwarding is an allowlist**, not the whole toolset: Junie keeps running its own read/edit/execute tools, so re-offering those would make Hermes re-run finished work. The set covers the background-review fork's *entire* whitelist, reads included — a review that can call `skill_manage` but not `skills_list`/`skill_view` could only create skills blindly instead of updating the right one. A test keeps the two in sync. `HERMES_JUNIE_ACP_FORWARD_TOOLS` widens/narrows it (`all` forwards everything). - **Transcript projection**: the per-`toolCallId` detail already folded in the client (`rawInput`/`rawOutput`, status, locations) is projected into `messages` as completed `assistant(tool_calls)` + `tool(result)` pairs, mirroring `agent/transports/codex_event_projector.py`. Projected ids are namespaced by session (a raw per-session `t1` repeats across turns and collides where calls pair with results by id), and projected tool names are namespaced `junie_*` so they can never be mistaken for Hermes tools to dispatch. - **Skill-nudge counter**: new `agent/provider_projection.py::splice_provider_projection` splices those rows and ticks `_iters_since_skill` with the iterations that happened inside the provider — what `agent/codex_runtime.py` already does for the codex app-server path. Called once in `agent/conversation_loop.py`; no-op for ordinary providers. - **Review-fork guard** (`agent/background_review.py`): a client that cannot carry Hermes tool calls declares `SUPPORTS_HERMES_TOOL_CALLS = False` and the fork is skipped with a log line pointing at `auxiliary.background_review.{provider,model}`, instead of spawning an agent that could only no-op. Capability-based, so any future agent-as-provider integration inherits it. `hermes_cli/model_setup_flows.py` now surfaces that override during setup. - **Session reuse within a turn**: a Hermes tool call splits one user turn into several ACP prompts. The first opens a fresh session with the full transcript; continuations reuse it and carry only the results, so Junie doesn't restart the task and redo every edit. Reuse is bound to the `tool_call` ids Junie actually requested, so a history that merely *ends* with a tool row (resume, host-fed transcript) is never delivered as \"here are your results\". - **Reaped-session recovery**: a reused session Junie no longer knows used to fail the turn hard. It now falls back to a fresh session with the full transcript — but only when Junie sent nothing at all for that prompt (no update, no permission request, no fs call), so a prompt it acted on is still never replayed. - **`stream=True`** (MoA / auxiliary paths) returned a bare completion and would have silently dropped the projection; both ACP clients now share one chunk converter whose carrier keeps response-level extras. Configuration is env-driven: `HERMES_JUNIE_ACP_COMMAND` / `JUNIE_CLI_PATH`, `JUNIE_API_KEY`, `HERMES_JUNIE_ACP_PERMISSION` (`deny` default / `allow`), `HERMES_JUNIE_ACP_BRAVE` (`on`/`off`), `HERMES_JUNIE_ACP_FORWARD_TOOLS` (allowlist override). ## How to Test 1. Install the ACP extra and the JetBrains Junie CLI: `pip install 'hermes-agent[acp]'`; ensure `junie` is on `PATH` and authenticated (or set `JUNIE_API_KEY`). 2. Provider + review-loop suites: `pytest tests/agent/test_junie_acp_client.py tests/hermes_cli/test_junie_acp_setup.py tests/agent/test_agent_as_provider_projection.py tests/agent/test_background_review_tool_call_guard.py -q` → **84 passed**. The junie client suite runs end-to-end against a real SDK-based fake ACP agent subprocess, including the tool bridge, session reuse and the reaped-session fallback. 3. No regression to the sibling: `pytest tests/agent/test_copilot_acp_client.py tests/agent/test_copilot_acp_deprecation.py -q` → 25 passed (copilot-acp now uses the shared bridge). 4. Live: `hermes --provider junie-acp -m junie-acp chat`, ask a coding question, confirm Junie answers over ACP and that `/model` lists its live catalog. 5. Live, self-improvement loop: ask Junie to remember something (`memory` reaches it via the bridge and the write lands), then check the session transcript — Junie's file/command work appears as `junie_*` tool call/result rows, not just as reasoning text. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat(junie-acp): ...`, `fix(junie-acp): ...`) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass <!-- tests/agent, tests/run_agent and tests/agent/transports run green relative to main: tests/agent gives the SAME 87 failures on this branch as on a clean upstream/main worktree (pre-existing, order-dependent), with +73 passing; tests/run_agent + tests/agent/transports = 1479 passed. Did not get the entire tests/ tree green in my environment — it isn't green on main either. --> - [x] I've added tests for my changes (24 added in the review-fix commit: bridge parse, forwarded allowlist, continuation detection + both fallbacks, session reuse, reaped-session recovery, projection shape and id uniqueness, streaming carrier, fork guard, loop wiring, allowlist/whitelist sync) - [x] I've tested on my platform: macOS 15 (Darwin), Python 3.13 ### Documentation & Housekeeping - [x] I've updated relevant documentation (`junie` skill `SKILL.md` documents the forwarded-tool set and the review-routing knob; module docstrings for the new `acp_openai_bridge.py` / `provider_projection.py` carry the rationale) — README/`docs/` N/A - [x] cli-config.yaml.example — N/A (provider is env-var driven, no new config keys; the review fix documents the existing `auxiliary.background_review` key rather than adding one) - [x] CONTRIBUTING.md / AGENTS.md — N/A (no architecture/workflow change) - [x] I've considered cross-platform impact — POSIX + Windows HOME/`pwd` fallback handled; stdio transport via the SDK - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A (no Hermes tool schema changed; only which of them are described to the ACP agent) ## Note on the automated closure of #69207 #69207 was closed by hermes-sweeper under the in-tree-provider policy. `AGENTS.md:797-813` does name `plugins/model-providers/junie-acp/`, so I'm not arguing the rule — but the suggested remedy (\"publish standalone, nothing special is needed in core\") isn't reachable today for an ACP provider, and I'd rather ask than guess: - `ProviderProfile` (`providers/base.py`) has no hook for a client/transport, and `VALID_HOOKS` in `hermes_cli/plugins.py` has none either. - Which client to construct is hardcoded on provider name: `agent/agent_runtime_helpers.py:1865,1876`, `agent/agent_init.py:658-660,1103`, `agent/conversation_loop.py:1455-1457` (streaming), `agent/auxiliary_client.py:5282,5301`, `agent/model_metadata.py:51`. - `HERMES_OVERLAYS` / `ALIASES` in `hermes_cli/providers.py:94,105` are in-tree dicts with no registration API — and that's where `acp://junie` and `auth_type=\"external_process\"` live. - The plugin docs describe `external_process` as \"`copilot-acp` plugin only\", and copilot-acp — the only existing ACP provider — is itself in-tree and spread across those same files. So a user-installed plugin can register a profile but can't make Hermes speak ACP to a subprocess. If standalone is the preference, I'm glad to do the enabling work first: a vendor-neutral PR replacing those hardcoded provider literals with profile data (`client_factory`, api-mode/streaming flags, plugin-registerable overlays), with the in-tree `copilot-acp` migrated onto it as proof, and then ship Junie as its own repo against that seam. The three core pieces here (`acp_openai_bridge.py`, `provider_projection.py`, the review-fork guard) are provider-neutral and would stay in core either way. Tell me which direction you want and I'll follow it.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/74743",
        "createdAt": "2026-07-30T09:41:42Z",
        "updatedAt": "2026-08-13T17:31:30Z",
        "timestamp": "2026-08-13T17:31:30Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/cli",
          "comp/acp",
          "comp/plugins",
          "needs-decision",
          "sweeper:risk-session-state",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:risk-platform-windows",
          "sweeper:blast-broad",
          "P4"
        ],
        "author": "AlexanderPrendota",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:74823",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(buzz): discover newly joined channels dynamically",
        "text": "## What does this PR do? Fixes two gaps in Buzz channel discovery: 1. When no explicit channel allowlist is configured, startup now lists only channels where the agent is a member. 2. A WebSocket membership event now discovers and subscribes to newly joined ordinary channels, not only new DM conversations. An explicit `BUZZ_CHANNELS` configuration remains a strict allowlist for ordinary channels. The membership cursor is captured before the startup member-channel snapshot, so a join racing with channel seeding is covered by either the snapshot or the WebSocket subscription overlap. If member-channel rediscovery fails transiently, the handler preserves the previous cursor and raises into the existing WebSocket reconnect loop instead of silently consuming the only membership trigger. ## Related Issue No linked issue. Reproduced against current `main`. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `plugins/platforms/buzz/adapter.py` - Use `channels list --member` for automatic startup discovery. - Capture the membership cursor before the startup channel snapshot. - Rediscover joined ordinary channels when a membership event arrives. - Subscribe immediately from the membership event timestamp with the existing one-second inclusive overlap. - Preserve explicit channel allowlists. - Reconnect and retry when member-channel discovery fails transiently. - `tests/gateway/test_buzz_adapter.py` - Verify startup excludes unjoined channels. - Verify the membership cursor precedes the startup snapshot. - Verify live subscription to newly joined ordinary channels. - Verify explicit channel allowlists remain strict. - Verify transient discovery failure preserves the cursor for retry. ## How to Test 1. Run `pytest tests/gateway/test_buzz_adapter.py -q`. Expected: `27 passed`. 2. Run `ruff check plugins/platforms/buzz/adapter.py tests/gateway/test_buzz_adapter.py`. Expected: `All checks passed!` 3. Run `git diff --check`. Expected: no output and exit status 0. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains only changes related to this fix - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on Linux The full repository test suite has not been run locally. The complete focused Buzz adapter test file passes. ### Documentation & Housekeeping - [x] Documentation update: N/A; no user-facing configuration changed - [x] `cli-config.yaml.example`: N/A; no config keys changed - [x] `CONTRIBUTING.md` or `AGENTS.md`: N/A; no architecture or workflow changed - [x] Cross-platform impact considered; the change uses existing Python and CLI paths - [x] Tool descriptions/schemas: N/A ## Screenshots / Logs ```text 27 passed in 0.30s All checks passed! ```",
        "url": "https://github.com/NousResearch/hermes-agent/pull/74823",
        "createdAt": "2026-07-30T12:35:51Z",
        "updatedAt": "2026-08-13T17:24:47Z",
        "timestamp": "2026-08-13T17:24:47Z",
        "metrics": {
          "reactions": 0,
          "comments": 6
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "P3",
          "needs-decision",
          "sweeper:risk-message-delivery",
          "sweeper:blast-moderate"
        ],
        "author": "cmyk",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:74983",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(auxiliary): preserve raw base_url for anthropic_messages in anonymous custom branch",
        "text": "## Summary When `api_mode: anthropic_messages` is set and the custom provider's `base_url` ends in `/anthropic` (e.g. `https://ai.hatz.ai/v1/anthropic`), the anonymous custom provider branch in `resolve_provider_client()` unconditionally calls `_to_openai_base_url()` which strips the `/anthropic` suffix and appends `/v1`, producing a mangled URL like `https://ai.hatz.ai/v1/v1`. The Anthropic SDK then appends its own `/v1/messages`, resulting in `https://ai.hatz.ai/v1/v1/v1/messages` → **404**. ## Root cause `agent/auxiliary_client.py:` `resolve_provider_client()`, anonymous custom branch (line `~5430`): ```python # Before (buggy — unconditionally rewrites /anthropic → /v1): custom_base = _to_openai_base_url(explicit_base_url).strip() ``` The named custom provider branch (line `~5554`) already has the correct guard: ```python if entry_api_mode == \"anthropic_messages\": openai_base = custom_base raw_base_for_wrap = custom_base else: openai_base = _to_openai_base_url(custom_base) raw_base_for_wrap = custom_base ``` This patch applies the identical guard to the anonymous branch. ## Fix ```python # After (preserves raw URL for anthropic_messages): if api_mode == \"anthropic_messages\": custom_base = explicit_base_url.strip().rstrip(\"/\") else: custom_base = _to_openai_base_url(explicit_base_url).strip() ``` ## Affected - MoA reference slots using an `anthropic_messages` custom provider - Auxiliary tasks (`title_generation`, `compression`, `vision`, `web_extract`, `session_search`) when routed through a custom provider with `anthropic_messages` wire format - Any custom endpoint whose `base_url` ends in `/anthropic` with `api_mode: anthropic_messages` ## Verification Both paths tested against a live Hatz.AI Anthropic-compatible gateway: | Mode | Before | After | |------|--------|-------| | `anthropic_messages` | `AnthropicAuxiliaryClient(base=https://ai.hatz.ai/v1/v1)` ❌ | `AnthropicAuxiliaryClient(base=https://ai.hatz.ai/v1/anthropic)` ✅ | | `chat_completions` | `OpenAI(base=https://ai.hatz.ai/v1/v1)` (correct — will never hit /anthropic) | `OpenAI(base=https://ai.hatz.ai/v1/v1)` (unchanged) ✅ | ## References - Closes #19753 - References #17086 (duplicate) - References #41211 (same root cause, double-/v1 URL path) - Previous partial fixes: #17467, #19772",
        "url": "https://github.com/NousResearch/hermes-agent/pull/74983",
        "createdAt": "2026-07-30T19:11:56Z",
        "updatedAt": "2026-08-13T17:33:32Z",
        "timestamp": "2026-08-13T17:33:32Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/agent",
          "provider/anthropic",
          "P2",
          "sweeper:risk-compatibility",
          "sweeper:blast-contained"
        ],
        "author": "0xr00tf3rr3t",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:75312",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(slack): detect <@UID> mentions in mrkdwn blocks and legacy attachments",
        "text": "## What does this PR do? `#52387` fixed Block-Kit-only `@mention` detection for the one carrier Slack's WYSIWYG composer produces — a `rich_text` tree with a structured `user` element. Two other carriers were still dropped, so a bot that *explicitly* `@`-mentions the gateway stayed invisible to the `allow_bots: mentions` gate and to `is_mentioned` routing: 1. **Hand-built blocks.** An app that builds Block Kit by hand emits no `user` element — it writes the raw `<@UID>` token into a `section`/`header`/`context` block's `text` or `fields` string. `_collect_slack_block_mentions` recursed only through `(\"elements\", \"element\")` and appended only for `type == \"user\"` nodes, so neither the subtree nor the token was ever reached. 2. **Legacy `attachments`.** Detection returned early when `event[\"blocks\"]` was falsy and never consulted `attachments` at all. `#69316` had already established that Alertmanager, Grafana, PagerDuty and CI bots post with an empty top-level `text` and the real content inside `attachments` — but applied that understanding to *display* only, never to *detection*. Both gaps close inside the existing helpers. The walker now also descends `\"text\"`/`\"fields\"` and harvests raw tokens from string values; a new `_collect_slack_attachment_mentions` covers the legacy carrier including attachment-nested blocks; a shared `_SLACK_USER_MENTION_RE` normalizes the labelled `<@U123|alice>` form to the bare token the gates compare against. **Why this approach.** Two design decisions are worth calling out, because the obvious implementation of each is wrong: - **Recovered mentions are returned as a list, not spliced into the routing text.** `_slack_recovered_mentions` hands the gates a token list; the routing text stays byte-identical to `event[\"text\"]`. Appending recovered tokens would corrupt the two other consumers of that string. `_slack_message_addressed_to_other_user` reads its *first* token — and with an empty top-level text (precisely the alert-bot shape this fixes) the appended tail *becomes* that token, so the message would be dropped as \"addressed to someone else\". And user-configured wake-word patterns are matched with `.search`, so an anchored pattern like `^hey hermes$` would stop matching the moment a tail is appended. The gates now consume `_slack_event_mentions_bot` / `_slack_mention_gate_inputs` instead. - **The `#52390` carve-out is generalized, not merely preserved.** Widening what gets scanned adds carriers the structured `rich_text_quote` node check cannot see, and each is closed explicitly: mrkdwn blockquote markers, `is_msg_unfurl`/`is_share` attachments, and `fallback`. The underlying rule is not \"quoting\" but *verbatim content* — text the author is displaying rather than speaking — so code also counts: a `<@UID>` inside `rich_text_preformatted`, on a `style.code` element, or inside an mrkdwn triple-backtick fence or inline backtick span is not an address. Slack does not linkify mrkdwn inside code, so such a token notifies nobody; waking on it would be the same spurious trigger, reached through a different carrier. Without all of this, widening the scan would have re-opened the agent-agent re-trigger loop that `allow_bots` exists to prevent. ## Related Issue Fixes #75286 ## Type of Change - [x] 🐛 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 All production changes are in `plugins/platforms/slack/adapter.py`; all test changes in `tests/gateway/test_slack_mention.py`. 9 hunks in a 9232-line file — the diff is confined to the mention-detection helpers and their call sites, with no reformatting churn. **Detection helpers** - `_SLACK_USER_MENTION_RE` — one module-level pattern for `<@UID>` / `<@UID|label>`, capturing only the ID. The ID class is deliberately permissive: the gates substring-compare against whatever `auth.test` returned, so a narrower class would silently drop the very mention this recovers. - `_extract_mention_tokens()` — the single place a mrkdwn string is scanned. Both carriers call it, so the carve-outs below apply to all of them automatically instead of having to be re-added per collector. - `_collect_slack_block_mentions()` — `_walk` now descends `\"text\"` and `\"fields\"` too, harvesting tokens when the value is a string and the node is not verbatim. The flag (renamed from `quoted`, which no longer described what it carries) is set by `rich_text_quote`, `rich_text_preformatted` and `style.code`, and propagates down the subtree. - `_collect_slack_attachment_mentions()` — new; covers `pretext`/`title`/`text`, `fields[].title`/`value`, and attachment-nested `blocks` via the existing block walker. - `_slack_recovered_mentions()` — unions both sources and dedupes (one mention commonly appears in several carriers). - `_slack_mention_detection_text()` — **removed.** It existed to hand a substring-testable string to the thread-parent wake check; that caller now takes a boolean (below), leaving the helper with no consumer. Keeping it would mean shipping a helper whose docstring warns against every remaining use of it. **Carve-outs preserving the `#52390` contract** - Lines opening with a mrkdwn blockquote marker (`>`, and the escaped `&gt;` form Slack actually sends) are skipped — mrkdwn-level quoting is invisible to the `rich_text_quote` node check. - Code content is skipped in every carrier it has: the `rich_text_preformatted` node, `style.code` elements, and triple-backtick fences / inline backtick spans inside mrkdwn strings. This is the one carve-out that is genuinely new rather than extended — `#52390` covered quoting only, so a mention a human formatted as code, or one an app emitted inside a payload dump or a relayed log line, still summoned the bot. That last shape lands squarely on the `allow_bots: mentions` path this PR serves. A human *typing* a literal token is unaffected either way, because Slack escapes it to `&lt;@U123&gt;`; that bound is now pinned by a test so relaxing `_SLACK_USER_MENTION_RE` cannot silently regress it. - Attachments flagged `is_msg_unfurl` or `is_share` are skipped, mirroring the skip the agent-text path already performs at `adapter.py:5483`. Without this, pasting a permalink to an old `<@BOT> deploy prod` message would summon the bot — and, because the text path strips the unfurl, summon it with the body missing from its input. - `fallback` is not scanned. Slack never renders it, so a mention living only there is invisible in the channel and notifies nobody. **Defensive handling** - The `try/except` is now per-attachment rather than around the whole loop. Previously one malformed sibling discarded the genuine mentions already collected from earlier attachments — reintroducing the exact silent drop this PR fixes. **Call sites** - `allow_bots: mentions` gate and `is_mentioned` routing now go through `_slack_event_mentions_bot()` / `_slack_mention_gate_inputs()`. - The `#24848` thread-parent wake check is migrated too — its cached branch returned the raw `msg[\"text\"]`, empty for app-authored parents, so a plain follow-up reply in an alert thread was dropped after a restart. The fix otherwise would have covered a thread's first message but not its follow-ups. Rather than filtering display text, the two concerns are split: - `_fetch_thread_parent_event()` — new; returns the raw parent payload, cache-first. - `_thread_parent_mentions_bot()` — new; decides the wake through `_slack_event_mentions_bot()`, **the same predicate the live channel gates use**, so every carve-out above applies to the parent check by construction rather than by being re-added there. - `_fetch_thread_parent_text()` — back to display-only for reply_to_text injection, with a docstring saying it must not be substring-tested. Deriving the wake from it was unsafe in both directions: `_render_message_text` deliberately *preserves* `rich_text_quote` content for the agent to read, and extracts attachment text with no `is_msg_unfurl`/`is_share` exclusion and a `fallback` fallthrough. Its `strip_bot_mention` flag existed solely for the wake caller and is gone. ## How to Test **1. Reproduce on `main`** — both helpers are pure and module-level, so no gateway, credentials or config are needed. Run the snippet from #75286 against a plain checkout of `main`; all six shapes print `DROPPED`. **2. Verify the fix.** On this branch the same snippet reports every carrier as `seen`. **3. Verify the carve-outs did not regress** (these are the cases a naive fix breaks): ````python from plugins.platforms.slack.adapter import _slack_recovered_mentions as rec BOT = \"U0BOTID\" sect = lambda t: {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": t}} # Pasted permalink / forwarded share must NOT wake the bot print(rec({\"text\": \"look at this\", \"attachments\": [{\"is_msg_unfurl\": True, \"text\": f\"<@{BOT}> deploy prod\"}]})) # [] # mrkdwn blockquote must NOT wake the bot print(rec({\"text\": \"\", \"blocks\": [sect(f\"&gt; <@{BOT}> old ping\\nstatus: green\")]})) # [] # A malformed sibling must not discard a real mention print(rec({\"text\": \"\", \"attachments\": [{\"text\": f\"<@{BOT}> disk 91%\"}, {\"fields\": 3}]})) # ['<@U0BOTID>'] # Code content must NOT wake the bot, in any carrier rt = lambda *e: {\"type\": \"rich_text\", \"elements\": list(e)} print(rec({\"text\": \"\", \"blocks\": [rt({\"type\": \"rich_text_preformatted\", \"elements\": [{\"type\": \"user\", \"user_id\": BOT}]})]})) # [] print(rec({\"text\": \"\", \"blocks\": [sect(f\"```\\n notify <@{BOT}>\\n```\")]})) # [] print(rec({\"text\": \"\", \"blocks\": [sect(f\"the field holds `<@{BOT}>` verbatim\")]})) # [] # ...but the fence carve-out must end at the closing fence print(rec({\"text\": \"\", \"blocks\": [sect(f\"```\\nlog\\n```\\n<@{BOT}> look\")]})) # ['<@U0BOTID>'] ```` **3b. Verify the thread-parent wake check obeys the same carve-outs.** Both the cold and the cached parent path, since they are separate code paths that must agree: ```python # adapter = any SlackAdapter instance; parent = a thread-root payload await adapter._thread_parent_mentions_bot( channel_id=\"C1\", thread_ts=PARENT_TS, bot_uid=BOT) # False for a parent whose mention lives in a quote / is_share / is_msg_unfurl / # fallback / preformatted carrier; True for flat text, a section block, or an # attachment field. ``` **4. Verify the routing text stays clean** — the two regressions the list-not-string design prevents: ```python adapter = ... # any SlackAdapter instance # Attachment-only alert naming a human must not look like \"addressed to someone else\" routing_text, is_mentioned = adapter._slack_mention_gate_inputs( {\"text\": \"\", \"attachments\": [{\"fields\": [{\"title\": \"owner\", \"value\": \"<@U_ONCALL>\"}]}]}, BOT) assert routing_text == \"\" and adapter._slack_message_addressed_to_other_user(routing_text, {BOT}) is False # An anchored wake word must still match when an attachment is present ``` Both are covered by `test_gate_leaves_routing_text_free_of_recovered_mentions` and `test_gate_wake_word_pattern_still_matches_with_an_attachment`. **5. Run the suite:** ```bash pytest tests/gateway/test_slack_mention.py -q # 87 passed (27 on main) pytest tests/gateway/test_slack*.py -q # 369 passed across 27 files ``` Of the 60 tests added to `test_slack_mention.py`, 53 fail on a plain `main` (verified by running the new file against the unmodified adapter); the other 7 are non-regression bounds that must pass both before and after — escaped tokens, text after a *closed* fence, a mention adjacent to an inline code span, an unpaired backtick, and `rich_text_list` carrying `style` as a plain string rather than a dict. **6. End-to-end.** With `allow_bots: mentions`, have any app post a message with an empty top-level `text` and `<@YOUR_BOT_UID>` inside a `section` block or an attachment field. On `main` the gateway never replies; on this branch it does. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) — single commit, two files, no `ruff format` churn - [x] I've run `pytest tests/ -q` and all tests pass — **with one caveat, see note below** - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15.7.7 (Apple Silicon), Python 3.13.3 > **Note on `pytest tests/ -q`:** `tests/acp/test_entry.py` fails to collect on a plain checkout (`ModuleNotFoundError: No module named 'acp'`), so I ran `tests/gateway tests/plugins` instead: **5550 passed, 3 failed**. The 3 are pre-existing and unrelated — `test_systemd_notify.py` (1) and `test_wecom_callback.py` (2). I confirmed that by stashing this change and re-running those two files on the unmodified tree: the same 3 fail. Happy to rebase if they are fixed on `main` first. ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — docstrings on every touched helper; no user-facing docs affected - [x] N/A — no config keys added or changed - [x] N/A — no architecture or workflow change; the fix stays inside the existing Slack adapter helpers - [x] I've considered cross-platform impact (Windows, macOS) — pure string/dict handling, no platform-dependent code paths - [x] N/A — no tool descriptions or schemas changed ## Screenshots / Logs The failure is silent by design; the only trace on `main` is the existing debug line, which this PR also corrects (it claimed to cover \"flat text or blocks\" while attachments were never consulted): ``` # before [Slack] Dropping bot message under allow_bots=mentions: no <@%s> mention in flat text or blocks # after [Slack] Dropping bot message under allow_bots=mentions: no <@%s> mention in flat text, blocks or attachments ```",
        "url": "https://github.com/NousResearch/hermes-agent/pull/75312",
        "createdAt": "2026-07-31T08:10:16Z",
        "updatedAt": "2026-08-13T15:59:11Z",
        "timestamp": "2026-08-13T15:59:11Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "platform/slack",
          "P3",
          "sweeper:risk-message-delivery",
          "sweeper:risk-security-boundary",
          "sweeper:blast-moderate"
        ],
        "author": "chenwei791129",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:75866",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(stt): add optional LLM transcript cleanup",
        "text": "## What does this PR do? Adds an optional, disabled-by-default LLM cleanup pass after gateway speech-to-text and its configured STT fallback. The implementation is deliberately small and fail-open: it makes one explicit provider/model call, accepts a non-empty structured result only above the configured confidence threshold, and otherwise keeps the raw transcript. There is no shadow mode, semantic regex engine, retry layer, or provider fallback chain. ## Related Issue No upstream issue. ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [x] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `tools/transcript_cleanup.py`: one structured LLM cleanup call with timeout, confidence gate, raw-transcript fallback, and an optional per-call `prompt_file` override. - `gateway/run.py`: applies cleanup once after successful STT/fallback; the retained text is used for both agent input and transcript echo. - `gateway/config.py` and `hermes_cli/config_defaults.py`: add `stt.cleanup`, disabled by default; the conservative English prompt remains built in unless `stt.cleanup.prompt_file` is configured. - Cleanup failures emit fixed metadata-only logs; transcript and topic content are not logged. - `cli-config.yaml.example` and voice-mode docs describe configuration and the external-provider privacy boundary. - Focused unit and gateway tests cover disabled/apply/fail-open behavior, context propagation, built-in prompt fallback, custom prompt loading, and deterministic relative-path resolution under `HERMES_HOME`. ## How to Test 1. Run the focused STT/config/gateway suite: ```bash scripts/run_tests.sh \\ tests/tools/test_transcript_cleanup.py \\ tests/tools/test_transcription.py \\ tests/tools/test_stt_default_language.py \\ tests/hermes_cli/test_config.py \\ tests/gateway/test_stt_transcript_cleanup.py \\ tests/gateway/test_stt_config.py \\ tests/gateway/test_stt_transcript_echo_config.py \\ tests/gateway/test_telegram_voice_v0_regressions.py \\ tests/gateway/test_busy_session_ack.py -q ``` Result: `141 passed`. 2. Run Ruff and diff validation: ```bash uv run --extra dev ruff check \\ tools/transcript_cleanup.py hermes_cli/config_defaults.py \\ gateway/config.py gateway/run.py \\ tests/tools/test_transcript_cleanup.py \\ tests/gateway/test_stt_transcript_cleanup.py \\ tests/gateway/test_busy_session_ack.py git diff --check upstream/main...HEAD ``` Result: clean. 3. Verify the example cleanup block matches defaults and compile changed Python modules. Both complete successfully. ## Checklist ### Code - [ ] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Debian Linux, Python 3.11, `uv` test environment ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A - [x] I've considered cross-platform impact (Windows, macOS) per the compatibility guide — no platform-specific code added - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A ## Screenshots / Logs Not applicable. The feature is disabled by default and no user transcript was sent to an external cleanup provider during verification.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/75866",
        "timestamp": "2026-08-13T12:08:12Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "tool/tts",
          "area/config",
          "P3",
          "sweeper:risk-message-delivery",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:blast-moderate"
        ],
        "author": "echo931",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:76013",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix: detect progress narration as final answer and retry turn",
        "text": "Fixes #74604 The agent can end a turn by emitting a progress narration (e.g. 'I am now compiling the complete answer.') as its final assistant message, producing no deliverable. No bound was reached, nothing errored, and no hook exists to catch this. Work in progress — root cause analysis and fix coming in the next commit.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/76013",
        "createdAt": "2026-08-01T08:09:10Z",
        "updatedAt": "2026-08-13T15:23:34Z",
        "timestamp": "2026-08-13T15:23:34Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "invalid",
          "comp/agent",
          "P3",
          "sweeper:blast-contained"
        ],
        "author": "rkfshakti",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:76867",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(session): filter technical history from auto-title guard",
        "text": "## What does this PR do? Technical `role=\"user\"` records are persisted for context compaction, background-process notifications, model changes, image attachments, and replayed history. The existing auto-title guard counted those records as user turns, so a session could remain untitled even though a real exchange had completed. This keeps the intentional first-two-semantic-exchange boundary while filtering known runtime scaffolding by its persisted metadata and stable prefixes. Every qualifying history row counts as one real user turn, including repeated user text. A persisted title check and a process-local per-session claim prevent duplicate work and preserve manual-title races; the claim is also released when title generation is disabled or a worker cannot be started. A contemporaneous PR [#76856](https://github.com/NousResearch/hermes-agent/pull/76856) addresses the same issue but removes the first-semantic-exchange boundary. This change keeps that original contract and adds real `SessionDB` persistence coverage plus worker-start cleanup coverage. The automated review also identified that repeated identical user messages must still count as separate completed turns. The follow-up now counts qualifying history rows rather than de-duplicating their text, with a regression covering three repeated messages. ## Related Issue Fixes #76842 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✅ Regression tests included with the bug fix ## Changes Made - `agent/title_generator.py`: classify real user-history rows, ignore persisted technical markers/display metadata, count every qualifying turn, retain the first-two-real-turn limit, guard title reads and in-flight workers, and clean up claims on every exit path. - `tests/agent/test_title_generator.py`: cover end-to-end persistence through technical history, existing titles, late ordinary turns including repeated text, duplicate workers, and worker-start failure recovery. ## How to Test 1. `scripts/run_tests.sh tests/agent/test_title_generator.py -q` → 20 passed. 2. `scripts/run_tests.sh tests/test_tui_gateway_server.py -q` → 502 passed. 3. `scripts/check.sh --project hermes-agent --worktree worktrees/hermes-agent/76842` → uv lock checks, Ruff, and all blocking gates passed; ty reports only the repository's pre-existing advisory diagnostics. 4. A temporary regression probe failed on base `26e0b1c12c2bbc2d1ef4640df18abff2d737445a` with `AssertionError: title generation was suppressed on base`, then passed on this branch. ## Checklist ### Code - [x] Read the current Contributing Guide and project policy files. - [x] Commit message follows Conventional Commits (`fix(title): ...`). - [x] Searched and reviewed existing semantic PR candidates; the contemporaneous #76856 was explicitly compared in the worklog. - [x] The branch contains only the two files required for this fix. - [x] Ran the scoped canonical title tests and the affected TUI gateway integration suite; a full repository `pytest tests/ -q` run is not represented as completed. - [x] Added regression tests for the bug, repeated real turns, and its concurrency/lifecycle boundaries. - [x] Tested on macOS arm64 with Python 3.11.15. ### Documentation & Housekeeping - [x] Updated the affected function documentation; no README or standalone docs change is needed. - [x] No configuration keys were added or changed. - [x] No architecture or workflow policy changed. - [x] Considered Windows/macOS cross-platform impact; the change uses standard Python threading and mapping APIs only. - [x] No tool descriptions or schemas changed.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/76867",
        "timestamp": "2026-08-13T11:59:51Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P3",
          "needs-decision",
          "sweeper:risk-session-state",
          "sweeper:risk-compatibility",
          "sweeper:blast-broad",
          "area/sessions"
        ],
        "author": "Christopher-Schulze",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:77263",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(usage,security): desktop usage & cost surface + Safety & Security panel",
        "text": "<!-- native-links:v1 --> Related #77221 #77222 #77223 #77262 ## What changed and why This PR turns Hermes' existing local usage accounting and security controls into first-class desktop surfaces. ### 1. Usage observability (`/usage`) The desktop now provides three distinct Usage decks: - **Overview** — profile/session telemetry from `usage.overview`: sessions, canonical tokens, provider actual cost, local estimated cost, cache activity, activity history, and top sessions. - **Routes** — installation-wide traffic captured by the optional `usage-meter` plugin, grouped by provider/model/endpoint with explicit priced, included, and unpriced coverage. - **Call Ledger** — installation-wide events from `usage.meter.recent`, including route, task, token, pricing-status, pricing-source, and cost evidence. Profile/session analytics and installation-wide capture remain visibly separate because they are not atomically equivalent populations. Accounting behavior is evidence-preserving: - Overview, model, platform, daily-series, top-session, and cost-bucket reducers share one reconciled main-plus-auxiliary row population. - Provider actual cost remains distinct from local estimation, while actual rows participate in known market-comparison coverage rather than falling into the unknown bucket. - Main-loop legacy rows may inherit missing session route metadata; auxiliary tasks remain route-isolated. - A literal provider actual `$0.00` is shown only when producer evidence proves it. Missing or malformed evidence renders `—`. - Malformed token buckets reject the captured meter event instead of becoming an invented zero-token priced call. - Included subscription usage remains billed at `$0.00`; public market-equivalent pricing is a separate contract. - Mixed estimated/included sessions preserve every route contribution. Missing required comparison evidence makes the result unavailable rather than partial. - Reasoning tokens remain an output subset and are never added to canonical totals twice. - Overview and daily series use the same local-calendar boundary. The desktop boundary preserves absent and malformed RPC fields as unavailable. It includes localized navigation, labels, units, and dates; RTL-logical layout; Hermes `Tip` tooltips; accessible compact controls; a single pane-owned vertical scroll surface; and responsive midnight-black wide, medium, and mobile layouts. ### 2. Safety & Security panel (Settings → Safety) The former flat toggle list becomes a live posture surface: - `security.status` reports redaction, approvals, Tirith pre-exec scanning, checkpoints, and a redaction self-test. - `security.scan` exercises `check_command_security` and reports allow/warn/block findings. - The desktop explains each control, persists settings through existing config paths, and provides interactive verification. ## Placement rationale This extends existing Hermes seams and introduces no third-party observability backend, parallel pricing store, or new model-tool schema: - `InsightsEngine` remains the profile/session analytics engine. - `usage_pricing.py` remains the billing and public-comparison pricing engine. - `usage.overview` and `usage.meter.*` use the existing gateway RPC registry. - Installation-wide capture remains optional through `hermes plugins enable usage-meter`. ## Verification Final owner-directed shipment candidate: `7c8ec9a9a673ca026ce88ab6b161bcf4d3add2f7` (supersedes `6cc368eaae4e0f2377e894414d41e8e949752652`). Closed after the initial publication: - stored session cost is reconciled with per-model derived rows instead of counting the same estimate twice; - a route containing any unpriced calls reports cost as unavailable rather than displaying a partial subtotal; - Messaging, Skills, and Gateway Settings imports no longer consume the ordinary behavioral test budget; the Gateway test imports its component statically with an isolated profile-store mock. Exact-final receipts: - Messaging, Skills, and Gateway Settings regression boundary: **3 passed files; 13 passed tests**; - Gateway Settings exact test: **2 passed in 824 ms**; - desktop TypeScript checks: **PASS**; - affected ESLint with zero warnings: **PASS**; - diff check and clean committed worktree: **PASS**; - guarded remote push and PR-head readback at `7c8ec9a9a673ca026ce88ab6b161bcf4d3add2f7`: **PASS**. The complete suite on immediate parent `4c5e074ccab9a21ed890766718b0d466b0d15dc1` executed **382 passed / 383 files; 3,320 passed tests; 2 skipped**. Its only failure was the Gateway Settings suite's 60-second `beforeAll` dynamic-import hook. The final commit removes that hook; all 13 assertions across the three formerly failing files pass on the final candidate. GitHub CI was retriggered through exact head `7c8ec9a9a673ca026ce88ab6b161bcf4d3add2f7`. The code-bearing CI run passed; the remaining non-green runner job failed before tests while downloading Python from GitHub (HTTP 503 Service Unavailable). ## Platforms tested - Windows 10 native desktop build and test toolchain. - Python changes are platform-neutral stdlib/SQLite code; report buckets intentionally use the host's local calendar boundary. ## Related issues - Part of #77221 — desktop Usage surface and installation-wide captured meter - Part of #77222 — time-series aggregation - Part of #77223 — cost buckets and pricing coverage - Part of #77262 — Safety section overhaul ## Contributor provenance The installation-wide per-call meter is credited to @muhammadshess-10xe from #77221 and remains carried in commit `2fb128e2c86`. The dashboard builds on that seam without collapsing it into profile/session telemetry.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/77263",
        "createdAt": "2026-08-03T02:56:00Z",
        "updatedAt": "2026-08-13T17:52:40Z",
        "timestamp": "2026-08-13T17:52:40Z",
        "metrics": {
          "reactions": 0,
          "comments": 5
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/tui",
          "P3",
          "comp/desktop",
          "area/billing"
        ],
        "author": "andrexibiza",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:77507",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): eliminate sidebar scroll jitter during dynamic sizing",
        "text": "## What does this PR do? Stops the Desktop sessions sidebar from jumping/flickering while you scroll a long virtualized list. Native scroll anchoring was fighting the virtualizer’s padding updates, and date-divider rows were estimated at the session-row height. Rebased onto current `main` so inbox-style card rows keep the 66px estimate. A naive replay of the original `estimateSize` line would have sized every non-divider row at 28px, including cards. Not in this PR: mouse-wheel dead-zones once the list is long (~25+ sessions), where the wheel stops mid-list but dragging the scrollbar still works. Tracked as #84964. ## Related Issue Fixes #77505 ## Type of Change - [x] 🐛 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 - `apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx`: disable native scroll anchoring on the virtual list container. - Same file: `estimateSize` is divider 26px / compact session 28px / card session 66px. - Same file: set the virtualizer `gap` to 1px to match the existing `gap-px` row layout. - `apps/desktop/src/app/chat/sidebar/virtual-session-list.test.tsx`: contract test so a future merge cannot drop the card or divider branch. ## How to Test 1. Open Hermes Desktop with enough sessions for the sidebar to virtualize (25+), including several date dividers. 2. Scroll up and down past the dividers with the wheel and with arrow keys. 3. The list should no longer jump or flicker as rows are measured. 4. With inbox-style session cards enabled, card rows should still estimate at card height (no compact-row snap). 5. Unit: from `apps/desktop`, `npx vitest run --project ui src/app/chat/sidebar/virtual-session-list.test.tsx` — 2 passed. This PR does **not** claim that mid-list wheel stalls are gone. If the wheel stops while scrollbar drag still works, that is #84964. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass — N/A (Desktop UI only; Python suite not in this diff) - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Linux, vitest UI project ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/77507",
        "createdAt": "2026-08-03T08:42:55Z",
        "updatedAt": "2026-08-13T17:58:03Z",
        "timestamp": "2026-08-13T17:58:03Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "P2",
          "comp/desktop"
        ],
        "author": "StanleyStetson",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:77592",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(security): prevent multiplex dotenv credential leakage",
        "text": "## What does this PR do? Prevents turn-scoped `load_hermes_dotenv()` calls from copying a routed profile's credentials into process-global `os.environ` while a multiplex profile scope is active. The root cause was that `load_hermes_dotenv()` always reached `_load_dotenv_with_fallback(..., override=True)`, even when the active Hermes home came from a routed multiplex profile. The gateway helper guarded one reload call site, but lazy imports, cron, and other callers could invoke the loader directly and bypass that guard. The guard now lives at the shared loader boundary and requires both: - multiplex mode is active; and - a routed profile-home override is installed. This keeps unscoped gateway startup loading unchanged. Inside a routed profile scope, the loader refreshes external secret providers through `hydrate_profile_secret_sources()`, which writes to the existing profile-private snapshot, and returns without mutating the shared process environment. ## Related Issue Fixes #77562 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [x] 🔒 Security fix - [ ] 📝 Documentation update - [ ] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `hermes_cli/env_loader.py`: skip process-global dotenv mutation only during an active routed multiplex profile scope, while retaining profile-private external-secret hydration. - `tests/gateway/test_multiplex_credential_isolation.py`: prove two routed profiles retain distinct credentials and channel allowlists while the process-global allowlist remains unchanged. - `tests/test_env_loader_secret_sources.py`: prove unscoped multiplex startup still loads `.env`, and routed profile loading still hydrates Bitwarden-backed credentials without exporting bootstrap/provider secrets globally. ## Review Follow-up This revision addresses the startup-order concern raised by @DonShelly and @egilewski: - the early return is now restricted by `get_hermes_home_override() is not None`; - multiplex startup without a routed profile scope still loads `DISCORD_ALLOWED_CHANNELS`; - the profile-private secret-source hydration path remains covered; - the field scenario from #77970 is covered by distinct profile A/B `DISCORD_ALLOWED_CHANNELS` values plus an unchanged process-global value. The routed-scope discriminator follows the boundary identified by @DonShelly in #77970, while this PR keeps its existing external secret-provider hydration behavior and coverage. ## How to Test 1. RED proof before narrowing the production guard: `scripts/run_tests.sh tests/test_env_loader_secret_sources.py -k multiplex_without_profile_scope_still_loads -q` failed because `DISCORD_ALLOWED_CHANNELS` remained unset instead of loading `123,456`. 2. Targeted regression files: `scripts/run_tests.sh tests/gateway/test_multiplex_credential_isolation.py tests/test_env_loader_secret_sources.py -q` → **25 passed**. 3. Broader affected-surface suite across env loader, secret scope, gateway, cron, and runtime profile isolation: → **91 passed** across 9 files. 4. Ruff, `git diff --check`, and `scripts/check-windows-footguns.py` on the three changed files: → all passed. 5. Repository-standard full suite: `scripts/run_tests.sh -j 16` → **30,678 passed, 22 failed, 264 skipped** across 2,816 files in 1,313.8s. 6. Re-ran all 16 files implicated by the full run with identical `-j 4` parameters on this branch and a clean `upstream/main` worktree at `762610538`: → both produced **698 passed, 21 failed, 6 skipped**, with the same failing tests and the same separate import-error file. The extra FIFO timing failure seen only in the 16-worker full run passed in both four-worker comparisons. GitHub `main` advanced after that full comparison. The final commit was rebased onto `fe5e7799f`; none of the 20 intervening commits touched this PR's three files, and the 91-test affected-surface suite passed again after the rebase. The full-suite checkbox remains unchecked because the repository-wide suite has existing macOS/environment failures. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26.5.1, Python 3.11.15. Linux and Windows were not tested directly. ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A (function behavior documented in its docstring) - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — pure Python control flow; Windows footgun scan passed - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## Screenshots / Logs No screenshot is applicable because this is a process-level credential-isolation fix with no UI change. ```text Targeted regression files: 25 passed, 0 failed Broader affected-surface suite: 91 passed, 0 failed Ruff: All checks passed Windows footgun scan: No issues found git diff --check: passed Full repository suite (-j 16): 2,816 files 30,678 passed 22 failed 264 skipped 1,313.8 seconds Failed-file comparison (-j 4, same 16 files): fix branch: 698 passed, 21 failed, 6 skipped upstream/main: 698 passed, 21 failed, 6 skipped ```",
        "url": "https://github.com/NousResearch/hermes-agent/pull/77592",
        "createdAt": "2026-08-03T11:10:51Z",
        "updatedAt": "2026-08-13T13:39:57Z",
        "timestamp": "2026-08-13T13:39:57Z",
        "metrics": {
          "reactions": 0,
          "comments": 6
        },
        "labels": [
          "type/security",
          "comp/cli",
          "comp/gateway",
          "area/auth",
          "P2",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "area/profiles"
        ],
        "author": "lesterlxt",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:78162",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(media): media-delivery denylist is inert on Windows",
        "text": "## The bug `_MEDIA_DELIVERY_DENIED_PREFIXES` is POSIX-only, and the entries **don't degrade gracefully**. `Path(\"/etc\").resolve()` becomes `C:\\etc` on the current drive — which doesn't exist. So on Windows every entry in that tuple is inert and the default-mode denylist is effectively empty. Measured against unmodified `main` on Windows: ``` /etc -> C:\\etc exists=False /sys -> C:\\sys exists=False /root -> C:\\root exists=False ...all nine resolve to nonexistent paths C:\\Windows covered=False C:\\ProgramData covered=False ``` Verified end-to-end through the guard itself, not just the constant: ```python validate_media_delivery_path(r\"C:\\Windows\\win.ini\") before -> <path> # deliverable as a gateway attachment after -> None # refused ``` **Strict mode was unaffected** — the `~/.ssh` denial works, because those entries are built from the live `$HOME` rather than hard-coded POSIX roots. This is the *default* (non-strict) path, which is what most operators run. ## The fix Two Windows-specific lists: **System roots**, resolved from `%SystemRoot%` and `%ProgramData%` — read from the environment rather than hard-coded, because Windows needn't live on `C:` and a roaming profile needn't sit under `C:\\Users`. **Credential stores under the user profile** that have no POSIX counterpart: `Microsoft\\Credentials` (roaming and local), `Microsoft\\Protect` (DPAPI master keys), `Microsoft\\Crypto`, `Local\\Microsoft\\Vault`. The POSIX dotfile equivalents (`.aws`, `.ssh`, `.azure`, `.gcloud`) use the same names on Windows and are already covered by `_MEDIA_DELIVERY_DENIED_HOME_SUBPATHS`. **AppData is deliberately not denied wholesale.** `%LOCALAPPDATA%\\Temp` is a normal home for generated artifacts, so a blanket rule would break legitimate media delivery. A test asserts `Temp`, `Pictures` and `Downloads` stay deliverable. ## Verification (Windows) - **9 new tests. 6 fail when the production change is reverted** — so the coverage pins real behaviour rather than passing vacuously. The 3 that pass either way are the must-stay-deliverable assertions. - `tests/gateway/test_platform_base.py`: 2 failed before, 2 failed after. **Set-diff of failure names is empty** — nothing newly broken. Those 2 are unrelated pre-existing failures. - `ruff check` clean. ## Relationship to #52045 Complementary, not overlapping. #52045 rewrites the *comparison* helpers to be case-insensitive (`_path_compare_key`, `_path_under_or_equal`); this fixes *what is in the list* to compare against. Different hunks in the same file — #52045 touches the helpers around :52 and `_path_under_denied_prefix` at :1134, this touches the constants at :1196 and `_media_delivery_denied_paths` at :1333. Both are needed: case-folding a denylist that contains only nonexistent paths still denies nothing. 🤖 Generated with [Claude Code](https://claude.com/claude-code)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/78162",
        "createdAt": "2026-08-04T03:53:48Z",
        "updatedAt": "2026-08-13T17:54:52Z",
        "timestamp": "2026-08-13T17:54:52Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/security",
          "comp/gateway",
          "area/auth",
          "P2",
          "sweeper:risk-message-delivery",
          "sweeper:risk-security-boundary",
          "sweeper:risk-platform-windows",
          "platform/windows"
        ],
        "author": "bbasketballer75",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:78590",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(install): surface aborted gateway restart during hermes update (#78574)",
        "text": "## What does this PR do? `hermes update` can finish with a clean `✓ Update complete!` and exit 0 while leaving a running gateway on pre-update modules — the next turn then dies with `ImportError: cannot import name 'is_trivial_prompt' from 'agent.memory_provider'`. The cause is the blanket handler around the entire gateway auto-restart phase in `hermes_cli/update_cmd.py`: ```python except Exception as e: logger.debug(\"Gateway restart during update failed: %s\", e) ``` If anything in that phase raises — most plausibly the `from hermes_cli.gateway import (...)` at the top of the block, which pulls the *freshly pulled* module into a process that already loaded the *pre-update* ones — the whole phase is skipped. Every `→ draining …` / `✓ Restarted hermes-gateway` line vanishes from the update log, and the failure is invisible at default log level. That matches the reported log exactly: earlier updates show drain/restart lines, the failing run shows none, and exit status is 0. Upstream already has the right machinery for the *partial* failure case (`failed_or_stale_units` → `_warn_incomplete_gateway_fleet_restart` → `gateway_fleet_restart_incomplete` → nonzero exit + `.update_exit_code` marker). This PR routes the *total* failure case into the same contract instead of swallowing it. On exception the handler now: 1. probes for surviving gateway PIDs (best-effort, never raises — a broken `hermes_cli.gateway` is itself one of the ways we get here); 2. unless it can *positively* prove nothing is running (empty list), prints the underlying exception, the surviving PIDs, and `hermes gateway restart`; 3. sets `gateway_fleet_restart_incomplete`, so the update exits nonzero and — under `--gateway` — writes `1` to `.update_exit_code`, exactly like the existing failed-unit path. `None` (undeterminable) and a non-empty PID list are both treated as \"assume stale\"; only a positive empty result stays quiet, so a host with no gateway running does not start failing its updates. This is deliberately a *reporting* fix, not an attempt to guess why the phase died: the issue asks for \"exit nonzero or print a clear warning and manual recovery command\", and any root cause behind that blanket `except` produces the same silent-stale outcome today. ## Related Issue Fixes #78574 ## Type of Change - [x] 🐛 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 - `hermes_cli/update_cmd.py` - new `_surviving_gateway_pids_after_failed_restart()` — best-effort probe for gateways still running after the restart phase aborted; returns `None` when undeterminable so callers can distinguish \"nothing running\" from \"cannot tell\". - new `_warn_gateway_restart_phase_aborted()` — prints the cause, the surviving PIDs, and the `hermes gateway restart` / `hermes gateway status` recovery commands. Mirrors the wording/shape of the neighbouring `_warn_incomplete_gateway_fleet_restart()`. - the phase-level `except Exception` now calls both, sets `gateway_fleet_restart_incomplete = True`, and writes the gateway-mode `.update_exit_code` marker. - `hermes_cli/main.py` — re-export the two new helpers alongside the existing update-path names. - `tests/hermes_cli/test_update_gateway_restart_aborted.py` — new regression tests. ## How to Test ``` scripts/run_tests.sh tests/hermes_cli/test_update_gateway_restart_aborted.py tests/hermes_cli/test_update_fleet_restart_timeout.py ``` Result: `2 files, 9 tests passed, 0 failed`. Full update-path suite (22 files, includes every `tests/hermes_cli/test_update*.py` and `test_cmd_update*.py`): ``` scripts/run_tests.sh tests/hermes_cli/test_update*.py tests/hermes_cli/test_cmd_update*.py ``` Result: `22 files, 167 tests passed, 0 failed`. The tests cover the invariants from the issue: the probe never raises when `hermes_cli.gateway` is broken, an empty probe result stays distinguishable from an undeterminable one, and the warning names both the underlying cause and the manual recovery command. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass <!-- ran the 22-file update-path suite above, not the whole tree --> - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS (Darwin 25.5.0) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A <!-- N/A: helper docstrings only --> - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A <!-- N/A: no new config keys --> - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A <!-- N/A --> - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A <!-- the handler is platform-agnostic; `write_text` uses `encoding=\"utf-8\"`; no new POSIX-only calls --> - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A <!-- N/A --> ## Screenshots / Logs Before (reported in #78574 — no drain/restart step, exit 0): ```text ✓ Update complete! Tip: You can now select a provider and model: hermes model ``` After, when the restart phase aborts: ```text ⚠ Update incomplete — gateway auto-restart failed: cannot import name 'is_trivial_prompt' from 'agent.memory_provider' Gateway process(es) still running pre-update code: 4321 Restart it manually, then verify: hermes gateway restart hermes gateway status ``` …and the update process exits nonzero.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/78590",
        "createdAt": "2026-08-04T15:46:26Z",
        "updatedAt": "2026-08-13T17:54:45Z",
        "timestamp": "2026-08-13T17:54:45Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/gateway",
          "P1",
          "sweeper:risk-message-delivery",
          "sweeper:risk-compatibility",
          "area/install-update"
        ],
        "author": "PRATHAMESH75",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:79203",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): bound systemd planned-restart helper",
        "text": "## Summary - bound the transient systemd planned-restart helper to `restart_drain_timeout + 5s` - after that grace period, re-read the unit's `MainPID` and only force-kill when it still matches the original gateway PID - reset and restart the unit as before, using the resolved `systemctl` path - add focused coverage for user-unit scope, timeout derivation, PID identity guard, and action ordering ## Why this is valuable A gateway can finish logical shutdown (drain timeout, adapter disconnect, state cleanup, exit code selected) yet retain a live Python PID because a thread or interpreter teardown path is wedged. In that state systemd still reports the service as active, while Slack/other platforms are offline. `_launch_systemd_restart_shortcut()` currently waits forever in `while kill -0 <pid>`. Because the PID never disappears, the helper never reaches `systemctl restart`, and systemd has no failed/exited main process to recover. This turns a recoverable stuck shutdown into an indefinite messaging outage. The change keeps the graceful path intact and uses the existing configured drain budget. Force is a last resort only after that budget, and the fresh `MainPID` equality check prevents killing a recycled PID or a replacement gateway. The result is bounded recovery without weakening normal drain semantics or crash-loop backoff. This is the remaining systemd-helper variant of the restart fragility discussed in #12438; current `main` already bounds the general detached restart watcher, but not this transient systemd helper. ## Validation ```text scripts/run_tests.sh tests/gateway/test_gateway_shutdown.py tests/gateway/test_gateway_process_exit.py -q 11 passed ```",
        "url": "https://github.com/NousResearch/hermes-agent/pull/79203",
        "createdAt": "2026-08-05T08:29:38Z",
        "updatedAt": "2026-08-13T17:56:45Z",
        "timestamp": "2026-08-13T17:56:45Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "P2",
          "sweeper:risk-message-delivery"
        ],
        "author": "carljborg",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:79839",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(guardrails): loop-detect read-only shell commands in terminal",
        "text": "## Problem A single `terminal` call repeated **517 times** in one turn, ~46 minutes, each returning a byte-identical `[]` with `exit_code: 0`. Nothing stopped it until `max_iterations`. ``` terminal {\"command\":\"cd ~/repo && gh api repos/o/r/pulls/N/reviews 2>&1\",\"timeout\":15} -> {\"output\": \"[]\", \"exit_code\": 0, \"error\": null} x517 ``` Both loop detectors in `ToolCallGuardrailController` missed it: - **No-progress detector** only considers tools in `IDEMPOTENT_TOOL_NAMES`. `terminal` is in `MUTATING_TOOL_NAMES`, so `_is_idempotent()` returns `False` and repeated identical results were never tracked — even though the command being repeated was a pure read. - **Failure detector** only counts non-zero exits (`classify_tool_failure`). The command *succeeded* every time. Logically stuck, technically fine. Same session also showed 89x, 69x, 38x, 36x and 26x repeats of other `terminal` commands. ## Fix Classify the **command**, not just the tool name. `shell_command_is_read_only()` splits on `&&`/`||`/`;`/`|`/newline and requires every segment to be a known read. Allowlist-only — the default answer is \"this writes\": - unknown command, subcommand, or flag → write - `$(...)` / backticks → write (can hide anything) - `> out` / `>> out` → write; `2>&1` and `2>/dev/null` do not disqualify - write flags are scoped **per command**, because a global list conflates meanings: `-f` is `--field` to `gh` but `--file` to `grep`; `-x` is `--method` to `gh` but `--exclude-type` to `df` - `_FLAG_DECIDED_SUBCOMMANDS` handles cases where flags decide: `git config --get` reads, `git config k v` writes; `git branch` lists, `git branch name` creates A miss therefore costs a *later* block (the mutating threshold still applies), never a wrongly-blocked write. Mutating calls now get the same detector at a looser ceiling (`mutating_no_progress_block_after`, default 12): a write repeating identical args **and** output is also making no progress, just with a weaker signal since it may be legitimately polling something external. ## Verification - Classifier checked against 79 commands (readers, writers, flag edge cases, injection attempts): 0 mismatches. - Replaying the affected session's 828 real tool calls through the patched controller blocks at the **11th** identical call instead of admitting all 517. - False-positive check on legitimate patterns: a `sleep`-then-poll CI wait with stable output survives 20 polls; 30 distinct reads and a rebuild loop whose output changes are never blocked. - `pytest -k \"guardrail or no_progress or loop_cap or tool_executor or turn_finalizer or turn_context\"` → 97 passed, 4 skipped. `ruff` clean. ## Note on thresholds The shipped `hard_stop_after.idempotent_no_progress` of 5 cut off a legitimate CI wait too early in testing. Operators running this may want 10. `hard_stop_enabled` still defaults to `false`, so nothing changes for existing users until they opt in.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/79839",
        "timestamp": "2026-08-13T11:27:49Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P3"
        ],
        "author": "rodrigogs",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:79840",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(fallback): benched credentials must not disqualify a chain entry",
        "text": "## Problem A healthy provider silently vanished from the fallback chain, leaving a free-tier model as the last resort for 47 minutes. When DeepSeek returned `402 Insufficient Balance`, `credential_pool` benched the key for an hour (`EXHAUSTED_TTL_DEFAULT_SECONDS`; 402 is billing by definition, so it correctly keeps the full bench). `resolve_provider_client` then returned `None` — which at the call site in `try_activate_fallback` is **indistinguishable** from a provider the user never configured. Two things followed: **1. A false log message.** The chain logged: ``` Fallback to deepseek failed: provider not configured ``` DeepSeek was the *primary* provider in `config.yaml` and had served 195 calls earlier the same day. The message sent diagnosis toward config and credentials when the real cause was a billing bench with a known expiry. **2. Permanent suppression from a temporary condition.** The entry was added to `_unavailable_fallback_keys`, a memo cleared only when `fallback_providers` content changes (`gateway/run.py:_apply_fallback_chain_to_agent`). Credentials normally arrive via `hermes auth` (writes `auth.json`) or a new env var — neither touches `config.yaml` — so a **one-hour** bench removed the entry for the **entire life of the cached agent**. Reproduced deterministically by setting the pool entry to `last_status=exhausted, last_error_code=402, last_status_at=now-7s`: ``` pool.has_credentials=True pool.has_available=False _select_pool_entry -> (True, None) # pool present, nothing selectable resolve_provider_client -> None # logged as \"provider not configured\" ``` ## Fix `credential_pool` already separates the two cases — `has_credentials()` means the user configured something, `has_available()` means one is usable right now. Use it: - `_fallback_provider_benched_until()` returns `None` for genuinely unconfigured (memoize, as before), or the epoch time the next credential re-enters rotation for a benched one (skip this turn, stay retryable). An unreadable pool is treated as unconfigured, preserving previous behavior. - Benched entries log honestly and surface the wait: `Fallback to deepseek skipped: configured, but all credentials are in cooldown (retry in 59m). Staying retryable for later turns.` - `_unavailable_fallback_keys` gains a 10-minute TTL (`UNAVAILABLE_FALLBACK_RETRY_SECONDS`). An unconfigured provider still is not re-probed on every activation, but the memo can no longer outlive the condition that created it. The container stays a `set` so every existing `key in unavailable` check — including `tests/run_agent/test_nous_fallback_unavailable.py` — is unaffected; stamps live in a side table. ## Impact The affected chain had 6 entries but only **3 distinct credentials** (zai ×2 sharing `GLM_API_KEY`, openai-codex ×2 sharing one `device_code`, nous). With deepseek wrongly dropped, the only pool not already exhausted was the free tier, which then ran 467 API calls and produced a 517-iteration tool loop (separate fix in #79839). Redundancy that collapses under pressure into \"the worst model is the only survivor\" is worth guarding: this fix keeps a temporarily-benched good provider in the running instead of retiring it. ## Verification - Reproduction above now classifies as `BENCHED (retryable) (retry in 59m)` and is not memoized. - New `tests/run_agent/test_fallback_benched_credential.py`: benched entry stays retryable; unconfigured entry still memoized; memo expires so a provider configured mid-uptime is reconsidered; memo still short-circuits within its window. - `pytest -k \"fallback or failover or credential_pool or guardrail or chat_completion\"` → 960 passed, 4 skipped. `ruff` clean. (One unrelated pre-existing failure in `tests/tools/test_web_tools_config.py::TestParallelClientConfig` reproduces on a clean checkout — order-dependent state leakage, untouched here.)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/79840",
        "timestamp": "2026-08-13T11:28:06Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/deepseek",
          "area/auth",
          "P2",
          "area/billing"
        ],
        "author": "rodrigogs",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:80022",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(web): a benched lazy-install must not disable an importable SDK",
        "text": "## Problem `_ensure_parallel_sdk_installed` in `plugins/web/parallel/provider.py` documented one contract and implemented the opposite. Its docstring promised to swallow a benign availability error from the lazy-deps helper and let the subsequent `from parallel import ...` be the real gate on whether the SDK is usable. The handlers were: ```python except ImportError: pass # never fired except Exception as exc: raise ImportError(str(exc)) # fired for exactly the intended case ``` The root cause is a wrong exception class. `tools.lazy_deps.ensure` signals an unusable feature by raising `FeatureUnavailable`, which subclasses `RuntimeError` — not `ImportError`. So: - the narrow `except ImportError` arm never matched the error it was written for; - the broad `except Exception` arm caught `FeatureUnavailable` and converted \"cannot install right now\" into a hard `ImportError`; - the import that was supposed to make the decision was never reached. The practical consequence: on any host with `security.allow_lazy_installs=false`, the Parallel provider was dead whether or not `parallel-web` was actually installed. Declining to *install* a package was treated as the package being *absent*. A correctly provisioned host that had `parallel-web` present but lazy installs disabled by policy got the same failure as a host missing the dependency entirely. ## Fix Try the install; if it is impossible, then check whether it was needed at all. Only a genuinely missing package raises, and the raised error keeps the actionable install hint: ```python except FeatureUnavailable as exc: if not _parallel_sdk_importable(): raise ImportError(str(exc)) from exc # Already importable; nothing needed installing. except Exception as exc: raise ImportError(str(exc)) from exc ``` Unrelated faults (an `OSError` from the installer, say) are real problems and still surface to the caller as `ImportError` rather than being silently swallowed. An `ImportError` from importing the lazy-deps helper itself returns early and lets the import decide, which is what the original docstring described. ## Why `sys.modules` is checked before `find_spec` ```python if \"parallel\" in sys.modules: return True try: return importlib.util.find_spec(\"parallel\") is not None except (ImportError, ValueError): return False ``` Two reasons, and the order matters: 1. An already-imported module is importable by definition; consulting `sys.modules` first is both correct and cheaper than a filesystem scan. 2. `importlib.util.find_spec` raises `ValueError` on a module present in `sys.modules` whose `__spec__` is `None`. That is not a hypothetical: it is exactly how the existing test suite injects a stub SDK — a bare `types.ModuleType(\"parallel\")` has no `__spec__`. Calling `find_spec` first would raise `ValueError` on precisely the inputs the check needs to answer `True` for. `ValueError` is caught as well, so a malformed entry degrades to \"not importable\" instead of escaping as an unexpected exception type. This also explains the pre-existing failure in `tests/tools/test_web_tools_config.py::TestParallelClientConfig`: the suite installs a stub `parallel` module in `sys.modules`, but production code raised before ever looking at it. ## Test evidence `tests/tools/test_parallel_sdk_ensure.py` (new) covers the three distinct paths, since the bug was that one exception class was being conflated with another: - `test_importable_sdk_survives_disabled_lazy_installs` — `FeatureUnavailable` raised while the package imports fine: not an error. - `test_missing_sdk_still_reports_the_install_hint` — genuinely absent package still raises `ImportError` carrying `parallel-web`. - `test_unrelated_failure_is_still_surfaced` — an `OSError` from the installer is not swallowed. ``` $ ./venv/bin/pytest tests/tools/test_parallel_sdk_ensure.py tests/tools/test_web_tools_config.py -q 41 passed in 2.36s $ ./venv/bin/ruff check plugins/web/parallel/provider.py All checks passed! ``` Reverting only `provider.py` to its current `main` content, with the new tests in place, fails as expected — confirming the production change is what the tests are pinning rather than the tests passing incidentally: ``` FAILED tests/tools/test_parallel_sdk_ensure.py::test_importable_sdk_survives_disabled_lazy_installs FAILED tests/tools/test_web_tools_config.py::TestParallelClientConfig::test_creates_client_with_key FAILED tests/tools/test_web_tools_config.py::TestParallelClientConfig::test_singleton_returns_same_instance 3 failed, 38 passed ``` Note that the two `TestParallelClientConfig` failures are pre-existing on `main` and are fixed by this change; they were the symptom that led to the bug. ## Context Found alongside #79839 and #79840 while investigating a single incident; the three are independent fixes in different subsystems and can be reviewed and merged in any order. This branch is based on current `main` (`ff3793fdf`) and touches two files: `plugins/web/parallel/provider.py` and the new test module.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/80022",
        "timestamp": "2026-08-13T11:28:17Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "tool/web",
          "area/config",
          "P3",
          "sweeper:risk-compatibility"
        ],
        "author": "rodrigogs",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:80135",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(update): preserve non-prefixed systemd gateway process",
        "text": "## Summary Fixes an updater safety gap for **non-prefixed systemd-managed Hermes gateways**. `hermes update` already excludes active `hermes-gateway*` service PIDs from its manual-process sweep. A valid gateway run owned by a custom system unit (for example `gaiasignal-agent-hermes.service`) was not in that set, then was rediscovered as a manual process and sent `SIGTERM`. This focused patch classifies only existing positive `gateway run` candidates at the update manual-sweep boundary using `/proc/<pid>/cgroup`: - cgroup v2 `0::/...unit.service` and v1 `name=systemd:/...unit.service` => managed service, never raw-SIGTERM; - confirmed non-service membership => preserves the existing manual-cleanup behavior; - unreadable/ambiguous cgroup on Linux => fail safe and do not terminate; - no broad `systemctl` enumeration and no unit-name-prefix trust. ## Why A real custom systemd service was interrupted by the generic updater and systemd recovered it roughly five seconds later. The update code must not treat system-manager ownership as manual merely because the unit name is outside the standard `hermes-gateway*` convention. ## Verification Fork branch commit `2ece25503`: ```text uv sync --extra dev .venv/bin/python -B -m pytest -p no:cacheprovider -q \\ tests/hermes_cli/test_update_gateway_pid_classification.py \\ tests/hermes_cli/test_update*.py tests/hermes_cli/test_gateway.py # 162 passed ``` Reviewed/rebased validation commit `02f1a138` on current upstream `8f271272`: ```text # 164 update + gateway tests passed .venv/bin/python -B -m ruff check hermes_cli/update_cmd.py \\ tests/hermes_cli/test_update_gateway_pid_classification.py .venv/bin/python -B -m py_compile hermes_cli/update_cmd.py git diff --check ``` The new focused coverage verifies custom v2 and legacy v1 service cgroups, normal manual session scopes, unreadable cgroups, a false capability-probe path, non-systemd manual behavior, and preservation of targeted existing service discovery. A read-only check classified the affected live service PID as managed (`True`) without updating or restarting it. ## Scope / safety - Exactly two files; no dependencies, workflow files, or production changes. - Independent evaluator and full Consensus Board approved the final two-file change. - This draft intentionally does **not** authorize local deployment or a service restart. ## Maintainer note The personal fork is 534 commits behind current `main` because its PAT cannot push inherited workflow-file updates. GitHub compare shows this branch is **ahead by one commit with exactly these two file changes**. Please rebase/cherry-pick the focused patch onto current `main` before merging. Related context: closed unmerged #5409 added the existing standard-service exclusion; open #20488 and #16349 concern status/profile reporting rather than update-time termination; open #77854 addresses updater-owning standard gateway lifecycle and is currently dirty. No exact duplicate was found.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/80135",
        "createdAt": "2026-08-06T07:16:55Z",
        "updatedAt": "2026-08-13T17:56:49Z",
        "timestamp": "2026-08-13T17:56:49Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P2",
          "sweeper:risk-compatibility",
          "area/install-update"
        ],
        "author": "pankajarm",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:80343",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(tests): live module must not import the developer's .env at collection",
        "text": "## Symptom Two tests passed in isolation and failed in a wide run: ``` tests/tools/test_web_tools_config.py::TestBackendSelection::test_fallback_no_keys_defaults_to_firecrawl AssertionError: assert 'searxng' == 'firecrawl' - firecrawl + searxng tests/tools/test_web_tools_config.py::TestCheckWebApiKey::test_null_backend_value_does_not_crash AssertionError: assert True is False ``` ## Root cause `tests/run_agent/test_sequential_chats_live.py` is skipped unless `HERMES_LIVE_TESTS=1`, but the skip governs **execution**, not **import**. Its `_load_user_env()` call sat at module scope, so pytest ran it during **collection** — before any fixture, including the hermetic `_hermetic_environment` autouse fixture that scrubs credential-shaped variables. It copied *every* key from `~/.hermes/.env` into `os.environ` via `setdefault`. On the machine where this reproduced, that file holds 23 keys. Roughly 12 of them — `SEARXNG_URL`, `TELEGRAM_*`, `WHATSAPP_*`, `HERMES_SPOTIFY_*` — match neither `_CREDENTIAL_NAMES` nor the `_CREDENTIAL_SUFFIXES` heuristic in `tests/conftest.py`, so the scrubber never removed them. With `SEARXNG_URL` leaked, `_get_backend()` reached its searxng candidate and returned `searxng` instead of `firecrawl`. The suite's outcome therefore depended on what the developer happened to have configured locally. ## Why this was hard to bisect Two properties, both worth recording for the next person: **It is collection-time, not execution-order.** Selecting only the two victims out of the full tree still fails, with zero other tests executing: ``` $ pytest tests/ -k '<the two victim names>' 2 failed, 4 skipped, 26400 deselected ``` **Order is irrelevant.** Listing the victim file *first* still fails, because pytest imports every collected module before running anything: ``` $ pytest tests/tools/test_web_tools_config.py tests/run_agent/test_sequential_chats_live.py -k ... 2 failed, 37 deselected in 0.29s ``` This is why a pairwise bisect over the 39 candidate files found nothing, and why feeding pytest the full 180-item *ordered* prefix of node ids passes cleanly. Delta debugging over the collected **file set** converged on a single file in 22 runs. ## Fix Import only the key the module actually needs (`OPENROUTER_API_KEY`), and only when live runs are enabled. A skipped module should not touch shared process state merely by being imported. The added regression test walks every test module's AST and fails on any that copies file contents into `os.environ` at import time. It deliberately targets that *combination* rather than module-scope env writes in general: a fixed `os.environ[\"TERMINAL_ENV\"] = \"local\"` is deterministic and reviewable, and `tests/tools/test_code_execution.py` and `test_code_execution_modes.py` both use it on purpose. Reading the developer's filesystem is what makes the result differ per machine. ## Verification Shortest reproducer, 0.31s, before the fix: ``` $ pytest tests/run_agent/test_sequential_chats_live.py \\ 'tests/tools/test_web_tools_config.py::TestBackendSelection::test_fallback_no_keys_defaults_to_firecrawl' \\ 'tests/tools/test_web_tools_config.py::TestCheckWebApiKey::test_null_backend_value_does_not_crash' -q sFF -> 2 failed, 1 skipped in 0.31s ``` After: `2 passed, 1 skipped in 0.31s`. - Wide run `-k \"parallel or web_tools or lazy_deps\"`: both victims pass. The only remaining failures in that selection are `TestParallelClientConfig`, fixed independently by #80022 — with both branches applied the selection is **213 passed, 5 skipped, 0 failed**. - The new regression test fails when the production change is reverted (`git stash` the module, test goes red), so it is not a tautology. - `tests/tools/` in full passes; `ruff` clean on both touched files. Found alongside #79839, #79840 and #80022 while investigating a single incident — independent subsystems, separate PRs.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/80343",
        "timestamp": "2026-08-13T11:28:34Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/test",
          "comp/agent",
          "P3"
        ],
        "author": "rodrigogs",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:80353",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(api): surface agent context-window occupancy to thin clients",
        "text": "## What does this PR do? Surface the agent's live context-window occupancy as persisted session fields, so thin clients can display meaningful context usage without computing it from token sums or probing `model_config`. Currently the API server's session response exposes cumulative lifetime counters (`input_tokens` through `reasoning_tokens`) but nothing that tells a client \"how full is the agent's context window right now.\" Thin clients that want a context ring either sum the cumulative counters (fabricating readings like 1.9M/128K, see issue #50421) or attempt to parse `model_config` (not exposed by the server for security). This PR closes that gap by having the agent persist its compressor's measured occupancy onto the session row at each token-count update, and exposing it in the session detail endpoint. This is purely additive — existing callers and code paths are untouched. The two new columns default to `0` (meaning \"unknown\"), and `COALESCE` semantics prevent stale readings from being clobbered when a later update carries no context info. ## Changes Made 1. **`hermes_state_common.py`**: Added `context_window`/`context_used` columns to `sessions` table schema (`INTEGER NOT NULL DEFAULT 0`). Auto-added to existing DBs by `_reconcile_columns()`. 2. **`hermes_state.py`** (`update_token_counts`): Both absolute and incremental SQL branches persist context with `COALESCE` no-clobber semantics. 3. **`agent/context_breakdown.py`**: New `context_window_usage(agent)` helper — reads the agent's compressor state, mirrors the desktop TUI's sentinel logic. 4. **`agent/codex_runtime.py`**, **`agent/conversation_loop.py`**: Both token-count update paths now pass `**context_window_usage(agent)`. 5. **`gateway/platforms/api_server.py`**: Exposes `context_window`/`context_used` in the session detail payload. 6. **Tests**: 10 new tests across `tests/agent/test_context_breakdown.py` (5) and `tests/test_hermes_state.py` (5). ## Type of Change - [x] ✨ New feature (non-breaking change that adds functionality) ## How to Test ```bash # Run the new tests pytest tests/agent/test_context_breakdown.py -k \"context_window_usage\" pytest tests/test_hermes_state.py::TestContextWindowPersistence Or manually: 1. Start a session 2. `GET /api/sessions/{id}` → verify `context_window` and `context_used` are present (nonzero after one turn) 3. Send a follow-up message → verify fields update ## Checklist - [x] My commit message follows Conventional Commits - [x] My PR contains only changes related to this feature - [x] Tests pass: 10/10 - [x] Tested on: Windows 11 - [x] No new config keys, env vars, or core tools - [x] Cross-platform impact: none (no OS-specific code) --- **Commit summary:** 8 files, +173/-5 on current `origin/main` (HEAD `069551d19`). Single atomic commit with feature + tests. Ready to PR.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/80353",
        "createdAt": "2026-08-06T13:25:37Z",
        "updatedAt": "2026-08-13T12:56:03Z",
        "timestamp": "2026-08-13T12:56:03Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/gateway",
          "P3",
          "sweeper:risk-session-state",
          "area/usage-cost"
        ],
        "author": "mr-september",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:80493",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(memory): entry-point + project-dir discovery parity for out-of-tree providers (salvage of #18842, #40644, #76567)",
        "text": "Closes #40101. ## Problem `plugins/memory/` is closed to new providers, so every new memory backend now ships out of tree. But the out-of-tree path is measurably weaker than the in-tree one, and the docs already promise otherwise. `plugins/memory/__init__.py:_iter_provider_dirs()` walks **directories only** — bundled and `$HERMES_HOME/plugins/`. The general `PluginManager` (`hermes_cli/plugins.py:1371-1391`) scans four sources including pip entry points. `CONTRIBUTING.md:78` and `AGENTS.md` both state memory discovery \"picks them up from user/project plugin directories and pip entry points\". It does not. The user-visible symptom is #40101: a correctly-registered pip-installed provider reports `Plugin: NOT installed`. The workaround in the wild is a bespoke second install step — note that Memori, documented in `memory-providers.md`, ships `pip install hermes-memori` **plus** `hermes-memori install` for exactly this reason. ## This is a salvage, not a fourth attempt Three PRs already address parts of this and none have merged. Rather than add another competing implementation, this rebases them onto current `main` with authorship preserved and adds what none of them cover — the same approach as #79239. | PR | Author | Taken | |---|---|---| | #18842 | @smarzola | Entry-point discovery via `hermes_agent.memory_providers`, `ctx.register_skill()` forwarding, tests, developer-guide docs | | #76567 | @mlsmith | Classify entry-point provider plugins without importing them, including the dotted-name parent-import fix | | #40644 | @AxDSan | Not taken — its discovery half duplicates #18842, and its pipx switch was correctly rejected in review (pipx venvs are invisible to both `importlib.metadata` and the agent runtime) and reverted by the author. Its Mnemosyne docs belong in a docs PR. | #18842 and #76567 are complementary rather than competing: one adds memory entry-point discovery, the other stops the general PluginManager from eagerly importing such packages in every hermes process. ## What this adds on top **Project-local providers** (`./.hermes/plugins/<name>/`), gated on `HERMES_ENABLE_PROJECT_PLUGINS` exactly as `PluginManager` gates its own project scan. Completes the four sources. **`find_provider_dir()` resolves package entry points.** This is load-bearing rather than cosmetic: `config_schema.py` (the dashboard config panel) and `cli.py` (the `hermes <provider>` subcommands) are read **from disk**, not imported — `plugins/memory/config_schema.py:14-17` does this deliberately so the web server never pulls in the agent runtime. Without a directory, a pip-installed provider silently loses both. **`list_memory_provider_names()` includes entry-point providers**, so they appear in the dashboard's `memory.provider` dropdown. **Resolution stays import-free.** `resolve_module_origin()` is extracted from #76567's `_resolve_module_source()` and shared, so discovery walks a module's file layout instead of importing it. `find_provider_dir()` is called from the dashboard and from argparse setup — long before the operator has chosen a provider — so importing every installed candidate would execute third-party code on the strength of a package merely being present. A test asserts resolution leaves no side effect and no `sys.modules` entry. **`PluginContext.register_memory_provider()`.** Memory was the only provider category without one — context engine, image gen, video gen, web search, browser, TTS, transcription, secret source, dashboard auth and platform all have one. **`_ProviderCollector` delegates unknown `register_*` calls to a real `PluginContext`** instead of carrying three hand-written no-ops. It silently dropped `register_tool` / `register_hook`, and had **no `register_auxiliary_task` at all** — despite `PluginContext.register_auxiliary_task`'s own docstring using a memory provider (hindsight's pre-retain dedup) as its worked example. It can no longer drift behind `PluginContext`. **A raise after `register_memory_provider()` no longer costs the provider.** The loader caught it into a `logger.debug`, discarded the registered instance, and fell through to \"instantiate any `MemoryProvider` subclass\" — returning a *different, unconfigured* provider. A silent downgrade that looked like success, and the exact outcome of calling `register_auxiliary_task`. ## Precedence **bundled > user > project > entrypoint** — deliberately the reverse of the general PluginManager's later-wins order, and documented in the module docstring. A memory provider is activated by *name*, so letting a directory dropped into a working tree shadow a shipped provider would silently redirect the agent's memory. Existing behaviour is unchanged; the new sources sit below it. ## Safety Activation is still gated on `memory.provider` naming the plugin. Discovery **enumerates**; it does not load. Using a real `PluginContext` must not start also requiring the plugin in `plugins.enabled` — that would break every existing user-installed provider — and there is a test pinning it. ## Testing `406 passed` across `tests/plugins/memory/`, `tests/agent/test_memory_provider.py`, `tests/hermes_cli/test_plugins.py`, `tests/test_plugin_skills.py`, `tests/hermes_cli/test_web_server.py`. Six failures in `test_hindsight_provider.py` are a missing optional dependency (`hindsight_client_api`) and fail identically on `main`. New coverage in `tests/plugins/memory/test_discovery_sources.py`: project-dir discovery on and off, entry-point discovery, precedence, import-free resolution, bare-module entry points, the secondary-registration fix, and the `plugins.enabled` invariant. Verified end to end against a real third-party provider ([kainappsinc/elephant](https://github.com/kainappsinc/elephant)) installed by pip alone with no directory copy — it appears in the dropdown, resolves its directory, loads with its 34 tools, and renders its dashboard config panel. ## Follow-ups, deliberately not bundled - `hermes_cli/config_defaults.py:3792` hardcodes env-var metadata per bundled provider; external plugins cannot join. Largely served already by `get_config_schema()` / `config_schema.py`, and a much larger refactor. - `hermes_cli/main.py:9210` still hardcodes `\"honcho\"` in `_SUBCOMMANDS`. Cosmetic — it only affects unquoted multi-word session names — but it is the same class of thing PR #5295 removed.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/80493",
        "createdAt": "2026-08-06T17:19:28Z",
        "updatedAt": "2026-08-13T13:32:43Z",
        "timestamp": "2026-08-13T13:32:43Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/plugins",
          "tool/memory",
          "tool/skills",
          "P3"
        ],
        "author": "gigabyte22",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:80533",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(gateway): add webhook completion scripts",
        "text": "## What does this PR do? Adds an optional route-level `completion_script` for static, agent-backed webhook routes. Webhook requests continue to return `202 Accepted` immediately. After the asynchronous agent run reaches a terminal `success` or `failure`, Hermes invokes the configured script with a versioned JSON envelope on stdin. This gives external orchestrators a generic completion signal without coupling the webhook adapter to a specific provider or delivery target. The implementation intentionally remains best effort: script failures do not replace the original processing outcome or block one-shot session cleanup, and expected cancellation does not invoke the finalizer. ## Related Issue Fixes #80531 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [x] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - Add `completion_script` dispatch snapshots and terminal lifecycle handling in `gateway/platforms/webhook.py`. - Reuse the confined webhook script runner in `gateway/platforms/webhook_filters.py`, with JSON stdin, profile-relative script resolution, timeout handling, and output suppression for finalizers. - Prevent duplicate completion invocations when the base lifecycle reports more than one outcome for the same event. - Keep expected cancellation and `deliver_only` routes outside the completion lifecycle. - Add real processing-path tests for success, failure, cancellation, nonzero exit, timeout, session cleanup, and dispatch-time route snapshots. - Document the route key, envelope contract, limitations, and configuration example. ## How to Test 1. Configure a static webhook route with `completion_script: finalize.py`, and place the script under the active profile's `~/.hermes/scripts/` directory. 2. POST a signed webhook and verify Hermes returns `202` while the script later receives the documented JSON envelope on stdin. 3. Run: ```bash scripts/run_tests.sh \\ tests/gateway/test_webhook_session_close.py \\ tests/gateway/test_webhook_adapter.py \\ tests/gateway/test_webhook_integration.py \\ tests/gateway/test_webhook_dynamic_routes.py \\ tests/gateway/test_webhook_deliver_only.py \\ tests/gateway/test_webhook_signature_rate_limit.py -q ``` Result: 56 passed. 4. Run focused static checks: ```bash uv run --python 3.11 ruff check gateway/platforms/webhook.py gateway/platforms/webhook_filters.py tests/gateway/test_webhook_adapter.py tests/gateway/test_webhook_session_close.py uv run --python 3.11 ty check gateway/platforms/webhook.py gateway/platforms/webhook_filters.py tests/gateway/test_webhook_adapter.py tests/gateway/test_webhook_session_close.py ``` Both pass. The full local suite was also started, but this checkout discovers an unrelated external deployment repository whose source-anchor patcher tests fail when upstream `webhook.py` changes. The Hermes-focused tests above pass. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - Not checked because the full local suite is not green in this checkout: test discovery includes an unrelated external deployment repository whose source-anchor patcher tests fail when upstream `webhook.py` changes. The focused Hermes webhook suite passes (56 tests), as do Ruff and `ty`; see **How to Test** above. - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15.6 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A (no architecture or workflow change required) - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## Screenshots / Logs Not applicable; this is a configuration and lifecycle feature covered by automated tests. ## Infographic <img width=\"1024\" height=\"768\" alt=\"圖片\" src=\"https://github.com/user-attachments/assets/c989c915-b3f0-4f23-8beb-617820bfbd18\" />",
        "url": "https://github.com/NousResearch/hermes-agent/pull/80533",
        "createdAt": "2026-08-06T18:54:24Z",
        "updatedAt": "2026-08-13T15:57:46Z",
        "timestamp": "2026-08-13T15:57:46Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "platform/webhook",
          "P3",
          "sweeper:risk-message-delivery"
        ],
        "author": "chenwei791129",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:80847",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(tools): stop browser_cdp frame_id routing from skipping private-page SSRF guard",
        "text": "## What does this PR do? `browser_cdp(..., frame_id=...)` had two private-page / SSRF gaps on the OOPIF path: (1) a non-dict `params` value skipped the clear validation error and could let the guard fail open, and (2) even with valid params, the guard only checked the top-level page URL before supervisor dispatch, so a public parent could embed a private OOPIF and still receive page-content CDP such as `Runtime.evaluate`. This PR closes both paths with shared fail-closed checks before child-session dispatch. ### Symptom A `browser_cdp` call that sets `frame_id` and passes a truthy non-dict `params` (for example a string) does not return `'params' must be an object/dict`. Separately, when the top-level page is public but the selected OOPIF URL/origin is private (or still empty), page-content CDP can still reach the child session. ### Impact Malformed `params` on the `frame_id` path can silently skip the private-page / SSRF boundary that other `browser_cdp` entry points enforce after validating `params`. A public top-level page with a private child OOPIF can also expose private frame content through `frame_id` routing. Blast radius beyond these paths was not measured. ### Bug Cause **Trigger:** `tools/browser_cdp_tool.py` `browser_cdp()` / `_browser_cdp_via_supervisor()` - `frame_id` early-return before `isinstance(params, dict)`; private-page guard uses top-level `_current_page_private_url` only; selected-frame metadata is available but was not checked before child-session dispatch. **Causal chain:** 1. Caller passes `frame_id` with either non-dict `params`, or a well-formed call targeting a private OOPIF under a public top page. 2. Non-dict `params` AttributeErrors inside the guard probe; broad except fail-opens. Or the top-page probe returns public while the child frame is private / metadata-empty. 3. Call reaches `_browser_cdp_via_supervisor` and dispatches into the child CDP session without a validation error or frame-level private-page block. **Why it is wrong:** Params validation lived only on the post-early-return / stateless branch, while the guard is intentionally fail-open for probe failures. Frame routing resolves child URL/origin but never validated them before page-content sinks. **Working sibling / contrast:** Without `frame_id`, non-dict `params` already returned a clear validation error. Top-level private pages already blocked restricted methods; the gap is specifically the selected OOPIF address. **Ruled out:** Not a missing private-page guard call on `frame_id` for well-formed top-level-private cases - that path already invoked the top-page guard. The remaining gaps are malformed params + child-frame address coverage. ### Fix Validate `params` as a dict before the `frame_id` early-return. After resolving the selected frame (frame_tree or raw `_frames` fallback), validate URL/origin for private/internal addresses before dispatch; fail closed when an OOPIF has a `session_id` but empty URL/origin metadata. Retain `_CDP_PRIVATE_PAGE_ALLOWED_METHODS` so navigation/inspection still works on blocked frames. ## Related Issue Fixes #80846 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [x] Security fix ## Changes Made - `tools/browser_cdp_tool.py` - move params dict validation above the `frame_id` early-return; validate selected frame URL/origin (and empty-metadata OOPIF) before supervisor dispatch; keep navigation/inspection allowlist - `tests/tools/test_browser_cdp_tool.py` - regression coverage for non-dict params, public-top/private-child OOPIF, raw `_frames` fallback, allowlist survival, and empty URL/origin fail-closed ## How to Test 1. Manual: with the private-page guard active, call `browser_cdp(method='Runtime.evaluate', params='not-a-dict', frame_id='...')` and confirm the tool returns a params object/dict validation error (not a successful supervisor dispatch). 2. Manual / unit: public top-level page + private OOPIF `frame_id` must block `Runtime.evaluate` / `DOM.getDocument`; allowlisted methods such as `Page.reload` must still dispatch; OOPIF with `session_id` but empty url/origin must block page-content CDP. 3. Automated: ```bash scripts/run_tests.sh tests/tools/test_browser_cdp_tool.py -q ``` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `scripts/run_tests.sh` on relevant tests and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Windows 11 (WSL test runner) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys - N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows - N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) - N/A (validation helpers are platform-agnostic) - [x] I've updated tool descriptions/schemas if I changed tool behavior - N/A (behavior matches the documented params object contract and private-page boundary on sibling paths)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/80847",
        "createdAt": "2026-08-07T06:20:47Z",
        "updatedAt": "2026-08-13T13:46:20Z",
        "timestamp": "2026-08-13T13:46:20Z",
        "metrics": {
          "reactions": 0,
          "comments": 5
        },
        "labels": [
          "type/security",
          "tool/browser",
          "P2",
          "sweeper:risk-security-boundary"
        ],
        "author": "fangliquanflq",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:81054",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(langfuse): shutdown client on session finalize to avoid interpreter-teardown TypeError",
        "text": "## Problem Quitting a Hermes session with the `observability/langfuse` plugin enabled prints a noisy traceback: ``` Exception ignored in: <generator object Langfuse._create_span_with_parent_context at 0x...> Traceback (most recent call last): File \".../langfuse/_client/client.py\", line 1297, in _create_span_with_parent_context File \".../opentelemetry/trace/__init__.py\", line 597, in use_span TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union ``` ## Root cause The langfuse plugin never called `client.shutdown()`. It relied entirely on the Langfuse SDK's own `atexit.register(self.shutdown)` handler (`resource_manager.py:279`). That atexit handler fires during interpreter finalization — by then module globals (notably `opentelemetry.trace.Span`) may already be torn down to `None`. The SDK's span-finalization path runs `use_span` → `isinstance(span, Span)` (`opentelemetry/trace/__init__.py:597`), and `Span` being `None` raises the `TypeError`. Python suppresses it as \"Exception ignored in: <generator>\". This is cosmetic (no data loss — spans were already queued), but every langfuse-enabled session sees it on quit. ## Fix Register an `on_session_finalize` hook in the plugin that explicitly calls `client.shutdown()` while the interpreter is still alive. `on_session_finalize` fires from the normal CLI exit path (`cli.py:1228` → `lifecycle.finalize_session`), not from atexit — so all modules are intact. `client.shutdown()` flushes pending spans and joins the background export threads. The SDK's own atexit handler then becomes a no-op (it checks `_shutdown` and unregisters itself), so the race with interpreter teardown never starts. ## Test plan - [x] `tests/plugins/test_langfuse_plugin.py` — 24/24 pass (updated manifest assertion for the new hook) - [x] `tests/hermes_cli/test_lifecycle.py` — 3/3 pass - [ ] Manual: quit a Hermes session with the langfuse plugin enabled — the \"Exception ignored in: <generator>\" traceback is gone",
        "url": "https://github.com/NousResearch/hermes-agent/pull/81054",
        "createdAt": "2026-08-07T13:29:05Z",
        "updatedAt": "2026-08-13T17:40:36Z",
        "timestamp": "2026-08-13T17:40:36Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/plugins",
          "P3"
        ],
        "author": "bgodlin",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:81088",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): route approval/denial replies before busy handler",
        "text": "## What does this PR do? Fixes the gateway busy handler so that approval/denial replies are routed to the canonical `/approve`/`/deny` handlers before the generic queue/steer/interrupt busy logic can consume them. The second (and subsequent) pending dangerous-command approval in the same agent run could be missed when the user's reply reached the busy handler in a form that wasn't matched by the previous bare-word-only approval routing: slash commands inside quoted/forwarded messages, display-prefixed commands (`!approve` on Slack/Matrix), or multi-line replies where the command is on the last line. ## Related Issue Fixes #81026 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `gateway/run.py`: - Added `_route_pending_approval_response()`, a shared helper that detects approval/denial replies in multiple forms (slash, bare-word, `!`/`/` display prefixes, quoted last-line, aliases like `yes`/`no`) and only activates when `has_blocking_approval(session_key)` is true. - Replaced the bare-word-only approval routing in `_handle_active_session_busy_message()` with the new helper. - Added the same guard at the start of the busy queue/steer/interrupt path in `_handle_message()` so un-recognized slash-shaped replies (e.g. quoted commands) are not queued or steered when an approval is pending. - `tests/gateway/test_plaintext_approval_routing.py`: - Added a parametrized regression test covering `/approve`, `/approve all`, `/approve session`, `/approve always`, quoted replies, `!approve`, and the `yes` alias. ## How to Test 1. `uv run python -m pytest tests/gateway/test_plaintext_approval_routing.py -q -o 'addopts='` 2. `uv run python -m pytest tests/gateway/test_approve_deny_commands.py tests/gateway/test_priority_path_compression_demotion_56391.py tests/gateway/test_subagent_protection_30170.py -q -o 'addopts='` 3. `scripts/check.sh --project hermes-agent --worktree worktrees/hermes-agent/81026` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature - [x] I've added tests for my changes - [x] I've tested on my platform: macOS",
        "url": "https://github.com/NousResearch/hermes-agent/pull/81088",
        "timestamp": "2026-08-13T11:59:50Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "P2"
        ],
        "author": "Christopher-Schulze",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:81351",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): keep-alive hidden panes can no longer paint through",
        "text": "## Problem Closes #79833 — with Inline Embeds set to \"Always\", an X/Twitter card that auto-loads in one session keeps painting over the whole UI after switching to another session, the Artifacts view, or Settings. It also survives a renderer reload (Ctrl+R). ## Root cause Keep-alive tabs stay **mounted** when inactive and are hidden with `visibility: hidden` (not `display: none`) so their layout box — and with it the scroll position — survives a tab round-trip (`pane-shell/tree/renderer/tree-group.tsx`). But CSS `visibility` is inherited-*and-overridable*: Twitter's embed script renders its card into a sandbox element with an **inline** `visibility: visible` (`platform.twitter.com/widgets.js`), which CSS then lets paint through the hidden pane over the active view. It returned after Ctrl+R because the tab layout and the embed mode (\"always\") are persisted, so the embed re-auto-loads. Plain-iframe embeds (YouTube, Spotify, maps) do not set inline `visibility`, which is why only the X/Twitter card was affected. ## Fix One pane-level CSS rule — every descendant of a `[data-pane-hidden]` pane follows the pane's visibility (`visibility: inherit !important`). An author `!important` rule beats the widget's inline non-important style. The fix does not require additional unmounting: the embed stays mounted and renders exactly as before when its tab is active (no re-fetch, no flicker), and scroll preservation is untouched. ## Verification - New \"hidden pane painting contract\" tests in `pane-visibility.test.ts` validate the rule against the **live stylesheet** (extracted by an anchored regex, so removal/rename fails loudly): a `visibility: visible` descendant inside a hidden pane computes `hidden`; an element outside is unaffected. - Real-Chromium (Electron 40) probes: with the rule, a widget-style card inside a hidden pane computes `hidden`; without the rule it computes `visible` (bug mechanism reproduced); cards outside hidden panes stay `visible`. - Full desktop UI suite: 4525 tests passed (480 files, 2 skipped); `tsc --noEmit` clean. ## Notes - **Open question:** a widget that set `visibility: visible` on elements *inside* a shadow root (rather than on the host, which is what Twitter's script does) would not be covered — CSS cannot select into shadow roots. No known provider (Twitter/Instagram/TikTok, all checked) does this; if one appears, the follow-up is gating widget embeds on pane visibility in `social-embed.tsx`. - **Open question:** the rule cannot win against a widget that set inline `visibility: visible !important`; today's widget scripts use plain inline styles.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/81351",
        "createdAt": "2026-08-07T22:43:07Z",
        "updatedAt": "2026-08-13T16:12:10Z",
        "timestamp": "2026-08-13T16:12:10Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "P3",
          "comp/desktop"
        ],
        "author": "DavidMetcalfe",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:81910",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "perf(desktop): keep built-in theme fonts local",
        "text": "## Summary Keep the built-in Nous and Midnight theme font stacks fully local. - Midnight now uses the JetBrains Mono WOFF2 faces already shipped in the Desktop bundle instead of requesting the same family from Google Fonts. - The default Nous stack uses native monospace faces first and bundled JetBrains Mono as its cross-platform fallback, removing an unused Courier Prime request. - Ember retains its IBM Plex Mono URL because that remote face is an explicit theme choice rather than an unused fallback. ## Root cause Midnight declared a Google Fonts stylesheet for JetBrains Mono even though regular, bold, and italic faces are bundled by `styles.css`. There was also a less obvious inheritance issue: typography is merged with the Nous defaults, so removing only Midnight's URL made it inherit the Nous Courier Prime URL. On macOS that stylesheet was fetched even though Menlo, earlier in the stack, was the rendered face. ## Measured impact Runtime verification used Chromium performance resource entries on the packaged macOS arm64 app with Midnight active. | Startup resource | Before | After | |---|---:|---:| | Google Fonts requests | 2 | **0** | | Active external font stylesheets | 1 | **0** | The post-change renderer reported the local JetBrains Mono stack and no `fonts.googleapis.com` or `fonts.gstatic.com` resources. This is primarily a startup/network/privacy cleanup; no battery-life percentage is claimed. ## Validation - 12 theme preset tests passed - Desktop typecheck passed - Desktop lint passed - `git diff --check` passed - Packaged and runtime-verified on macOS arm64",
        "url": "https://github.com/NousResearch/hermes-agent/pull/81910",
        "createdAt": "2026-08-08T18:01:17Z",
        "updatedAt": "2026-08-13T12:52:30Z",
        "timestamp": "2026-08-13T12:52:30Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/perf",
          "P3",
          "comp/desktop"
        ],
        "author": "alexdev03",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:82228",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(weixin): add POST /api/weixin/send endpoint",
        "text": "POST /api/weixin/send — stateless HTTP send for alert-bridge/cron. - Auth via API_SERVER_KEY - Input validation: Content-Type (415), chat_id format (400), message length (400) - JSON body shape validation (rejects non-dict payloads) - Profile adapter resolution (named-profile support via _api_request_profile ContextVar) - Weakref for runner reference (avoids circular reference cycle) - Audit logging: [weixin-send-audit] - 19 unit tests Closes #70892",
        "url": "https://github.com/NousResearch/hermes-agent/pull/82228",
        "createdAt": "2026-08-09T03:47:52Z",
        "updatedAt": "2026-08-13T15:08:22Z",
        "timestamp": "2026-08-13T15:08:22Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "platform/wecom",
          "area/auth",
          "P2",
          "sweeper:risk-message-delivery",
          "sweeper:risk-security-boundary"
        ],
        "author": "HouMinXi",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:82236",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix: ignore glob-shaped skill support paths",
        "text": "## What does this PR do? Direct URL skill installation scans SKILL.md for referenced support files. Prose such as ``references/*.md`` currently matches the local-path parser, causing Hermes to request a literal file named `*.md` and reject otherwise valid skill bundles. This change ignores glob-shaped path candidates (`*`, `?`, `[` or `]`) while continuing to fetch explicit concrete references and preserving valid query/fragment suffixes, including extensionless paths such as `references/LICENSE?download`. It also URL-encodes decoded support-file paths at both direct-URL and GitHub Contents API fetch boundaries, so concrete filenames containing encoded delimiters (for example `%23`) are requested as files rather than mistaken for URL fragments. ## Related Issue Related: https://github.com/Panniantong/Agent-Reach/issues/584 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✅ Tests (adding or improving test coverage) ## Changes Made - Updated `tools/skills_hub.py::_referenced_support_paths` to skip glob-shaped pseudo-paths. - Preserved legitimate query/fragment suffixes, including valueless queries on extensionless support files. - Added fetch-safe encoding for decoded support paths in `UrlSource` and `GitHubSource`. - Added regression coverage proving concrete links are preserved while `references/*.md` prose is ignored. ## How to Test 1. Run `uv run --with pytest python -m pytest tests/tools/test_skills_hub.py -q -o 'addopts='`. 2. Install an Agent Reach `SKILL.md` URL containing the former prose reference. 3. Confirm Hermes installs `SKILL.md` and its seven explicit reference files without requesting a literal `references/*.md` path. ## Checklist ### Code - [x] I've read the Contributing Guide. - [x] My commit messages follow Conventional Commits. - [x] I searched existing PRs; PR #30633 covers install exit status but not support-path parsing. - [x] My PR contains only changes related to this parser fix. - [ ] I've established the complete `pytest tests/ -q` suite as green; an initial run lacked optional dependencies, and a broader-extra run encountered environment/integration failures outside the changed modules before completion. The two changed modules pass independently; their combined order-dependent failure reproduces unchanged on current `main`. - [x] I've added tests for the bug. - [x] Tested on macOS 14.1. ### Documentation & Housekeeping - [x] Documentation update: N/A; behavior is internal and covered by tests. - [x] `cli-config.yaml.example`: N/A. - [x] `CONTRIBUTING.md` / `AGENTS.md`: N/A. - [x] Cross-platform impact considered; the check is platform-independent string parsing. - [x] Tool descriptions/schemas: N/A. ## Verification - `tests/tools/test_skills_hub.py` — **98 passed** - `tests/tools/test_skill_bundle_provenance.py` — **6 passed** - Ruff — passed - `compileall` — passed - Isolated direct-URL Agent Reach installation — installed `SKILL.md` plus all seven explicit references successfully",
        "url": "https://github.com/NousResearch/hermes-agent/pull/82236",
        "createdAt": "2026-08-09T03:57:12Z",
        "updatedAt": "2026-08-13T12:54:14Z",
        "timestamp": "2026-08-13T12:54:14Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "tool/skills",
          "P2"
        ],
        "author": "fifeli",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:82243",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(tools): compose deferred tools in execute_code",
        "text": "## Summary - let local `execute_code` programs search, inspect, and invoke deferred MCP and plugin tools through Hermes' existing Tool Search bridge - build the `execute_code` schema from the final per-session tool surface while preserving its established direct terminal, file, and web helper umbrella - preserve session identity and toolset scope through nested dispatch - keep deferred bridges local-only and unavailable to remote file RPC - JSON-frame every RPC result, including plain and multiline strings - keep terminal schemas, agents, environments, cleanup, probes, locks, aliases, overrides, and working directories scoped to the active profile and terminal configuration Closes #82287. ## Problem Hermes already had two complementary paths: 1. Tool Search keeps deferred MCP and plugin schemas out of the model-facing tool list. 2. `execute_code` lets one Python program compose several Hermes tool calls. Those paths did not compose. Once an MCP or plugin tool was deferred, `execute_code` could not search for it, inspect its schema, or invoke it. ## Changes - generate deferred bridge stubs only from the final explicit per-session tool list - allow one local sandbox program to call `tool_search`, `tool_describe`, and `tool_call` - keep `execute_code`'s existing direct helper umbrella at runtime and document it in the dynamic schema - rebuild that schema after Tool Search assembly - carry `session_id`, `enabled_toolsets`, and `disabled_toolsets` through nested dispatch - keep deferred bridges off remote backends until those transports have a session-private capability channel - JSON-encode all RPC result shapes before newline framing, including plain text, multiline text, JSON-looking strings, structured values, multimodal results, and errors - resolve terminal settings from the active profile snapshot and fingerprint the full effective backend configuration - namespace cached environments, creation locks, task overrides, working-directory records, and subagent aliases by routed profile and terminal configuration - track every environment's owning profile, session, backend fingerprint, and lifetime so expiry and teardown use the creating profile's policy - evict dead remote environments, creation locks, and file-operation caches through one structured degraded-mode path, including connection failures from the file-RPC poller - resolve the active profile backend before consulting the process-wide Python toolchain probe cache, skip local probes for remote profiles, and propagate profile context into the probe worker - revalidate backend fingerprints after creation-lock waits and publish file-operation wrappers inside the same lock boundary - apply the shared task CWD policy to lazy sandbox creation, fail closed while an RPC poller remains live, and tear down ephemeral prompt-probe environments - keep internal multiplex namespace keys distinct from user-controlled task IDs and preserve task-owned workspace mounts for every raw ID shape - apply administrator-managed terminal policy before profile snapshots, fingerprints, schemas, cache identity, and execution - retain one stable creation lock per task key while retiring stale environments - evict dead terminal, file, and vision environments after established connections fail - stop remote `execute_code` promptly when its RPC transport fails while the generated client is waiting - close delegated children before parent sandbox teardown and clean every terminal task identity used by an agent - serialize stale retirement, backend cleanup, replacement creation, and file-wrapper publication under stable per-task locks - propagate remote RPC cancellation through standard and managed execution backends - use the shared container and SSH policy builders for terminal, file, and code-execution environment creation - clear task overrides, session working directories, and aliases during canonical teardown even when no environment remains cached - tag shared file wrappers with their raw session owner so stale-wrapper recovery cannot overwrite another session's workspace - preserve current main's execute_code argument recovery while forwarding session, toolset, and deferred-bridge scope through the validated registry handler - preserve adjacent tolerant-client and multi-frame transport work owned by #74100 and #81622 ## Safety - process-global tool names cannot authorize deferred bridges - deferred bridges require final-session authorization and a local backend - tools outside the parent session's toolset scope remain unavailable - deferred bridge tools cannot recursively invoke themselves - `tool_call` still routes the underlying tool through normal schema validation, middleware, hooks, approvals, and dispatch - remote shared-file RPC exposes only the established direct helper surface - direct helper schema and runtime surfaces match on cache misses and hits - terminal configuration changes cannot reuse stale environments or local-only schemas - delegated children inherit parent sandbox overrides and do not destroy the shared parent environment when the child closes ## Review fixes The follow-up commits address the reported integration and lifecycle cases: 1. The dynamic schema documents the established direct helper umbrella, while runtime stub generation keeps those helpers alongside any authorized deferred bridges. 2. The server JSON-frames every supported result, so plain and multiline plugin output round-trips through generated UDS and file clients. 3. Gateway agents and model tool definitions rebuild when the active profile's terminal configuration changes. 4. Child-first sandbox creation resolves the parent's image and working-directory overrides before environment creation. 5. Remote container execution rejects host-only working directories and uses the shared terminal working-directory policy. 6. Remote creation, execution, and file-RPC polling return structured degraded results and evict all dead cached state on connection loss. 7. Session teardown retires every runtime namespace owned by that profile and session. Secondary-profile agents restore their own profile scope before cleanup. 8. The cleanup worker applies each environment's stored lifetime instead of reading one process-global profile policy. 9. Remote profiles cannot consume or warm the host's cached Python toolchain probe, and local probe workers inherit the active profile context. 10. Lazy image-triggered sandbox creation uses the same task override, session CWD, and container remapping policy as terminal, file, and code execution. 11. Terminal, file, and code-execution waiters revalidate an environment's fingerprint after acquiring its creation lock without removing the held lock from the registry. 12. Remote execution cannot return success while its file-RPC poller remains live after the bounded join. 13. Prompt backend probes use nonpersistent environments and clean them in a `finally` block. 14. User-controlled task IDs that begin with `mpx:` cannot bypass the active profile and backend namespace. 15. Per-session Docker workspace selection uses the raw task identity, including IDs whose final segment is `default`. 16. Administrator-managed terminal values win consistently in profile snapshots, backend fingerprints, schema/cache identity, and execution. 17. Stale retirement retains the original creation lock, so a concurrent creator cannot publish a file wrapper for a retired environment. 18. Established terminal, file, and vision connection failures evict cached state, and remote RPC loss aborts a blocked model-facing call promptly. 19. Agent teardown closes active children first and cleans every terminal task identity recorded across its turns. 20. Stale detection, slow backend cleanup, replacement creation, and file-wrapper publication share one stable per-task lock, including idle expiry, degraded eviction, and explicit teardown. 21. Standard and managed remote execution observe RPC cancellation, terminate the active command, and stop the script worker before returning a degraded result. 22. File and code-execution creation use the same complete container and SSH policy builders as terminal creation, including managed Modal selection, mounts, forwarding, extra arguments, and persistence policy. 23. Canonical teardown clears task overrides, session working-directory state, and aliases in a `finally` path even after the active environment has already been evicted. 24. Stale shared file wrappers restore working-directory state only to the raw session that created them; a later session reusing the collapsed environment key keeps its own workspace override. 25. The rebased registry handler keeps current main's malformed-argument guidance and forwards `session_id`, toolset scope, and deferred-bridge authorization to execution. Behavioral tests cover code-execution-only sessions, final Tool Search surfaces, remote withholding, generated-client round trips, cache hits, terminal configuration transitions, concurrent profiles, same-task environment isolation, all-namespace teardown, profile-scoped cleanup, per-environment expiry, task overrides, working directories, child aliases, child cleanup, multimodal results, errors, and concurrent calls. ## Local verification A focused terminal, file, code-execution, managed-backend, process-registry, delegation, gateway-cache, multiplex, and prompt matrix completed with: ```text 325 passed, 1 skipped, 0 failed ``` The focused regression slices include the stale-cleanup interleaving, held-lock eviction, full managed container policy, real local subprocess cancellation, managed Modal cancellation, blocked remote RPC cancellation, and canonical teardown state cleanup. Additional exact-head checks passed: ```text python -m ruff check <changed Python files> python -m py_compile <changed Python files> python scripts/audit_pr_attribution.py git diff --check ``` Verified after rebasing onto current `origin/main` at exact head `7cf025117b4e00c50a6b177690138be6663bee5b`. GitHub CI at this exact head passed all required checks, including 12 Python test slices, E2E tests, macOS and Windows tests, Ruff and type checks, contributor attribution, supply chain scans, and both Docker builds. An independent exact-head blocking review returned PASS with no findings and ended `READY FOR MAINTAINER REVIEW: yes`. ## Related work This PR includes the functional session-ID forwarding commit from #52000 with Christopher Schulze's original authorship preserved. That change also fixes #51931.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/82243",
        "timestamp": "2026-08-13T12:19:51Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/feature",
          "innovation",
          "comp/tools",
          "tool/mcp",
          "tool/code-exec",
          "P3",
          "needs-decision"
        ],
        "author": "DomGrieco",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:82332",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(langfuse): finalize open root spans at exit so short-lived processes export complete traces",
        "text": "## What Short-lived Hermes processes (kanban workers, `hermes chat -q` one-shots, cron jobs) export **anonymous traces** to Langfuse: no name, no `sessionId`, empty metadata — just a bag of orphaned observations. On a board running a few parallel workers this quickly becomes the majority of traces (33 of the most recent 50 on the instance where this was diagnosed). Root cause: the plugin ends the root span in `_finish_trace()`, which only runs when a turn finalizes (final assistant message with no tool calls). A process that exits while the last LLM response still has tool calls queued never reaches it. The SDK's own atexit flush then exports the ENDED child observations, but the root span never lands — so the backend renders a nameless trace shell around orphaned children. Fix: `_finalize_all_traces()` drains `_TRACE_STATE` and ends every open root span (children first), registered with `atexit` immediately after the Langfuse client is constructed. atexit is LIFO, so it runs BEFORE the SDK's own shutdown hook and the spans ended here are still picked up by the exporter. Idempotent; fail-open per span; no-op when nothing is pending. ## Why - Anonymous traces are unusable for debugging: they can't be found by session, carry no model/platform metadata, and pollute every trace listing. - Any deployment that runs workers/cron alongside the gateway hits this constantly. ## How to test ``` scripts/run_tests.sh tests/plugins/test_langfuse_plugin.py -- -q ``` 29 tests (2 new): `_finalize_all_traces` ends every open root and drains state (idempotent on re-entry), and the atexit hook is registered on client init. Both new tests fail without the fix (verified by stashing it). Live verification on the diagnosed instance: after the fix, a `hermes chat -q` one-shot exports a complete \"Hermes turn\" trace with session id; before, the same flow produced a nameless trace with 17+ orphaned observations. Tested on Linux (aarch64).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/82332",
        "createdAt": "2026-08-09T07:22:07Z",
        "updatedAt": "2026-08-13T17:40:39Z",
        "timestamp": "2026-08-13T17:40:39Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "P3",
          "telemetry"
        ],
        "author": "aldoeliacim",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:82520",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cron): guard against PyPI utils package shadowing local utils.py",
        "text": "## Problem When the PyPI `utils` package is installed in the same venv (common in the webui Docker setup), `from utils import atomic_write_text` in `cron/jobs.py` and `from utils import atomic_replace` in `cron/suggestions.py` resolve to the PyPI package instead of the local `utils.py`, causing: ``` ImportError: cannot import name 'atomic_write_text' from 'utils' (/app/venv/lib/python3.12/site-packages/utils.py) ``` This breaks the `/api/crons` endpoint in the webui Docker setup. ## Fix Before each bare `from utils import ...` statement in both files: 1. Ensure the hermes root directory is on `sys.path` so our local `utils.py` is first in the search path 2. Remove any existing `utils` entry from `sys.modules` that is not the hermes local module, allowing the next import to find our version This is a targeted fix for the two files in the cron module — neither is part of the hot path and both are only loaded on demand. ## Test Plan - [x] Verified both files load successfully when a mock PyPI `utils` is pre-loaded in `sys.modules` - [x] Verified both files load successfully in the normal case (no PyPI utils installed) - [x] Lint passes (pyright clean) Closes NousResearch/hermes-agent#82069",
        "url": "https://github.com/NousResearch/hermes-agent/pull/82520",
        "timestamp": "2026-08-13T11:22:39Z",
        "metrics": {
          "reactions": 0,
          "comments": 5
        },
        "labels": [
          "type/bug",
          "comp/cron",
          "backend/docker",
          "P2",
          "sweeper:risk-compatibility"
        ],
        "author": "blut-agent",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:82581",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(tools): isolate subprocess Python environments (#74817)",
        "text": "## Summary Remove Hermes-owned runtime contamination while preserving user-owned `PYTHONPATH` semantics. This consolidates and builds on #78917, incorporates the follow-up correctness work from Yiipu/hermes-agent#1, and rebases the complete attributed history onto main at `f4c2c263f0672a4b1485f3071cd5f79cd32d38ab`. ## Rebase onto main at f4c2c263f (#84500 reconciliation) Rebased onto main at `f4c2c263f`; conflicts were confined to the `PYTHONPATH` composition region in `tools/code_execution_tool.py`: upstream #84500 (`fix(tools): isolate external project environments`) had reworked the exact block this PR touches. The reconciliation preserves **both** fixes — inherited-PYTHONPATH sanitization is applied to the scrubbed child environment **before** the conditional composition, which keeps the staging `tmpdir` first, re-adds the Hermes repo root only for same-Hermes-environment children, and omits it for external project interpreters. #84500's interpreter-probe machinery is untouched, and its regression suites (`test_code_execution_modes.py`, `test_code_execution.py`, `test_code_execution_windows_env.py`) pass unmodified. The Windows-native test now uses the registered `@pytest.mark.windows_only` marker (current upstream OS-native test policy) so the Windows CI lane actually selects it. ## Reproduction Hermes launchers can prepend the Hermes repository root and the Hermes Python 3.11 venv's `site-packages` to `PYTHONPATH`. An unrelated Python 3.13 child then resolves NumPy from the 3.11 tree and fails to load its C extension: ```text ModuleNotFoundError: No module named 'numpy._core._multiarray_umath' ... _multiarray_umath.cpython-311-darwin.so ``` The same contamination class affects Pillow and other ABI-sensitive packages. It also reaches non-agent cron/script subprocesses through the shared environment builders. ## Root cause Hermes-owned paths needed to bootstrap the backend are inherited by unrelated child processes. Blanket removal of `PYTHONPATH` would fix the immediate ABI failure but would also destroy legitimate user configuration. Conversely, comparing path-shaped Python versions against the backend interpreter is not sound because the environment builder does not know which interpreter a future child will run. The ownership boundary is therefore the reliable contract: remove entries proven to be injected by the Hermes runtime and preserve every other component. ## Design - Strip only the exact Hermes repository root and exact Hermes runtime `site-packages` directories. - Preserve direct/deeper repo children and `site-packages` descendants because launchers do not inject them as standalone entries. - Preserve raw non-owned components byte-for-byte, including empty components, duplicates, whitespace, spelling, and ordering. - Do not use cross-version, path-shape, or `/nix/store` heuristics. - Sanitize before removing runtime marker variables so the validated Windows runtime relationship remains available to the ownership check. ## Changes - Apply the selective filter across terminal/background, shared subprocess, cron/script, and execute-code environment construction. - Remove inherited `PYTHONHOME` together with `VIRTUAL_ENV` and `CONDA_PREFIX` on normal sanitized paths. - Keep the explicit `build_subprocess_env(scrub_secrets=False)` no-scrub escape hatch unchanged. - Add behavioral regression coverage for exact ownership, raw `PYTHONPATH` semantics, HERMES_HOME aliases, Windows base-Python runtime ownership, and `PYTHONHOME`. ## Windows runtime coverage Some Windows gateway launchers run under a base/uv `python.exe` or `pythonw.exe` while `VIRTUAL_ENV` points to the separate Hermes runtime venv. In that mode, `sys.prefix` is not the Hermes runtime owner. The sanitizer accepts `VIRTUAL_ENV` only when it is the exact `<Hermes repo>/venv` location under a known physical or configured repo alias and contains a real `pyvenv.cfg`. That validated runtime contributes the exact `Lib/site-packages` entry. An unrelated inherited `VIRTUAL_ENV` is not trusted. This contract is covered with platform-independent helper tests plus a Windows-only native-semantics test. **Live Windows 11 validation was provided by @vollegrewar**: on a real Windows 11 desktop build (junction install, Hermes backend Python 3.11, external project venv Python 3.14), the original #74817 failure reproduces on main -- the Python 3.14 child loads the Hermes venv's cp311 NumPy and crashes with No module named 'numpy._core._multiarray_umath' -- and is fixed by this PR: OK numpy 2.5.2, with NumPy resolved from the project venv's own site-packages. His run also surfaced five test-portability failures in the sanitization suite (72 passed / 5 failed), addressed by the follow-up commit e00e9dc17 (see Tests). ## PYTHONHOME The default sanitized paths remove inherited `PYTHONHOME`, preventing it from redirecting an unrelated child interpreter (#75018). No active gateway producer was found that intentionally sets it. The explicit no-scrub escape hatch still preserves it byte-for-byte. ## Known residual limitations - Nix `extraPythonPackages` may append runtime plugin paths outside the repo/venv ownership boundary. A `/nix/store` path is not sufficient provenance because users can legitimately put Nix paths in `PYTHONPATH`; these entries are deliberately preserved. A future producer-side provenance contract should be discussed separately if needed. - `HostSupervisor` has an independent internal-child environment overwrite pattern. It does not block the user-facing #74817 subprocess path and is intentionally left for a separate issue/PR. ## Tests - `scripts/run_tests.sh tests/tools/test_local_env_blocklist.py -q` — 75 passed, 2 skipped (Windows-only) ### Windows follow-up (commit e00e9dc17) The five failures from the reporter's native run are test-code portability, not production bugs; the follow-up commit makes the tests express the true per-platform contract: - os.pathsep-based inputs/parsing in the four :-hardcoded tests. - The sane-PATH merge tests now assert the documented Windows passthrough (Git Bash prepend neutralised) and the POSIX Homebrew append respectively. - The macOS launchd regression (#35613) is marked macos_only per the repo OS-marker policy. - The HERMES_HOME alias test creates its directory link via a helper that falls back to an unprivileged junction (cmd /c mklink /J, locale-safe binary capture) when symlink creation raises WinError 1314, and skips with a clear reason if no mechanism exists. - Also corrected a stale execute_code comment claiming the sandbox runs the same Python as Hermes (project mode can select an external venv). Validation of the exact follow-up commit: - Native Windows 11 (isolated temp checkout at the PR head, Python 3.12): baseline tests/tools/test_local_env_blocklist.py reproduced 4 separator failures (73 passed / 4 failed; the symlink test passed on this host because Developer Mode/symlink privilege is available -- it is the reporter's WinError 1314 host that needs the junction fallback); after the follow-up: **76 passed, 1 skipped (macos_only), 0 failed**. - Real directory-junction experiments on the same host (same-drive C: and cross-drive D: junctions, junction-backed HERMES_HOME): _build_hermes_repo_root_aliases and _strip_hermes_owned_pythonpath verified end to end (see the junction section below). ### Junction lexical root across profile re-home (commits 7d72996e4, d66ead3aa) @vollegrewar's residual (the lexical repo-root spelling survives stripping) was traced to a **supported production path**, confirmed on native Windows 11 with a real junction and the real startup chain: - Launchers spawn the backend with HERMES_HOME in the configured (lexical) spelling (e.g. D:\\hermes) and PYTHONPATH entries in that same spelling (D:\\hermes\\hermes-agent). - With `--profile <name>` / `--profile default` / a sticky `active_profile`, `_apply_profile_override()` (hermes_cli/main.py, module level, before any Hermes imports) re-homes HERMES_HOME through `resolve_profile_env()`. That resolver resolved the junction under the platform default and returned the **physical** spelling, so by the time `tools.environments.local` is imported and `_hermes_repo_root_aliases` is built, the launcher's lexical spelling is unrecoverable in-process and the lexical repo-root entry survives stripping. - Reproduced matrix on native Windows 11 (real junction, real `hermes_cli.main` module-level override): no profile strips the entry; `--profile default`, `--profile <name>`, sticky `active_profile`, and a cross-drive D: junction all leave it in place (exactly the reported residual). Narrow fix, no heuristics, no new env vars, no profile-system refactor: - `hermes_cli/profiles.py::resolve_profile_env`: when HERMES_HOME is set, the configured spelling IS the launch root (junction-transparent -- physically identical directories); keep it instead of re-deriving the native default. Existing resolution invariants are covered by new unit tests (root env -> `<root>/profiles/<name>`; profile-shaped env -> `<root>/profiles/<name>` with no nesting; profile-shaped env + default -> `<root>`; custom roots never fall back to the platform default; missing named profiles still raise). - `tools/environments/local.py::_build_hermes_repo_root_aliases`: when the configured home is a profile home (`<root>/profiles/<name>`), also derive the root spelling lexically (parent of the \"profiles\" component, the same rule `get_default_hermes_root()` uses) and run the existing exact-ownership mapping against it -- the launcher's lexical root is recovered after re-home without ever matching arbitrary descendants of HERMES_HOME. Validation of the exact fix commit on native Windows 11: the 5-case matrix above now strips the lexical repo-root entry in **every** case (no profile / default / named / sticky / cross-drive D:), and `tests/tools/test_local_env_blocklist.py` passes 77 passed / 1 skipped (macos_only) including the new regression test `test_profile_rehome_keeps_junction_lexical_alias`. macOS targeted suites: 134 passed / 4 skipped (blocklist + profiles + apply-profile-override), related subprocess suites 121 passed / 3 skipped. - macOS: tests/tools/test_local_env_blocklist.py -- 75 passed, 2 skipped (Windows-only); test_code_execution_modes.py test_code_execution.py test_code_execution_windows_env.py test_build_subprocess_env.py test_hermes_subprocess_env.py -- 121 passed, 3 skipped. - .venv/bin/ruff check / py_compile / git diff --check on the changed files -- passed. - `scripts/run_tests.sh tests/tools/test_code_execution_modes.py tests/tools/test_code_execution.py tests/tools/test_code_execution_windows_env.py -q` — 99 passed, 3 skipped (Windows-only; includes the #84500 suite, unmodified) - `scripts/run_tests.sh tests/tools/test_build_subprocess_env.py tests/tools/test_hermes_subprocess_env.py tests/tools/test_code_execution_modes.py tests/tools/test_code_execution_windows_env.py tests/cron/test_cron_script.py tests/tools/test_tts_pythonpath_fallback.py -q` — 109 passed, 4 skipped - `scripts/run_tests.sh tests/hermes_cli/test_gateway_windows.py tests/hermes_cli/test_update_gateway_launcher_refresh.py tests/gateway/test_restart_drain.py -q` — 15 passed, 5 skipped - `.venv/bin/ruff check tools/environments/local.py tools/code_execution_tool.py tests/tools/test_local_env_blocklist.py` — passed - `.venv/bin/python -m py_compile tools/environments/local.py tools/code_execution_tool.py tests/tools/test_local_env_blocklist.py` — passed - `git diff --check` — passed - `scripts/ci/list_os_marked_tests.py windows_only` — lists `tests/tools/test_local_env_blocklist.py` (Windows lane imports it; the native test is selected by the marker) - New integration test `test_execute_code_composition_strips_inherited_hermes_entries` (2 parametrized cases) drives `execute_code` to Popen with a contaminated inherited `PYTHONPATH`, proving the #84500 + #82581 composition contract: staging first, inherited Hermes site-packages stripped, repo root re-added exactly once for a same-env child / absent for an external child, user entries preserved in order. - Full `scripts/run_tests.sh` was paired against pristine `main` at `2446c8bb6` with the same wrapper/Python. A follow-up rerun of the exact 32 failing files produced the identical 96 failing node IDs on both revisions (`1017 passed, 96 failed`); there were no branch-only failures. For this rebase onto `f4c2c263f`, the #84500 intersection suites were added to the regression set and pass unmodified. - macOS arm64 real E2E: the contaminated environment makes independent Python 3.13 load the Hermes Python 3.11 NumPy tree and fail; the sanitized environment preserves `/custom/lib/python3.13/site-packages`, and Python 3.13 then imports NumPy and Pillow from its own conda environment successfully. ## Lineage / attribution This branch preserves the original Git commit authors instead of squashing or recreating their work: - `mcjoys`: the initial selective stripping commit, carried through #78917 from the earlier #61028 lineage. - `Yiipu`: repo-root correction and boundary tests from #78917. - `Xinyu Du` / `Starfie1d1272`: independent macOS reproduction, adversarial review, removal of the cross-version heuristic, user `PYTHONPATH` preservation, `PYTHONHOME`, Windows/runtime ownership hardening, current-main integration, and regression/E2E validation. No synthetic `Co-authored-by` trailers are added because the Git history already preserves authorship. ## Relationship to existing work and issues This PR consolidates and builds on #78917 by @Yiipu and its #61028 lineage while addressing the correctness gaps found during real reproduction and adversarial review. It targets #74817 and the inherited-`PYTHONHOME` portion of #75018, with related context in #65909 and #57467.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/82581",
        "createdAt": "2026-08-09T16:03:32Z",
        "updatedAt": "2026-08-13T18:02:10Z",
        "timestamp": "2026-08-13T18:02:10Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "comp/tools",
          "tool/code-exec",
          "backend/local",
          "P2",
          "sweeper:risk-compatibility"
        ],
        "author": "Starfie1d1272",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:83051",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(desktop): add tab close controls with focus recovery",
        "text": "## Summary - Add accessible direct close controls to closeable desktop pane and session tabs while preserving middle-click, modifier-click, and context-menu behavior. - Route pointer, keyboard, menu, global, terminal, confirmation, visibility, and bulk-close paths through a shared focus-recovery lifecycle. - Keep close targeting aligned with the tab the renderer exposes as selected, including hidden raw successors and layout-edit mode. - Preserve focused xterm identity independently from the selected terminal rail tab, and keep focus in the originating split group or terminal rail across pane removal and delayed initialization. - Add vertical-tab ARIA relationships, roving terminal-rail focus, and broad unit plus real Electron interaction coverage. ## Motivation Tab closing has several asynchronous entry points. Without a shared completion lifecycle, focus can remain on disappearing controls, jump to the wrong split group, target an invisible raw active pane, or be stolen by a delayed terminal mount. This change makes direct close controls durable and keyboard-accessible across those paths. ## Changes - Add horizontal pane/session close controls with separate tab and close-button semantics. - Serialize aggregate session closes around busy-session confirmation and settle focus only after the close result is known. - Preserve source-group identity and choose an actually visible, focusable survivor before application-level fallback. - Share the renderer's shown/selected-pane projection with global close routing so normal, narrow, and layout-edit modes cannot disagree. - Wrap the complete visual session tab in its context-menu trigger so right-clicking the sibling close glyph opens the session menu rather than a dead zone or the strip menu. - Stamp persistent terminal panels with terminal identity, synchronize selection on focus entry, and route global close through the focused xterm panel before falling back to the selected rail tab. - Suppress automatic xterm focus while the rail or pending tree recovery owns focus, without changing intentional drag/drop focus. - Implement terminal rail tablist/tab/tabpanel linkage, selected state, and roving keyboard navigation. ## Test Plan - [x] `npm run check` on clean committed HEAD `bb6de431a5525e724840a23b89d5ee43548eb2df` — typecheck and lint (0 errors, 89 existing warnings), 3,756 UI tests, 1,035 Electron-platform tests with 2 skipped, production build, and macOS DMG packaging stamped to `bb6de431a552` - [x] Focused integration set — 62/62 close, confirmation, tree-selection, terminal-rail, and workspace tests - [x] `npx playwright test e2e/pane-tab-close.spec.ts e2e/right-pane.spec.ts --reporter=list --timeout=180000 --workers=1` against that freshly built clean package — 6/6 real macOS Electron tests, including close-glyph context menu and focused real-xterm global close - [x] `git diff --check origin/main...HEAD` clean; added-line credential scan found no matches - [x] Synced with current upstream `main`; conflict resolution preserves `ContribRender`, split-share memory, deferred close results, and tab-panel semantics - [ ] Two fresh strict read-only auditors are reviewing exact commit `bb6de431a5525e724840a23b89d5ee43548eb2df` after remediation of all three prior findings ## Review History Two strict reviewers of the previous snapshot independently identified three reachable interaction defects: 1. global close could disagree with the selected tab in layout-edit mode; 2. right-clicking a session tab's close glyph did not open its session context menu; 3. a focused xterm could lose close affinity if terminal selection drifted. Each issue now has a red-before/green-after regression. The exact remediation snapshot is under fresh hash-bound semantic and interaction/accessibility/security review. ## Notes for Reviewers - Supersedes #69392, whose branch conflicts with current `main`. - Retains Austin Pickett attribution for the original hover-close feature while adding current-main accessibility, lifecycle, focus-recovery, terminal, and regression work. - The close control renders only for closeable horizontal tabs; minimized vertical rails use explicit focus restoration instead.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/83051",
        "createdAt": "2026-08-10T08:16:11Z",
        "updatedAt": "2026-08-13T16:14:16Z",
        "timestamp": "2026-08-13T16:14:16Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "P3",
          "comp/desktop"
        ],
        "author": "nicolasdmolina",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:83272",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(compaction): trust explicit Responses proxy origins",
        "text": "## Problem Hermes supports native OpenAI Responses compaction only for hard-coded first-party routes. A user can explicitly configure a compatible Responses proxy, but Hermes still disables native compaction solely because the origin is not `api.openai.com`. ## Approach Add an empty-by-default `compression.codex_responses_native_trusted_base_urls` allowlist. - Match normalized HTTP(S) origins only: scheme, lowercase hostname, and effective port. - Ignore paths, queries, and fragments. - Reject URLs containing credentials. - Keep xAI and GitHub/Copilot routes hard-excluded even if configured. - Accept either a list or one string value for ergonomic config. - Preserve the current behavior when the setting is absent. This is an explicit trust decision. Hermes does not infer compatibility or widen the default trust boundary. ## Verification - `uv run --with pytest --with pytest-xdist --with pyyaml python -m pytest tests/run_agent/test_native_compaction.py -q -o 'addopts='` - 40 passed ## Risk Low and opt-in. Existing configurations behave exactly as before. A configured origin gains access only to the existing native compaction path.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/83272",
        "timestamp": "2026-08-13T11:44:03Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "provider/openai",
          "area/config",
          "P3",
          "needs-decision",
          "sweeper:risk-session-state",
          "sweeper:risk-compatibility",
          "area/compression"
        ],
        "author": "aneym",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:83314",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(skills): add configurable skill index injection",
        "text": "## Summary - add `skills.inject_index` (default `true`) to control `<available_skills>` injection - add `--no-skills-index` for classic CLI, TUI, and `-z` one-shot runs - keep skill tools available when the prompt index is omitted ## Why Large skill catalogues can consume substantial prompt space even when a caller provides another discovery surface. This follows the focused config direction suggested in #26815 and supplies the per-invocation mechanism requested by #26806 without adding an environment-backed user setting or a general prompt mutation hook. The value is resolved once when the agent is constructed, so the system prompt remains stable for the conversation lifetime. Closes #26806. ## Validation - `14 passed` across the focused system-prompt, config, parser, classic CLI, one-shot, and TUI propagation tests - Ruff checks passed for all changed Python files - `git diff --check` passed",
        "url": "https://github.com/NousResearch/hermes-agent/pull/83314",
        "createdAt": "2026-08-10T16:07:08Z",
        "updatedAt": "2026-08-13T13:39:36Z",
        "timestamp": "2026-08-13T13:39:36Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/cli",
          "comp/tui",
          "tool/skills",
          "area/config",
          "P3",
          "sweeper:risk-compatibility"
        ],
        "author": "Vocllum",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:83316",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(skills): emit lifecycle events for removals",
        "text": "## Summary - emit `deleted` after a successful hard delete through `skill_manage` - emit `uninstalled` after a successful Skills Hub uninstall - preserve provenance before removal and keep curator archives on their existing `archived` action - document the complete observer scope and its filesystem boundary ## Why The merged `on_skill_lifecycle` observer from #68883 reports skill creation, mutation, loading, and curator transitions, but successful removal paths were silent. Plugins that maintain an external index therefore cannot invalidate a removed skill when Hermes performs the operation. This extends the existing observer rather than adding another hook. Emission happens only after the authoritative filesystem operation succeeds. Usage-sidecar cleanup remains best-effort because its failure cannot undo a deletion that already occurred. ## Scope - no filesystem watcher or scan loop - no promise to detect edits made by Git, editors, sync clients, or other processes - no per-command plugin-discovery special case; cross-entrypoint hook delivery remains the centralized concern tracked in #64178 - no new pre-operation or mutating hook contract ## Validation - `32 passed` across skill usage, hard-delete, archive, and Hub uninstall tests - Ruff checks passed for all changed Python files - `git diff --check` passed",
        "url": "https://github.com/NousResearch/hermes-agent/pull/83316",
        "createdAt": "2026-08-10T16:08:19Z",
        "updatedAt": "2026-08-13T13:39:32Z",
        "timestamp": "2026-08-13T13:39:32Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "tool/skills",
          "P3"
        ],
        "author": "Vocllum",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:83348",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(kanban): expose privacy-safe lifecycle activity",
        "text": "## Summary Add a bounded read-only kanban activity projection for operator dashboards. The new command is: hermes kanban activity --json --limit 80 It returns only pseudonymous event/work references, lifecycle kind, timestamp, and event-time profile when available. It never returns raw task/event/run IDs, task title/body/result/comments, payloads, claim locks, worker PIDs, paths, sessions, or error text. ## Contract notes - allowlisted lifecycle kinds only - limit is bounded to 1..200 - created and assigned profiles come from event-time payload projection - run-scoped events use the historical task_run profile - assigned exposes previous_profile only when a different prior projected profile exists - output is read-only and does not change kanban state ## Tests - uv run --extra dev ruff check on the four changed files - uv run --extra dev pytest tests/hermes_cli/test_kanban_core_functionality.py tests/hermes_cli/test_kanban_cli.py - 28 passed, 1 skipped A separate consumer integration is tracked in moonweave/hermes-agent-team-dashboard#6.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/83348",
        "timestamp": "2026-08-13T12:17:08Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/feature",
          "comp/cron",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-compatibility"
        ],
        "author": "moonweave",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:83367",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cli): don't serve stale update-check results after fetch failure",
        "text": "## What does this PR do? When `_check_via_local_git`'s `git fetch origin main` fails (timeout, offline, DNS resolution failure), the code silently fell through to compare `HEAD` against the stale `origin/main` tracking ref. That stale ref hadn't been updated, so `git rev-list --count HEAD..origin/main` could report `0` (up to date) even when upstream had moved forward by several commits. Combined with the 6-hour cache in `check_for_updates`, a single fetch failure could suppress update notifications for days. This is the exact symptom in #82166: the daily cron job hit the `/api/hermes/update/check` endpoint, which called `check_for_updates()`, which cached the stale `0` for 6 hours. After the cache expired, the next check attempted another fetch — if that also failed (intermittent network issue, slow DNS), another stale `0` was cached. This cycle repeated for 4 days while v0.20.0 was already available on GitHub. ## Related Issue Fixes #82166 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_cli/banner.py` — `_check_via_local_git()`: detect fetch failure (returncode != 0 or exception) and return `None` instead of falling through to stale refs. The caller treats `None` as \"check could not run\" rather than \"up to date\". - `hermes_cli/banner.py` — `check_for_updates()`: no longer cache `None` results. Previously, a `None` from a failed check was cached for 6 hours, suppressing retries until the cache expired. Now only conclusive results (`0` or `>=1`) are cached, so the next check attempt runs immediately on the next call. ## How to Test 1. `pytest tests/hermes_cli/test_update_check.py -v` — all 4 tests pass 2. `test_check_via_local_git_fetch_failure_returns_none` — verifies that a failed `git fetch` (returncode=1) returns `None` instead of a stale behind-count 3. `test_check_for_updates_does_not_cache_none` — verifies that `None` results are not written to the `.update_check` cache file 4. Manual: simulate a fetch failure by disconnecting network, run `hermes update --check`, confirm it reports \"could not determine\" instead of \"up to date\" ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15.5, Python 3.13.5 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/83367",
        "createdAt": "2026-08-10T17:46:42Z",
        "updatedAt": "2026-08-13T15:23:27Z",
        "timestamp": "2026-08-13T15:23:27Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [],
        "author": "rkfshakti",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:83437",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(langfuse): widen tracing to errors, sessions, subagents, and MoA fan-out",
        "text": "Widens the bundled Langfuse plugin from 6 hooks to 11 and fixes two attribution bugs. Also adopts the shutdown/atexit lifecycle fixes from #81054 and #82332 (original authorship preserved) with two interaction-fix follow-up commits. ## Model attribution `on_pre_llm_request` and `on_post_llm_call` read `model`, the agent's attribute at hook time. It goes stale after a mid-session `/model` switch or a provider fallback, so generations file under the wrong model and cost estimation keys off it. Both now prefer the wire value — `request[\"body\"][\"model\"]` and `response_model`. `agent/conversation_loop.py` already passed both and the plugin ignored them. ## Cost total Langfuse does not derive `calculatedTotalCost` from the per-type `cost_details` keys. `_usage_and_cost` wrote the breakdown and no total, so a priced generation read 0 in the dashboard while its components were correct. Both cost paths now send a summed `total`. A priced model that billed no tokens writes no breakdown and still sends no total. ## New coverage **Errors and session lifecycle.** `api_request_error` closes failed generations with `level=ERROR` and retry metadata. `on_session_finalize` and `on_session_end` close still-open traces so tool-only and interrupted turns stop dangling. Adds `HERMES_LANGFUSE_CAPTURE=metadata|sanitized|full`, default `sanitized`. **Subagents.** `tools/delegate_tool.py` emits `subagent_start`/`subagent_stop` and the sibling `nemo_relay` plugin already consumes them, so every delegated child was missing here. The payloads carry `parent_turn_id` but no `task_id`, and `_scope_prefix` prefers `task_id` when the LLM hooks minted the key — so `_state_for_turn` matches on the turn-id suffix instead of rebuilding a key that would miss. **MoA advisors.** A MoA turn runs N advisor models before its aggregator and returns only the aggregator's response, so the fan-out showed up as one generation priced at the aggregator's model. `_RefAccounting` already computes each advisor's usage and dollars, precisely because advisors run on a different provider and cannot be priced at the aggregator's rate. `moa_trace.slot_metrics` renders that and `post_api_request` carries it as `moa_references`. ## MoA plumbing The read is deliberately non-consuming. `post_api_request` fires on a different branch of `run_conversation` than `consume_reference_usage` and `consume_and_save_trace`, so a consuming read would race them. The client holds its last fan-out until the next one, so the plugin fingerprints each fan-out and does not re-emit the same advisors on every API call of a tool-loop turn. The payload derives from the already privacy-redacted `_trace_refs`, so an active privacy mode redacts it too. `slot_metrics` drops `input_messages` rather than crossing the hook boundary with a full transcript per advisor per turn. ## Verification 184 tests pass across the langfuse, MoA, subagent-hook, and relay-metrics suites. `moa_references` cannot reach the nemo relay's metrics payload: `model_call_fields` allowlists `model` and `provider`. The 11 whole-tree pytest collection errors are identical on `main`. ## Adopted lifecycle fixes **#81054 (bgodlin, 2 commits).** Quitting Hermes with the plugin enabled printed an \"Exception ignored in: <generator>\" TypeError traceback. The SDK's own atexit shutdown runs during interpreter finalization, after `opentelemetry.trace.Span` is torn down to `None`, so its span-cleanup `isinstance(span, Span)` check blows up. The fix shuts the client down at session finalize while the interpreter is alive, and exits the root observation's context manager instead of leaving its generator suspended for GC to unwind at teardown. **#82332 (aldoeliacim, 1 commit).** Short-lived processes (kanban workers, `hermes chat -q`, cron jobs) could exit with tool calls still queued, so the root span never ended and the backend showed an anonymous trace with no name, session, or metadata. An atexit finalizer — registered after the SDK client so LIFO ordering runs it before the SDK's own flush — ends every open root span at exit. **Follow-ups (interaction fixes found while composing).** The adopted shutdown fired on every `on_session_finalize`, but that hook also fires on `/new`, `/reset`, and gateway session expiry, where the process lives on — the first rotation would have killed the cached client and silently stopped exports for every later session. The shutdown is now gated on `reason == \"shutdown\"`. The adopted atexit finalizer also skipped subagent observations (added in this PR) and never exited the root context manager, reintroducing the same teardown TypeError it sat next to; both closed. ## Verification 74 langfuse + MoA-bridge tests pass; full `tests/agent/` + `tests/hermes_cli/` sweep is 8018 passed with 8 failures identical on the unmodified base (provider-routing/env, not langfuse). Live smoke against Langfuse Cloud: three sessions (clean turn, rotation-closed dangling turn, shutdown-closed dangling turn) all exported with correct names and session ids; the client survived rotation and shut down cleanly at exit. ## Adopted fixes (tier 2) Adopted with follow-up integration on top; each commit thanks and co-author-credits the original contributor. **#42326 (@nftpoetrist).** `_get_langfuse()` double-checked a global with no lock, so two concurrent first callers could both construct a client and leak the loser's HTTP connection and flush thread. First build is now serialized; the settled fast path stays lock-free. **#39653 (@rodboev).** Reasoning models that expose their scratchpad as `reasoning_content` or structured `reasoning_details` traced as `reasoning: None`. An accessor now checks the three fields in precedence order. Closes #29482. **#64292 (@FnExpress).** Providers that move the system prompt out of `messages` (Anthropic `system`, Codex `instructions`, Bedrock Converse blocks) produced generation inputs with no system prompt at all — no skills, memory, or instructions visible in traces. The loop now forwards the prompt as actually sent and the plugin prepends a `role: system` entry. Extends and supersedes #32175 (@db-aeon), whose Anthropic-only fix is credited in the commit. **#61166 (@Per0-1).** The plugin called `set_trace_io()`, which does not exist in SDK v3 — the AttributeError skipped `root_span.end()`, so traces listed with blank Input/Output columns and no CHAIN root. Trace I/O now uses v3 `update_trace()`, individually fail-open so no export step can block the root end. **#64797 (@NaMinhyeok).** Replaces this PR's earlier sum-of-components total with the canonical Hermes estimate: summing the breakdown undercounts when a component can't be priced or request-level pricing applies. Both cost paths now share one helper; a partial breakdown with no valid estimate exports no total. Together with the original cost-total commit this closes #72661. **#43130 (@liuhao1024).** Subscription-included routes (openai-codex) sent explicit `$0` cost_details, which Langfuse treats as authoritative — blocking its own model-based estimation, so every generation showed `$0` forever. Included routes now send no cost keys at all. Closes #43129. Integration fixes made while composing: system-prompt and reasoning serialization route through `_capture_content` so capture modes and secret redaction govern them; the canonical total adds a zero-guard so a priced model that billed nothing doesn't export an authoritative `0.0`. ## Closes / supersedes Closes #29482, closes #43129, closes #72661. Supersedes (can be closed in favor of this PR): #81054, #82332, #42326, #39653, #64292, #32175, #61166, #64797, #43130. Partially addresses #67544: capture modes (`metadata`/`sanitized`/`full`) plus secret redaction at the export boundary cover the masking half; `user_id` attribution remains open. ## Verification (tier 2) 88 plugin tests pass; full `tests/plugins/` + `tests/agent/` + `tests/hermes_cli/` sweep is 9380 passed with every failure reproduced on the unmodified base or current `main` (env-dependent provider tests, hindsight extra not installed locally, and a `_preset_cache` conftest collection error that exists on `main`). Live smoke against Langfuse Cloud confirmed server-side: system prompt visible in generation input, `reasoning_content` in output, `costDetails.total` matching `calculatedTotalCost`, trace-level Input/Output columns filled.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/83437",
        "createdAt": "2026-08-10T20:39:32Z",
        "updatedAt": "2026-08-13T17:40:27Z",
        "timestamp": "2026-08-13T17:40:27Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/plugins",
          "P3",
          "telemetry"
        ],
        "author": "erosika",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:83661",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(plugins): add safe exact-session message delivery seam",
        "text": "## Summary Adds a small, generic host-owned delivery seam so standalone plugins can route conversational input to the correct live Hermes session without accessing private host queues or interrupting active tools. This is the Hermes core prerequisite for the standalone [Hermes Walkie Talkie](https://github.com/Sahil-SS9/hermes-walkie-talkie) plugin and for local cross-session messaging requested in #81885. The transport and product-specific peer logic remain outside this repository. ```python ctx.inject_message( content, role=\"user\", *, mode=\"queue\", target_session=None, ) -> bool ``` ## What changes - Extends `PluginContext.inject_message()` additively with `mode` and `target_session`. - Adds host-owned routing for CLI, TUI/Desktop and gateway sessions. - Makes `queue` the safe default: idle sessions start a turn; busy sessions receive the message at the next safe boundary. - Preserves explicit `steer` and legacy `interrupt` modes where supported. - Adds the `on_session_open` lifecycle event for live, addressable sessions before their first model turn. - Adds exact session context to opt-in plugin slash-command handlers while preserving legacy one-argument handlers. - Makes host-open notification idempotent and releases its identity when the session closes. - Documents the public contracts and surface behaviour. No new core model tool is added. The external peer transport remains a standalone plugin. ## Safety properties - Injected text is conversational input only. - It cannot invoke slash commands, execute bang-shell input, attach local files, select resume entries, approve tools, or answer protected confirmation prompts. - `queue` never interrupts an active tool. - Unknown, closed, rotated or unauthorised exact-session targets fail closed. - Gateway injection is disabled per plugin unless `plugins.entries.<id>.allow_gateway_injection: true` is configured. - Gateway delivery reuses the existing authorised route; it does not fabricate a platform route. ## Compatibility - Existing two-argument `ctx.inject_message(content, role)` callers remain valid. - Legacy one-argument plugin command handlers remain valid. - CLI, TUI/Desktop and gateway hosts expose the same generic seam while retaining their existing scheduling behaviour. - This reconciles the host-contract direction of #64436, #70406 and #80920 without adding the standalone Walkie Talkie implementation to Hermes core. ## Verification Verified from a clean worktree based directly on `NousResearch/main` at `2cdb30a474d76cca9eb61714d889c18f493aa7fc`: - Focused core injection, lifecycle, gateway and TUI regression matrix: **605 passed**. - Standalone real-process E2E against this clean core transplant: **16 passed**. - Cross-surface CLI/TUI/gateway import and routing matrix under the Hermes environment: **4 passed**, no skips. - `py_compile` on all changed runtime modules: passed. - `ruff check` on all changed Python files: passed. - `git diff --check`: passed. - Six commits ahead of upstream; all commits attributed to `Sahil-SS9 <218421507+Sahil-SS9@users.noreply.github.com>`. ## Scope boundary This draft contains only the generic V1 host seam, tests and documentation. Groups, broadcasts, structured request/reply workflows, native Windows transport, operational metrics and the full Desktop collaboration UI are planned as a separately tested V1.1 enhancement and are not part of this PR.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/83661",
        "timestamp": "2026-08-13T12:11:54Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "comp/gateway",
          "comp/tui",
          "comp/plugins",
          "P3",
          "needs-decision",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery"
        ],
        "author": "Sahil-SS9",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:83720",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): never reap supervised gateway + relaunch on desktop (re)start (#83683)",
        "text": "## Summary Fixes #83683 — on a desktop (re)start the backend force-killed the live messaging gateway but never relaunched it, leaving WeChat/QQ/Telegram silently offline. This is a regression: the old gateway survived restarts (reparented), but the current gateway is a separate `hermes gateway run` process that the orphan-reaper happily killed, with no successor. ### Root cause `_reap_unsupervised_gateway_orphans()` scans for any process matching `looks_like_gateway_command_line` (`gateway run`) and kills it — including a **supervised** gateway that is owned by an external supervisor (systemd / launchd / Windows `Hermes_Gateway` scheduled task). Because the reap path has no relaunch successor, the gateway stayed dead and messaging went silent. ### Fix (two parts, fully guarded) 1. **Never reap a supervised gateway.** New `_gateway_has_active_supervisor()` detects a live external supervisor (systemd unit running / macOS launchd plist loaded+running / Windows scheduled task in `Running` state or with a live gateway pid). `_reap_unsupervised_gateway_orphans()` now bails out early when one is present, and additionally excludes the explicit `gateway.pid` supervised PID from the orphan set so the legitimately-running instance always survives. 2. **Relaunch a missing supervised gateway on desktop boot.** `hermes_cli/web_server.py` gains `_ensure_desktop_gateway_running()`, a detached, best-effort routine wired into the `_lifespan` startup (only when `HERMES_DESKTOP=1`). If a supervisor is installed (user opted into \"start gateway on login/boot\") but the gateway isn't running, it clears any stale `.gateway-planned-stop.json` marker and relaunches via the platform-native path (`systemd_start` / `launchd_start` / `gateway_windows.start()`). It is fully wrapped in try/except and never raises into the lifespan, so backend boot can never wedge on gateway recovery. ### Opt-out - Config key `gateway.relaunch_gateway_on_desktop_start` (default `True`). - Env var `HERMES_DESKTOP_NO_GATEWAY_RELAUNCH=1` forces it off. Both are documented in `config_defaults.py`. ## Confirmed reproductions across platforms (issue #83683) Since the original report, the regression has been independently reproduced on **every** supervised platform, each via a distinct, reliable trigger — all sharing the same root cause (a supervised gateway reaped by the orphan-reaper with no relaunch): - **Windows — desktop restart / update hand-off** (zuowen7, tutan0558, original reporter): reopening the desktop app (or `hermes update` + reopen) reaps the gateway. On Windows the gateway can exit **either** via SIGKILL **or** via the clean planned-stop-marker path (`Received UNKNOWN as a planned gateway stop — exiting cleanly`) — so a fix that only blocks SIGKILL would still disconnect messaging. Nothing relaunches it. - **macOS (launchd) — infinite kill loop** (Catmittee): launchd `KeepAlive` respawns the gateway → `hermes serve` reaps it (SIGTERM) → launchd respawns after `ThrottleInterval` → repeat, firing a shutdown notification each cycle (~26k serve respawns observed). - **macOS (launchd) via Desktop SSH reconnect** (woriwka-ai): each *Connect via SSH* reconnect starts a remote `hermes serve --isolated` with `HERMES_DESKTOP=1`, which reaps the remote launchd-managed gateway (reap wired into `_lifespan` by `bc1223840`). - **macOS (launchd) via multi-profile backend-pool churn** (TheVisher): the Desktop app stays **open**; LRU backend-pool rotation (cap 3) repeatedly starts fresh `HERMES_DESKTOP=1` serve backends, each reaping that profile's launchd gateway. No restart/relaunch needed. **Why this PR covers all of them:** the supervisor guard (`_gateway_has_active_supervisor()`) recognizes systemd / macOS launchd / Windows `Hermes_Gateway` and short-circuits the **entire** reap *before* any signal or `.gateway-planned-stop.json` write — so both the SIGKILL and the clean-marker exit paths are prevented, on every platform. `_ensure_desktop_gateway_running()` then relaunches a genuinely-missing supervised gateway on boot, covering the \"nothing relaunches\" half. ## Test plan - `tests/hermes_cli/test_gateway_reap_supervision.py` (10): supervisor-active skips reap on every platform (incl. a new repeated-pool-churn regression for a launchd-managed gateway); supervised PID excluded while genuine orphans still reaped; no-supervisor behavior unchanged. - `tests/hermes_cli/test_desktop_gateway_recovery.py` (8): no-op when already running; relaunch per platform; no relaunch when no supervisor; env var / config opt-out; never raises. - `tests/hermes_cli/test_gateway_windows.py` (11): VBS/CMD proxy baking + direct-spawn reload (incl. live-proxy-wins-over-snapshot) and Windows supervisor detection. - Regressions green: `tests/gateway/test_replace_child_reap.py` (5). - Full affected suite: **33 passed, 3 skipped**. ## Risk / rollout notes - Default-on but harmless: on hosts with no supervisor it's a no-op; on supervised hosts it only relaunches when the gateway is genuinely down (avoids double-start because that path checks `is_task_registered()`/plist existence, and the gateway's own supervisor would already be running it). - All recovery is best-effort; any failure is logged and swallowed.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/83720",
        "createdAt": "2026-08-11T06:30:00Z",
        "updatedAt": "2026-08-13T16:12:38Z",
        "timestamp": "2026-08-13T16:12:38Z",
        "metrics": {
          "reactions": 0,
          "comments": 10
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/gateway",
          "area/config",
          "P1",
          "sweeper:risk-message-delivery",
          "sweeper:risk-compatibility",
          "sweeper:risk-platform-windows",
          "platform/windows"
        ],
        "author": "EvanProgramming",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:83878",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(telegram): hold inbound messages across disconnect instead of destroying them",
        "text": "## Summary Current main's disconnect drop-guard (#55971) correctly refuses to dispatch buffered Telegram updates into a torn-down session (`_should_drop_delayed_delivery` / `_drop_delayed_deliveries`). The implementation still **destroys** the inbound event: debug-level `return` after `pop()` (or before enqueue) at the text/photo/media-group batch sites, and `_cancel_pending_delivery_tasks` clears pending maps with no salvage. By then python-telegram-bot has already accepted the update and advanced the polling offset, so Telegram will never redeliver. Result: silent permanent loss — no WARNING, no error, no retry. Operator signature: messages visible in Telegram (later quoted via `reply_to_id`) never appear as gateway inbound or in `state.db` for that turn — consistent with drop-on-enqueue while the drop-guard is true during polling recovery. ## Problem | Site | Before | |---|---| | `_enqueue_text_event` | drop → debug return | | `_flush_text_batch` | `pop` → drop → return (event gone); cancel after pop also lost | | `_enqueue_photo_event` | drop → debug return | | `_flush_photo_batch` | `pop` → drop → return; cancel after pop lost | | `_queue_media_group_event` | drop → debug | | `_flush_media_group_event` | `pop` → drop → return; cancel after pop lost | | `_cancel_pending_delivery_tasks` | cancel flushes, clear maps, **no salvage** | Invariant preserved: still never call `handle_message` into a torn-down session. ## Fix **Hold, don't destroy. Lifecycle-track redispatch. Drain on reconnect.** 1. `_hold_inbound_event(event, where=...)` — WARNING, `HELD_INBOUND_MAX=64`, identity dedup 2. All six drop sites hold instead of silent return 3. text/photo/media-group flush: `CancelledError` after `pop` holds 4. `_cancel_pending_delivery_tasks`: - salvage text/photo/media-group maps into the hold queue before clear - **cancel+await** `_held_inbound_redispatch_task` (same lifecycle rule as #72037 review: no untracked dispatch after teardown starts) 5. `_mark_connected` schedules one tracked `_redispatch_held_inbound(prior=...)` 6. Mid-drain disconnect/cancel re-holds the remainder 7. Non-retryable fatal discards the hold queue with WARNING (no silent death on permanent auth failure) ## Interaction with OOF-156 (connect-failure classification) Rebased onto current main (zero textual conflicts), which adds `retryable=False` fatals for `InvalidToken`/`Forbidden` at connect time (`telegram_auth_error`). The two mechanisms compose by design: | Fatal kind | Hold queue | Why | |---|---|---| | retryable (`telegram_connect_error`, network) | **preserved** | reconnect is precisely the drain trigger | | non-retryable (`telegram_auth_error`, revoked token) | discarded + WARNING, producers fenced | no reconnect will ever drain; holding would orphan | Covered by regression tests on both paths. ## Scope boundary | PR | Failure mode | Relation | |---|---|---| | **#72037** (open) | Follow-up chunk cancels in-flight flush after `pop` during normal supersession | Orthogonal — shield; this PR does not add shield | | **#81528** (closed, superseded by **#81371**, open) | Discard buffers at conversation boundaries (fragment leak) | Opposite direction — hold queue is **reconnect-scoped**, not a substitute for boundary discard | | **#55971** (merged) | Drop-guard introduction | Contract kept; destructive impl fixed | | **This PR** | Drop-guard / teardown / cancel-after-pop **destroy** inbound | Hold + tracked redispatch | Held queue is not cleared by conversation boundaries; that remains #81371's job if fragments must die on `/new`/`/stop`. ## Test plan 16 tests in `TestHoldInboundAcrossReconnect` — **no wall-clock races** (delay=0 and/or entered/release `asyncio.Event`): - [x] late enqueue held + redispatched on reconnect - [x] flush post-pop drop holds (delay=0) - [x] flush cancel-after-pop holds (Event sync) - [x] teardown salvages pending maps - [x] redispatch task cancel+await on teardown (lifecycle) - [x] photo + media-group enqueue held - [x] identity dedup - [x] queue cap drops oldest - [x] redispatch aborts cleanly if disconnect returns mid-drain - [x] non-retryable fatal discards held (OOF-156 auth path) - [x] **retryable fatal preserves held for reconnect drain (OOF-156 network path)** - [x] production terminal step (`_enqueue_text_event` as `_handle_text_message` ends) holds when disconnected - [x] permanent fatal teardown discards pending, never re-holds - [x] permanent fatal late enqueue discards - [x] connected hold schedules redispatch (no orphan) - [x] redispatch failure re-holds current + remainder ```bash scripts/run_tests.sh tests/gateway/test_telegram_text_batching.py -q # 21 passed scripts/run_tests.sh tests/gateway/ -q # 5446 passed, 0 failed, 29 skipped ``` Full suite verified against a pristine-`main` baseline in the same environment: zero delta introduced by this branch (remaining failures are identical pre-existing environment failures — daytona/fal/hindsight/acp extras not installed — present on both sides).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/83878",
        "createdAt": "2026-08-11T12:20:05Z",
        "updatedAt": "2026-08-13T17:51:25Z",
        "timestamp": "2026-08-13T17:51:25Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "platform/telegram",
          "P1",
          "sweeper:risk-message-delivery"
        ],
        "author": "dvbaecker",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84021",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): drop stale inflight-journal folds that duplicate answers",
        "text": "## What does this PR do? A stale inflight-journal fold can re-emit answers already present in the transcript, duplicating messages. Skip folds whose journal state is stale relative to the transcript. ## Related Issue Fixes #85308 ## Type of Change - [x] 🐛 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 - Skip stale inflight-journal folds (`apps/desktop/src/lib/inflight-turn-journal.ts`) - Tests: `apps/desktop/src/lib/inflight-turn-journal.test.ts` - `contributors/emails/nformenton@Nicolass-MacBook-Air.local` — attribution mapping ## How to Test 1. With the journal fold feature active, trigger a fold whose journal state is older than the transcript. 2. No duplicate answer is appended. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `npm run test:ui -- src/lib/inflight-turn-journal.test.ts`: **22 passed**.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84021",
        "createdAt": "2026-08-11T17:06:46Z",
        "updatedAt": "2026-08-13T13:12:53Z",
        "timestamp": "2026-08-13T13:12:53Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "P3",
          "sweeper:risk-session-state",
          "comp/desktop"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84137",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): title ladder descends on empty completions too",
        "text": "## What does this PR do? The title ladder descends on schema/format failures but returned unconditionally on empty 200 completions, leaving sessions untitled. It now descends to the next rung on empty completions too. ## Related Issue Fixes #85307 Related: #83186, #83725, #82890 — this PR is a superset of the ladder family (triage comment confirms); if one of those merges first, this diff shrinks to the delta and rebases cleanly. ## Type of Change - [x] 🐛 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 - Title ladder descends on empty completions (`agent/title_generator.py`) - Tests: `tests/agent/test_title_generator.py` - `contributors/emails/nformenton@Nicolass-MacBook-Air.local` — attribution mapping ## How to Test 1. Force a title completion that returns a 200 with an empty title. 2. The ladder proceeds to the next rung instead of returning; the session ends with a title. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `pytest tests/agent/test_title_generator.py -q`: **37 passed**.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84137",
        "createdAt": "2026-08-11T22:25:22Z",
        "updatedAt": "2026-08-13T13:13:00Z",
        "timestamp": "2026-08-13T13:13:00Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/deepseek",
          "provider/kimi",
          "P3"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84142",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): preserve message continuity across restarts",
        "text": "## What does this PR do? Makes messaging continuity durable across gateway restarts so a reply to an already-visible message resumes the exact session and transcript instead of opening an empty context, replaying a blank synthetic turn, losing an accepted inbound ask, or producing a duplicate response. The implementation adds a durable continuity journal/binding layer and wires it into inbound dispatch, outbound delivery, delivery-ledger recovery, and continuable cron delivery. It fails closed when a durable claim or binding cannot be established and scopes resolution by platform, chat, thread, profile, and owner. This is broader than the existing restart-recovery PRs #68902, #72305, and #30030: those address final-delivery timing or synthetic resume behavior, while this change also provides durable inbound idempotency, delivered-message reply binding, new-topic/background transcript seeding, recovered-ACK binding, and profile/owner isolation. ## Related Issue Related to #46088 and the restart-continuity work in #68902, #72305, and #30030. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] 🔒 Security fix - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - Add `gateway/message_continuity.py` with additive SQLite state for inbound event claims, outbound reply bindings, continuable seeds, diagnostics, retention, and owner-generation guards. - Journal accepted inbound events before agent dispatch; suppress durable CAS losers and replay only recoverable real events. - Bind provider message IDs to the correct session/transcript before retiring delivery obligations, including restart redelivery. - Treat successful recovery ACKs without a stable provider message ID as failed/recoverable instead of falsely delivered. - Seed continuable cron/background transcripts atomically before visible delivery and preserve multiplex profile scope. - Remove blank synthetic interactive restart turns while preserving non-interactive recovery behavior. - Add hermetic unit, integration, concurrency, profile-isolation, and process-boundary restart coverage. ## How to Test 1. Run the focused continuity and scheduler suite: `scripts/run_tests.sh tests/gateway/test_delivery_ledger.py tests/gateway/test_delivery_ledger_producer.py tests/gateway/test_message_continuity_inbound.py tests/gateway/test_message_continuity_outbound.py tests/gateway/test_message_continuity_e2e.py tests/gateway/test_message_continuity_restart.py tests/gateway/test_message_continuity_cron.py tests/gateway/test_message_continuity_diagnostics.py tests/gateway/test_message_continuity_schema.py tests/cron/test_scheduler_continuity_e2e.py tests/gateway/test_restart_resume_pending.py tests/tools/test_cronjob_tools.py tests/gateway/test_multiplex_busy_input_mode.py` 2. Run `python3 -m compileall -q gateway/message_continuity.py gateway/delivery_ledger.py gateway/platforms/base.py gateway/run.py cron/scheduler.py tools/cronjob_tools.py`. 3. Run `git diff --check origin/main..HEAD`. Latest exact-head result: 240 passed, 0 failed in each independent code and security gate. A broader integration run on the same candidate completed 338 passed, 0 failed. The earlier full gateway suite completed 5,321 passed / 3 failed / 37 skipped; all three failures reproduced identically on the pinned base and are environment-specific, not candidate regressions. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs and documented the overlapping but narrower work above - [x] My PR contains only changes related to this fix - [ ] I've run `pytest tests/ -q` and all tests pass (focused and full-gateway evidence is reported above; three full-gateway failures reproduce on the pinned base) - [x] I've added tests for my changes - [x] I've tested on macOS 26.6 ### Documentation & Housekeeping - [x] Documentation update — N/A; behavior is internal and covered by diagnostics/tests - [x] `cli-config.yaml.example` update — N/A; no config keys added - [x] `CONTRIBUTING.md` / `AGENTS.md` update — N/A - [x] Cross-platform impact considered; implementation uses Python/SQLite/temp-state abstractions and tests are hermetic - [x] Tool descriptions/schemas update — N/A ## Safety / Rollback - No live gateway, provider, credential, configuration, or production state was touched. - Rollback is the isolated PR revert; schema changes are additive and nullable.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84142",
        "createdAt": "2026-08-11T22:47:39Z",
        "updatedAt": "2026-08-13T17:59:26Z",
        "timestamp": "2026-08-13T17:59:26Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "comp/cron",
          "P2",
          "needs-decision",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery",
          "sweeper:risk-compatibility",
          "area/sessions"
        ],
        "author": "Isaac-b-ux",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84183",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): escape spaced currency amounts from remark-math",
        "text": "## What does this PR do? remark-math parses prose currency amounts like `US$ 5M` / `R$ 15.4M` as inline math, corrupting message rendering. Escape spaced currency amounts so the parser leaves them as prose. ## Related Issue Fixes #85306 ## Type of Change - [x] 🐛 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 - Escape spaced currency amounts before math parsing (`apps/desktop/src/components/assistant-ui/markdown-text.ts`) - Tests: `apps/desktop/src/components/assistant-ui/markdown-text.test.ts` - `contributors/emails/nformenton@gmail.com` — attribution mapping ## How to Test 1. Send a message containing `US$ 5M` and `R$ 15.4M`. 2. The amounts render as plain prose, not inline math. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `npm run test:ui -- src/components/assistant-ui/markdown-text.test.ts`: **41 passed**.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84183",
        "createdAt": "2026-08-12T01:06:05Z",
        "updatedAt": "2026-08-13T13:12:51Z",
        "timestamp": "2026-08-13T13:12:51Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "P3",
          "comp/desktop"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84202",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(onebot): add OneBot 11 platform adapter (NapCat / Lagrange / LLOneBot)",
        "text": "## Summary Adds a **OneBot 11 platform adapter** as a plugin (`plugins/platforms/onebot/`), connecting Hermes to QQ via local bridges — NapCat, Lagrange, LLOneBot, go-cqhttp — over reverse or forward WebSocket. Unlike the official QQ Bot platform (Tencent-approved app + openid), OneBot drives a regular QQ account, which is what many personal-bot and group use cases need. ### Features - **DM + group chats** with per-scope policies: `dm_policy` / `group_policy` (`open` / `allowlist` / `disabled`), `require_mention` in groups (@ or reply-to triggers the bot; fails closed when the bot id is unknown) - **CQ-code parsing**: `at` / `at all` → text, faces → emoji, replies stripped; inbound images downloaded to temp dir for the vision tool (undownloadable → `[图片]`) - **Outbound** uses OneBot **segment arrays** (NapCat rejects CQ-string payloads with a TypeError), no reply-quoting, media attached to the final chunk - **Long replies**: ≤`split_length` (100) chars single message; up to `text_image_threshold` (150) split at sentence boundaries (`。！？!?；;\\n`, never mid-sentence); beyond that rendered as a **CJK-aware text image** (720px, font fallback chain Noto CJK → WenQuanYi → Unifont, glyph-level fallback so no tofu boxes) - Authorization via `ONEBOT_ALLOWED_USERS` / `ONEBOT_ALLOW_ALL_USERS`; cron delivery via `ONEBOT_HOME_CHANNEL` - Configurable through `gateway.platforms.onebot.extra` in `gateway-config.yaml` ### Tests `tests/plugins/platforms/test_onebot_platform.py` — 21 tests: reply splitting, text-image rendering, CQ parsing, mention gating, DM/group policies, outbound segment payloads (fake WS with echo), and a live reverse-WS round trip against a fake NapCat client (ephemeral port, no network). ### Docs - `website/docs/user-guide/messaging/qq-onebot.md` (new) - sidebar + environment-variables entries Related: #38037 (shared OneBot client — complementary; this PR is the gateway platform side).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84202",
        "createdAt": "2026-08-12T01:54:11Z",
        "updatedAt": "2026-08-13T13:05:43Z",
        "timestamp": "2026-08-13T13:05:43Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/plugins",
          "platform/qqbot",
          "area/config",
          "P3"
        ],
        "author": "mario841859784",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84341",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(xai): drop stale 256K grok-4.6 context cache",
        "text": "## Summary Official card ([docs.x.ai/developers/models/grok-4.6](https://docs.x.ai/developers/models/grok-4.6)): **500,000** context. [#84661](https://github.com/NousResearch/hermes-agent/pull/84661) landed the catalog. `main` already lists native `grok-4.6` on the xAI picker. This PR is **only** the leftover cache guard. Pre-catalog builds resolved `grok-4.6` via the `grok-4` catch-all (**256K**) and persisted it. After the catalog land the table is 500K, but a leftover cache entry still wins. Same pattern as the existing grok-4.3 guard. ## Not in this PR - No catalog restatement - No picker / extras restatement (already on `main`) - No `xhigh` special-case - No tool-default flip `4.5` → `4.6` ## Test plan - [x] `uv run --extra dev pytest tests/agent/test_model_metadata.py::TestGrok46StaleCacheGuard tests/agent/test_model_metadata.py::TestGrok43StaleCacheGuard tests/agent/test_reasoning_stale_timeout_floor.py -q` → 40 passed - [ ] CI green",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84341",
        "createdAt": "2026-08-12T06:41:31Z",
        "updatedAt": "2026-08-13T17:22:18Z",
        "timestamp": "2026-08-13T17:22:18Z",
        "metrics": {
          "reactions": 0,
          "comments": 5
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/cli",
          "provider/xai",
          "P2",
          "sweeper:risk-compatibility"
        ],
        "author": "Julientalbot",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84387",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(api): stamp approval.request events with session_id",
        "text": "## Problem `/v1/runs` SSE streams emit `approval.request` events **without `session_id`** — only `run_id`. Every other event in the same stream goes through `_event_payload()`, which injects `session_id`; `_approval_notify` pushes the raw dict straight onto the queue. The Desktop renderer routes gateway events through `resolveGatewayEventSessionId()` (`apps/desktop/src/lib/gateway-events.ts`). With no explicit `session_id` on `approval.request`, the event falls into the unscoped-stream bucket and resolves to `unscopedStreamSessionId || activeSessionId`. For a remote approval that arrives outside an active `message.start`-pinned stream, that resolves to `null`, the prompt is parked under `keyFor(null) = \"\"`, and **no approval UI renders** — the agent stalls until the approval timeout and the tool fails closed with no way to approve. This is the transport half of #83443 (remote Desktop terminal approvals time out with no visible prompt). ## Fix Stamp `session_id` on the `approval.request` frame in `_approval_notify`, mirroring every other `/v1/runs` event. The frontend's explicit-session route then wins: the prompt lands on the right session and the inline/fallback approval UI appears. ## Validation - E2E on a live gateway (`/api/ws` JSON-RPC, v0.20.0): `session.create` returns `session_id=aa1038bc`; the subsequent `approval.request` frame now carries the same `session_id`, matching the frontend's `$activeSessionId` (previously the event had no session field at all). - `tests/gateway/test_api_server_runs.py`: unchanged pass/fail set vs baseline (the pre-existing failures are environment LLM/network dependent, identical with and without this change). Refs #83443",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84387",
        "createdAt": "2026-08-12T07:58:20Z",
        "updatedAt": "2026-08-13T13:20:28Z",
        "timestamp": "2026-08-13T13:20:28Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "P2",
          "sweeper:risk-message-delivery"
        ],
        "author": "yflmq001",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84496",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cli): resolve named custom provider credentials through the profile secret scope",
        "text": "## What does this PR do? `hermes model` → a named `custom_providers` entry lands in `_model_flow_named_custom` (`hermes_cli/model_setup_flows.py:1505`), which resolves the provider's credential and immediately probes the endpoint with it: `fetch_api_models(api_key, base_url, **fetch_kwargs)`. Both of the shapes it resolves that credential from were read scope-blind, so the value it sends as the bearer token could be a *different profile's* API key — or not a key at all. **1. `key_env` was read with a raw `os.environ.get`.** ```python if not api_key and key_env: api_key = os.environ.get(key_env, \"\") # :1527 on main ``` Under the multiplexed gateway one process serves many profiles from one `os.environ`, so this hands the current profile whatever key happens to be in the process environment. Because the resolved value is then transmitted to *this* entry's `base_url`, the failure mode is not a wrong menu — it is another profile's credential being disclosed to whatever third party that URL points at. `get_env_value` routes the read through `agent.secret_scope.get_secret`, which encodes the whole policy (scope authoritative under multiplexing; identical to the legacy `os.environ` read when multiplexing is off) and is fail-closed when multiplexing runs with no scope installed. **2. An unresolved `${VAR}` config ref was sent verbatim as the bearer token.** An entry may carry `api_key: ${MYCORP_API_KEY}` — the shape `hermes model` itself writes (`hermes_cli/main.py` sets `model[\"api_key\"] = f\"${{{custom_key_env}}}\"`). `load_config` expands those refs via `_expand_env_vars`, which by design *keeps the reference verbatim* when the variable is unset, \"so callers can detect them\". This caller did not detect it: the literal `${MYCORP_API_KEY}` is a non-empty string, so it was accepted as the credential and used as the `Authorization` value — a guaranteed 401 whose cause is invisible — and, being truthy, it also made the `key_env` fallback directly below unreachable for any entry that sets both fields. The ref is now resolved through the same scope-aware reader first, so a ref that resolves wins, a ref that does not falls through to `key_env`, and the placeholder is never transmitted. **3. …and the `${VAR}` case that is *invisible* in the expanded value.** Shape 2 above only covers the branch where the referenced variable is **unset** — which is the branch where there is no credential to leak. `_expand_env_vars` keeps the literal verbatim *only* then. When the variable **is** set, `config.py::_env_expand_match` substitutes it straight out of the process-global `os.environ`, with no scope check, and `api_key` arrives as a plain resolved string carrying no trace of the ref: | `${MYCORP_API_KEY}` | `provider_info[\"api_key\"]` after `load_config()` | placeholder guard fires? | |---|---|---| | unset | `'${MYCORP_API_KEY}'` | yes — the harmless case | | set to another profile's key | `'sk-other-profile'` | **no** — the harmful case | Nothing about that string distinguishes it from a directly-configured inline key, so the guard in shape 2 cannot see it, and the value is accepted as this provider's credential and sent as the bearer token to its `base_url`. So the very thing this PR is about — a scope-blind read reaching a third-party endpoint — survived on the config shape `hermes model` itself writes. The fix does not need a new config read: the unexpanded template is already carried on `provider_info[\"api_key_ref\"]`, the same field `_custom_provider_api_key_config_value` trusts to persist the entry. Keying the decision off the template instead of off the expanded value covers both branches with one rule. Composite templates are handled explicitly so neither direction regresses: `sk-${SUFFIX}` is not a bare ref and keeps its expanded value, while a composite the expansion could *not* resolve still contains `${` and is dropped rather than probed with. Mirrors the resolver precedence on the `/model` picker path in `hermes_cli/model_switch.py`: `${VAR}` ref > `key_env` > empty, both read through the profile secret scope. ## Related Issue No filed issue — this is a follow-on to the scope-blind-credential-reader sweep already asserted on `main`. `0569c001d08` (*\"fix(model-switch): route switch_model user-provider key reads through the secret scope\"*) converted the byte-identical shape `os.environ.get(_kenv, \"\")` on the `/model` picker path, and the helper it introduced states the remaining gap in its own docstring: `_scoped_key_env` — *\"That is the class swept in `854007d1c` for the fallback/aux key reads; the picker's `key_env` reads were not covered.\"* `get_env_value`'s docstring likewise records the class as closed — *\"this was the last scope-blind reader of the trio (#67027)\"* — so a surviving scope-blind reader on the same code path is a regression against a stated invariant, not a new feature request. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] 🔒 Security fix - [x] ✅ Tests (adding or improving test coverage) ## Changes Made - `hermes_cli/model_setup_flows.py` — in `_model_flow_named_custom`: - resolve an unresolved `${VAR}` / `${env:VAR}` `api_key` ref through `get_env_value` before the `key_env` branch, instead of sending the placeholder as the bearer token; - key that resolution off the **unexpanded** template on `provider_info[\"api_key_ref\"]`, so an `api_key` whose ref `load_config` already expanded out of the process environment is re-resolved through the scope rather than trusted; - resolve `key_env` through `get_env_value` instead of `os.environ.get`. - Ref-name normalization uses the config module's own `_env_ref_var_name`, so the `${env:VAR}` SecretRef spelling and the non-env sources (`bitwarden:`, `vault:`, …) are treated exactly as `load_config` treats them rather than by a second ad-hoc parser. - `tests/hermes_cli/test_named_custom_provider_key_scope.py` — new, 7 regressions. Shipped as 6 atomic commits: each production site separately, each followed by its tests. ### Sibling-site sweep `os.environ.get(key_env, …)` / `os.getenv(key_env, …)` across the repo, production files only: | site | status | |---|---| | `hermes_cli/model_switch.py` (picker) | already converted upstream in `0569c001d08` | | `hermes_cli/model_setup_flows.py:1527` | **fixed here** — the last raw one in this module | | `acp_adapter/server.py:155` | same cause, deliberately not changed — see below | Within `hermes_cli/model_setup_flows.py` this was the only raw `key_env` read. The other four all already use the scope-aware reader and all feed the same `fetch_api_models` call: - `:2850` `api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else \"\")` - `:2864` same shape - `:2879` same shape - `:2941` `get_env_value(key_env) if key_env else \"\"` ### Deliberately not changed - **`acp_adapter/server.py:155`** — `api_key = os.environ.get(key_env, \"\").strip() if key_env else \"\"` in `_named_custom_provider_catalogs` is genuinely the same root cause. It is left out because that file is being actively rewritten right now (open PRs #84332, #84232, #84101, #73768 and #67934 all touch it, several within the last day), and #67934's diff already edits the two lines immediately above it. Landing this one-line change there would collide for no benefit; it wants its own PR once that traffic settles. Flagging it explicitly so it is not lost. - **`:2151`, `:2753`, `:2805` — `os.getenv(base_url_env, …)`.** A base URL is not a credential; routing it through a credential reader (with a `.env` fallback and a fail-closed raise) is a different change and probably the wrong one. - **`:3071` — `os.getenv(var, \"\").strip() == existing_key`.** A display-attribution comparison (\"(from Bitwarden)\"), not a credential resolution. - **`hermes_cli/model_switch.py:1707`** — `if _ukey.startswith(\"${\") and _ukey.endswith(\"}\")` on the `/model` picker path has the *same* expanded-value blindness that shape 3 fixes here: `_ucfg` comes from `user_providers`, which `inventory.py:105` builds from `load_config()`, so it is already expanded. Left out on purpose, because the remedy is genuinely different rather than the same patch: that path reads the `providers:` block, which has no `api_key_ref` equivalent, so fixing it means plumbing a `read_raw_config()` lookup into the picker — a different config surface, a different function, and a design call about where raw config gets read on that path. It wants its own PR. Flagging it explicitly so it is not lost. - **`hermes_cli/config.py::_env_expand_match`** also reads raw `os.environ`, and is what leaves the `${VAR}` placeholders this PR now handles. It is deliberately untouched: it expands *every* config value, not just credentials, so giving it a `.env` fallback and a fail-closed raise would change config loading globally. That is a maintainer call, and handling the unresolved ref at the credential call site is the diff-local fix. - **`hermes_cli/models.py`** provider-catalog key reads (`:2128`, `:2972`, `:4486`, `:4646`, `:4920`) — same class, but a different surface and a much busier file; a separate PR. ## How to Test ``` uv run --with pytest --with pytest-asyncio python3 -m pytest \\ tests/hermes_cli/test_named_custom_provider_key_scope.py -v ``` Before/after was verified in both directions, per commit, on `a3bcb2c2326`: | test | on `main` | after | |---|---|---| | `test_key_env_resolves_from_the_profile_scope_not_the_process_env` | probes with `'sk-other-profile'`, expected `'sk-this-profile'` | pass | | `test_unresolved_env_ref_api_key_is_resolved_not_sent_as_the_bearer_token` | probes with `'${MYCORP_API_KEY}'` | pass | | `test_unresolvable_env_ref_api_key_falls_through_to_key_env` | probes with `'${MYCORP_MISSING_KEY}'` | pass | | `test_key_env_is_fail_closed_when_multiplexing_runs_without_a_scope` | `DID NOT RAISE UnscopedSecretError` | pass | | `test_already_expanded_env_ref_api_key_is_re_resolved_through_the_scope` | probes with `'sk-other-profile'`, expected `'sk-this-profile'` | pass | | `test_already_expanded_env_ref_api_key_is_dropped_when_out_of_scope` | probes with `'sk-other-profile'`, expected `''` | pass | | `test_partially_interpolated_api_key_keeps_its_expanded_value` | pass (non-regression guard) | pass | Note the first of those three: on the pre-fix code the probe transmits the literal string `sk-other-profile` — i.e. the leak is directly observable at the call site, not inferred. Each production commit is independently load-bearing: applying only the first leaves tests 2 and 3 red, only the second leaves tests 1 and 4 red, and only the first two leaves tests 5 and 6 red. The assertions inspect the argument `fetch_api_models` actually receives, because that is the point at which the credential leaves the process — a test that only read the local variable would still pass if the value never reached the probe. Manual: add a `custom_providers` entry with `key_env: MYCORP_API_KEY` (or `api_key: ${MYCORP_API_KEY}`), run `hermes model`, pick it. Before, an entry whose variable is unset probes with the literal `${MYCORP_API_KEY}` and 401s with no indication why; after, it probes with no credential and falls back to the saved model list. Adjacent suites run green: `test_model_switch_custom_providers.py`, `test_custom_provider_model_switch.py`, `test_setup.py`, `test_config_env_expansion.py`, `test_config_env_ref_parity.py`, `tests/test_secret_scope_plugin_families.py` — 89 passed. ## CI note `Python tests` is fully green on the current head (`8124a84a07c`) — all 12 slices plus `e2e`. The e2e flake noted here previously (`test_plaintext_restart_gateway_routes_to_safe_restart_command[telegram]`) did not recur and that note is withdrawn. One check is red: `build (arm64, ubuntu-24.04-arm, linux/arm64, ...)`. It is an infrastructure failure in the image build, not a test failure, and nothing in it touches this diff: ``` error: Failed to install cpython-3.11.14-linux-aarch64-gnu Caused by: Request failed after 3 retries Caused by: Failed to download https://github.com/astral-sh/python-build-standalone/ releases/download/20260127/cpython-3.11.14+20260127-aarch64-unknown-linux-gnu-... Caused by: http2 error Caused by: stream error received: refused stream before processing any application logic ``` `uv python install 3.11` could not reach the GitHub release asset; the workflow's own retry wrapper burned both of its attempts on the same transport error. The identical check is `success` on the two other branches I pushed today, so it is a transient network failure on that runner rather than anything repo-wide or branch-specific. Not spending an empty commit on it: it has no upstream root cause to name, so it is not a legitimate retrigger — and as an outside contributor `gh run rerun` returns *\"Must have admin rights to Repository\"*. Any future push to this branch re-runs it naturally. ## Related / Positioning Duplicate search, so the basis is visible rather than asserted: - **Text search** (`gh search prs --state open`, which indexes title/body only) on `_model_flow_named_custom`, `model_setup_flows`, `get_env_value`, `_scoped_key_env`, `named custom provider key_env`: no open PR proposes routing this flow's credential reads through the secret scope. The nearest neighbours are on other files — #83908 and #84199 (`model_switch.py` aliases), #76882 (`agent/auxiliary_client.py`, `gateway/`), #79283 (`agent/credential_pool.py`), #74587 (`hermes_cli/config.py`). - **By changed path**, across the open queue: 48 open PRs touch `hermes_cli/model_setup_flows.py`; five have hunks anywhere near this region and none covers line 1527 — #58776, #76480, #25250, #45533, and #66522 (whose hunks are all inside `_model_flow_vertex`, `@@ -2539` … `-2637`). - **#67934** (*\"fix: use native Ollama tags for local model discovery\"*) is worth calling out because it rewrites **this same function** — for an unrelated purpose (native `/api/tags` catalog discovery), and it does not touch the credential resolution. Its `model_setup_flows.py` hunks are `@@ -1505,14`, `-1538,13`, `-1552,19`, `-1623,7`; this PR's single production hunk is `@@ -1522,9`, which sits in the gap between its first two. The two apply independently and in either order — the import used here is scoped to the credential block precisely so the function's shared import header stays byte-identical to `main` and cannot collide. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass <!-- ran the focused + adjacent suites listed under \"How to Test\" (89 passed), not the full tree --> - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin 25.4) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A <!-- N/A: no user-facing surface changes; rationale is in code comments --> - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A <!-- N/A: no new config keys --> - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A <!-- N/A --> - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A <!-- pure env-var resolution, no platform-specific paths; only tested on macOS --> - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A <!-- N/A -->",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84496",
        "createdAt": "2026-08-12T11:39:52Z",
        "updatedAt": "2026-08-13T15:32:07Z",
        "timestamp": "2026-08-13T15:32:07Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/security",
          "comp/cli",
          "area/auth",
          "P2",
          "sweeper:risk-security-boundary",
          "area/profiles"
        ],
        "author": "briandevans",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84529",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): call write_eof() on SSE error paths (connection leak fix)",
        "text": "## Problem When SSE streams encounter errors, the HTTP response is not properly closed. This causes connections to leak and exhaust the connection pool, causing subsequent requests to hang or fail. ## Fix Add `write_eof()` calls on all 4 SSE error paths in `gateway/platforms/api_server.py`: - `_handle_session_sse`: write_eof on session stream error - `_handle_agent_sse`: write_eof on successful stream end - `_handle_agent_sse`: write_eof on error chunk path - `_handle_agent_sse`: write_eof on agent crash path ## Files Changed - `gateway/platforms/api_server.py` — 4 lines added (write_eof calls) ## Split Plan This is PR 2 of 3. See the comment on the original PR #78467 for the full split plan. - **PR 1 (SSE keepalive):** `agent/conversation_loop.py` — stream_delta_callback fix - **PR 3 (relay recovery):** `agent/relay_runtime.py` — scope stack rewrite",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84529",
        "createdAt": "2026-08-12T12:39:41Z",
        "updatedAt": "2026-08-13T12:56:35Z",
        "timestamp": "2026-08-13T12:56:35Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "P2",
          "sweeper:risk-message-delivery",
          "area/streaming"
        ],
        "author": "gottabstrong",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84531",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(relay): recover from scope stack out-of-sync errors (nemo_relay bug workaround)",
        "text": "## Problem When a session closes with errors, the nemo_relay scope stack gets out of sync, causing scope.pop() to raise RuntimeError: 'scope handle is not at the top of the stack'. This is a bug in nemo_relay's native Rust extension. Stale handles block future sessions from starting. ## Fix Adds `_attempt_stack_recovery()` which: 1. Peeks at the scope stack to find the target handle's position 2. Pops everything above the target handle 3. Pops the target handle itself 4. Falls back to clearing the entire stack if recovery fails Called from `end_turn()` when a RuntimeError is caught during scope.pop(), preventing stale handles from blocking future sessions. ## Files Changed - `agent/relay_runtime.py` — 76 lines added (new method + error handler) ## Split Plan This is PR 3 of 3. See the comment on the original PR #78467 for the full split plan. - **PR 1 (SSE keepalive):** https://github.com/NousResearch/hermes-agent/pull/84528 - **PR 2 (write_eof fix):** https://github.com/NousResearch/hermes-agent/pull/84529 **Dropped from original PR:** - API server 0.0.0.0 default binding (security concern, PR #83546 handles this) - Query-string API key fallback (security concern, PR #83546 handles this) - Non-TTY auto-replace (converts explicit contract to automatic)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84531",
        "createdAt": "2026-08-12T12:43:20Z",
        "updatedAt": "2026-08-13T12:56:30Z",
        "timestamp": "2026-08-13T12:56:30Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P3",
          "telemetry",
          "sweeper:risk-session-state"
        ],
        "author": "gottabstrong",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84577",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(approval): unwrap command wrappers before detection (#84551)",
        "text": "## Summary - detect interpreter file execution as requiring approval - inspect commands wrapped by `timeout` before dangerous-command classification - add regression coverage for the reported bypasses Closes #84551",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84577",
        "timestamp": "2026-08-13T12:06:07Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/security",
          "comp/tools",
          "tool/terminal",
          "area/auth",
          "P2",
          "sweeper:risk-security-boundary"
        ],
        "author": "kyssta-exe",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84586",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(hindsight): add local_embedded mode and full local-mode fields to config schema",
        "text": "## Problem The desktop Memory & context panel for Hindsight (issue #84572) only declares `cloud` and `local_external` modes, so Hindsight instances configured in `local_embedded` mode display incorrect cloud defaults: - **Mode** shows Cloud (fallback from invalid `local_embedded` value) - **API URL** shows the cloud default (the actual value is silently coerced) - **API key** shows \"not set\" (wrong env var: uses HINDSIGHT_API_KEY instead of HINDSIGHT_LLM_API_KEY) This happens because the wizard schema (used by `hermes memory setup`) declares all three modes plus local-mode fields, but the desktop's `config_schema.py` only knows about cloud and local_external. ## Fix Update `plugins/memory/hindsight/config_schema.py` to mirror the wizard's full schema: 1. **Add `local_embedded`** to the mode select options 2. **Add cloud-mode fields** with `when={\"mode\": \"cloud\"}` for visibility 3. **Add local_external-mode fields** with `when={\"mode\": \"local_external\"}` 4. **Add local_embedded-mode fields**: llm_provider, llm_base_url, llm_model, llm_api_key, idle_timeout, port_health_grace_timeout 5. **Add all shared fields** from the wizard: bank_id_template, bank_mission, bank_retain_mission, memory_mode, recall_prefetch_method, retain_tags, observation_scopes, retain_source, retain_user_prefix, retain_assistant_prefix, recall_tags, recall_tags_match, recall_types, auto_recall, auto_retain, retain_every_n_turns, retain_async, prefetch_waits_for_retain, prefetch_retain_drain_timeout, retain_context, recall_max_tokens, recall_max_input_chars, recall_prompt_preamble, timeout Also implement the underlying conditional visibility infrastructure: - **`ProviderField`**: new `when: dict[str, str] | None = None` attribute - **`_read_field_dep()`**: helper to read dependency values from stored config - **`_declared_provider_payload()`**: skips fields whose `when` predicate is not met This makes the desktop panel faithfully reflect whatever mode the wizard configured. ## Test Plan - [x] Lint passes (pyright) - [ ] Verify Hindsight panel shows correct values in local_embedded mode on desktop",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84586",
        "createdAt": "2026-08-12T14:19:40Z",
        "updatedAt": "2026-08-13T13:20:36Z",
        "timestamp": "2026-08-13T13:20:36Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/plugins",
          "tool/memory",
          "P3",
          "sweeper:risk-compatibility",
          "area/memory"
        ],
        "author": "blut-agent",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84589",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(tui_gateway): stop replaying live-turn user text after redirect",
        "text": "## What does this PR do? Stops the tui_gateway **server queue** from re-firing the live turn's original user prompt **P** after a mid-turn correction **Q** (Desktop `session.redirect` / busy-input interrupt redirect). That re-fire produced a second agent turn and a second active `role=user` row with the same content (#84417). **Root cause (contract layer):** while turn P was live, a text-only self-copy of P could sit in `session[\"queued_prompt\"]` / `queued_prompts` (e.g. mid-busy re-submit while redirect was not available; consecutive-text merge could also glue `P\\n\\nQ`). Successful redirect/steer of Q did not scrub that self-duplicate, so post-settle `_drain_queued_prompt` restarted P. **Approach:** 1. Scrub text-only self-duplicates of `inflight_turn.user` on successful redirect/steer; rewrite merged `{P}\\n\\n{Q}` → `Q` only. 2. Refuse admitting a text-only self-copy of the live user in `_enqueue_prompt` (scrub first so merge cannot re-glue P+Q). 3. On compression session rotation, bump `_queued_prompt_generation` so an in-flight drain claim cannot dispatch with a stale generation. If generation cancels a claim mid-drain, the claimed envelope is **restored** to the queue (legitimate follow-ups are not dropped). **Scope / residual (honest limits of this PR):** - Fixes **server** self-duplicates of the *live* turn. The independent Desktop **client** composer queue (`$queuedPromptsBySession`) is unchanged; a user-queued follow-up can still drain by design. - Image-bearing queue envelopes are left alone (chronology/ownership). - Verification is production-path unit/integration on gateway helpers (class repro), not a full interactive long-session Desktop compression e2e. ## Related Issue Fixes #84417 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✅ Tests (adding or improving test coverage) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `tui_gateway/server.py` — `_sanitize_queued_entry_vs_inflight_user`, `_drop_queued_duplicates_of_inflight_user`; scrub on successful busy redirect/steer; refuse self-dup in `_enqueue_prompt`; bump `_queued_prompt_generation` in `_sync_session_key_after_compress` on rotation; restore claimed queue envelope when generation cancels mid-drain. - `tui_gateway/methods_session.py` — scrub on successful `session.redirect` and `session.steer` (Desktop production path). - `tests/test_tui_gateway_queue_on_busy.py` — redirect scrub, hard-interrupt path, merge rewrite, drain E2E (no second P), enqueue refuse, compress generation bump / no-op, generation-cancel restores claimed head. - `tests/test_tui_gateway_server.py` — `session.redirect` RPC scrub + build-window queue of Q scrubs stale P. ## How to Test 1. **Regression suite:** ```bash scripts/run_tests.sh tests/test_tui_gateway_queue_on_busy.py -q --tb=short scripts/run_tests.sh tests/test_tui_gateway_server.py -k \"session_redirect\" -q --tb=short scripts/run_tests.sh tests/tui_gateway/test_protocol.py -k \"sync_session_key_after_compress\" -q --tb=short ``` 2. **Evidence (local runner):** `tests/test_tui_gateway_queue_on_busy.py` — 25 passed; related `session.redirect` / compress re-anchor tests passed via `scripts/run_tests.sh`. 3. **Key cases:** - successful redirect drops queued self-dup of inflight user (busy-submit + Desktop `session.redirect` RPC) - hard-interrupt + queue of Q scrubs stale P ahead of Q - build-window `session.redirect` enqueue of Q scrubs P - after redirect(Q), `_drain_queued_prompt` does **not** start a turn with P - merged `P\\n\\nQ` rewrites to Q-only - compress rotation bumps generation; unrelated follow-up text is preserved - generation cancel mid-drain does **not** drop the claimed follow-up (restored to queue) 4. **Class repro (pre/post):** P running + `queued_prompt=P` + redirect(Q) → pre-fix drain re-fired P; post-fix queue scrubbed, drain no-op. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `scripts/run_tests.sh` on the affected tests and they pass (project canonical runner) - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Linux (WSL/Ubuntu-class), via `scripts/run_tests.sh` ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — **N/A** - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — **N/A** - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — **N/A** - [x] I've considered cross-platform impact (Windows, macOS) — pure Python gateway session-dict logic; no OS-specific paths - [x] I've updated tool descriptions/schemas if I changed tool behavior — **N/A**",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84589",
        "createdAt": "2026-08-12T14:25:21Z",
        "updatedAt": "2026-08-13T13:07:02Z",
        "timestamp": "2026-08-13T13:07:02Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/tui",
          "P2",
          "sweeper:risk-session-state"
        ],
        "author": "StanleyStetson",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84605",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): inherit auxiliary provider transport",
        "text": "## Summary - resolve auxiliary vision transport from the selected provider profile instead of silently falling back to OpenAI chat completions - preserve explicit auxiliary overrides while inheriting provider-level api_mode for custom and built-in providers - add focused regression coverage for Codex Responses, explicit overrides, unknown transports, and main-model isolation ## Validation - canonical focused runner: 20 tests passed - `scripts/check-windows-footguns.py --diff upstream/main`: PASS - full CI-like suite used the repository exact locked Python 3.11 extras - patch run and clean upstream/main baseline reproduced the same 14 stable failures across 10 unrelated platform/environment files; regression delta: 0 - one additional picker prewarm failure from the full patch run passed on focused rerun (3/3) and was not reproducible in the baseline comparison No installed Hermes runtime, live config, credentials, or gateway process was changed.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84605",
        "createdAt": "2026-08-12T14:59:01Z",
        "updatedAt": "2026-08-13T13:39:21Z",
        "timestamp": "2026-08-13T13:39:21Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/openai",
          "P3"
        ],
        "author": "Const-Iv",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84622",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(tts): split long-form text on CJK punctuation",
        "text": "## Summary Long-form TTS chunking now respects **CJK punctuation** as sentence boundaries. Chinese/Japanese/Korean text has no spaces, so the old logic (`re.split(r\"(?<=[.!?;:,])\\s+\", ...)`) treated an entire paragraph as one \"word\" and hard-split it at arbitrary characters — e.g. `发现三个隐|患点` — producing unnatural mid-word breaks in spoken output. Latin punctuation behavior is unchanged (still breaks only after whitespace), so `3.14` and `Dr.` are never split mid-token. ## Background The `text_to_speech` tool splits long text into provider-safe chunks before synthesis (`_split_text_for_tts`). The chunking pipeline was built for space-delimited languages: sentence detection splits on `.!?;:,` **followed by whitespace**, and `_split_oversized_sentence` splits on word boundaries. CJK scripts don't use spaces, so: 1. Sentence detection never fires for pure-CJK text → the whole paragraph becomes a single \"sentence\" 2. `sentence.split()` on space-free CJK returns the paragraph as one giant word 3. `_split_oversized_sentence` hard-splits that word at `max_chars` boundaries — **mid-word, mid-sentence** Symptom: Chinese users get TTS chunks like `发现三个隐` / `患点。第一是` — the synthesis of each chunk reads with a broken word, and chunk boundaries have no relation to natural pauses. ## Root Cause `tools/tts_tool.py::_split_text_for_tts` (sentence regex) — CJK punctuation was simply absent from the break set, and the break condition required whitespace which CJK never has. ## Fix Sentence detection now also breaks after CJK punctuation (`。！？；，、：”’`) — as a hard break with or without trailing whitespace — while Latin punctuation keeps the whitespace requirement: ```python r\"(?<=[.!?;:,])\\s+|(?<=[。！？；，、：”’])(?:\\s+|(?=[^\\s]))\" ``` The existing greedy merge pass (short sentences combined up to `max_chars`) is untouched, so boundaries land after punctuation whenever the text fits, and only truly punctuation-free runs fall back to character hard-split. ## Verification Reproduction before fix (`max_chars=30`): ``` 块1: 今天安全审计进展汇报。我们完成了核心系统全面排查，发现三个隐 ← mid-word break 块2: 患点。第一是认证模块会话管理存在过期时间过长风险。第二是日志 ``` After fix: ``` 块1: 今天安全审计进展汇报。 我们完成了核心系统全面排查， 块2: 发现三个隐患点。 块3: 第一是认证模块会话管理存在过期时间过长风险。 块4: 第二是日志系统敏感信息脱敏不完整。 第三是备份任务监控缺失。 ``` - `python -m pytest tests/tools/test_tts_long_form_chunking.py -x -q` → **19 passed** - New regression tests: CJK boundary placement (every chunk starts after `。！？；，、：”’`), content-loss check, short CJK sentence passthrough, and Latin `3.14` / `Dr.` preservation. ## Who should enable this - **Chinese/Japanese/Korean users** reading long text aloud via any TTS provider (built-in, command-type, or plugin). No config change — the fix applies automatically to every provider that goes through `text_to_speech_tool`. - Anyone with CJK content in mixed-language passages. ## Platform compatibility | Platform | Impact | |---|---| | macOS / Linux | ✅ Same regex path, CI-verified tests | | Windows | ✅ Pure stdlib `re`, no OS-specific behavior | | All TTS providers | ✅ Fix lives in shared chunking, not in any provider | ## Quick-start guide No action needed — update to the next release and read a Chinese paragraph aloud. To see the chunking directly: ```python from tools.tts_tool import _split_text_for_tts chunks = _split_text_for_tts(\"今天安全审计进展汇报。我们完成了核心系统全面排查，发现三个隐患点。\", 30) ``` ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | Chinese TTS still breaks mid-word | Provider caps text before the shared chunker (e.g. a command wrapper truncates) | Pass full text to `text_to_speech_tool`; let Hermes chunk | | Mixed CJK/Latin text breaks oddly | Latin punctuation without trailing space (e.g. `3.14`) is intentionally not a boundary | Add a space after Latin periods in source text if a break is desired | | Very long punctuation-free CJK run | No boundary exists; hard split is the only option | Insert punctuation or raise provider `max_text_length` |",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84622",
        "createdAt": "2026-08-12T15:53:21Z",
        "updatedAt": "2026-08-13T13:38:59Z",
        "timestamp": "2026-08-13T13:38:59Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "tool/tts",
          "P2"
        ],
        "author": "x7peeps",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84624",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): strip empty tool_calls on the auxiliary client path",
        "text": "## What does this PR do? Strict OpenAI-compatible providers (DeepSeek v4, **Console Go / opencode.ai zen**) reject an assistant message carrying `tool_calls: []` with HTTP 400 `Invalid 'messages[N].tool_calls': empty array. Expected an array with minimum length 1, but got an empty array instead.` The main loop already strips these pre-send in `sanitize_api_messages` (#58755), but the **auxiliary client path** — `call_llm` / `async_call_llm`, used by the MoA aggregator and reference advisors, compression, vision and title generation — bypassed that chokepoint entirely. A poisoned live-history message (interrupted turn, dangling tool-call state after restart, or a consecutive-assistant merge) 400ed the whole auxiliary call with a **non-retryable** error, wedging the turn. ## Changes - Add `_strip_empty_tool_calls(messages)` in `agent/auxiliary_client.py`, mirroring the main-loop pass semantics exactly: - drop the `tool_calls` key on assistant messages where it is present but not a non-empty list (never write `[]`) - placeholder `(tool call removed)` when content is empty, so the turn is not an empty non-final message (Anthropic-family providers reject those; the auxiliary path has no `repair_empty_non_final_messages` backstop) - request-local copy — caller's list is never mutated - zero-copy fast path when nothing needs stripping (hot MoA reference path) - Wire it into both `call_llm` and `async_call_llm`, right after `_build_call_kwargs`, before any wire-format conversion. - Add `tests/agent/test_auxiliary_empty_tool_calls.py`: 9 unit cases + sync and async end-to-end wiring tests (monkeypatched provider, asserts the wire messages never carry an empty `tool_calls`). ## How to test ```bash PYTHONPATH=. python -m pytest tests/agent/test_auxiliary_empty_tool_calls.py -v ``` ## Related issues Fixes #84169 Note: the related dedup re-introduction gap in the main-loop sanitizer (`sanitize_api_messages` step 9 writing `tool_calls: []` after stripping) is tracked upstream in #74101 / #83203 / #64335 / #76862 / #77921 and is intentionally out of scope here — that pass already has open PRs (#64345, #77377, #82252).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84624",
        "createdAt": "2026-08-12T15:58:13Z",
        "updatedAt": "2026-08-13T13:42:57Z",
        "timestamp": "2026-08-13T13:42:57Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P2"
        ],
        "author": "falling-streamlet",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84785",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): replay the transcript spool in drop order with full fidelity on restart (supersedes #78323)",
        "text": "`supersedes #78323` ## Credit @686f6c61's #78323 found both of these defects first, and diagnosed both correctly. Its `_next_spool_file_id()` docstring names the ordering bug exactly — *\"Recovery walks `sorted(glob(\"*.json\"))`. uuid4 names re-insert out of order after a burst of pending-cap spools\"* — and its `append_kwargs` loop is a direct attempt at the fidelity bug. That PR is not stale because it was wrong. It is stale because the ground moved under it. `de0f20ff05b` (2026-08-09) landed the runtime transcript spool independently: `spool_dropped_transcript_message` / `drain_transcript_spool`, plus a new `TRANSCRIPT_CAP_DROP_REASON` branch inside `recover_pending_to_db`. That superseded #78323's own `spool_transcript_messages`. #78323 now reads `mergeable: false`, `mergeable_state: dirty`, has been cold since 2026-08-05 with zero reviews, and its diff still deletes `import uuid` and rewrites regions that no longer exist. **Both of the defects it identified are still live on main.** This PR ships them against the code that exists now. Three things had to change in the execution, and they are matters of substance rather than rebase mechanics: 1. **Where the order comes from.** #78323 fixes ordering by renaming *future* spool files (`time_ns` + counter). Main's payloads already carry `ts` and a monotonic `seq`, stamped by `spool_dropped_transcript_message`. Sorting on the payload orders the spool files a user **already has on disk** — which are exactly the files the incident produced, and which are named `pending-<uuid4>.json` and cannot be retroactively renamed. A filename scheme only helps files written after it ships. 2. **Where the fix lands.** #78323's fidelity work edits the legacy `data[\"text\"]` branch. On current main a cap-drop payload never reaches that code: it leaves the `TRANSCRIPT_CAP_DROP_REASON` branch via `continue` at `shutdown_flush.py:350`. 3. **Which idiom to copy.** The reference implementation is 90 lines above the bug in the same file. `drain_transcript_spool` already orders the identical payloads with `sorted(entries, key=lambda e: e[:3])` over `(ts, seq, path.name)`. Matching it makes the two drains agree instead of introducing a third convention. ## What does this PR do? `gateway/shutdown_flush.py` has **two** consumers of the transcript spool, and they disagree about what the spool means. `drain_transcript_spool` (`:193`) is the live drain, called from `gateway/session.py` once a transcript flush succeeds. It sorts on `(ts, seq, path.name)` and replays the full message dict through `SessionStore._append_transcript_message`, which forwards 15 fields. `recover_pending_to_db` (`:286`) is the restart drain, called unconditionally from `gateway/run.py:27979` after `runner.start()`. On the **same files**, it did two things wrong: **1 — Order.** `flush_files = sorted(flush_dir.glob(\"*.json\"))` (`:308`). `_write_payload` names files `pending-<uuid4().hex>.json`, so this sort is random. The payloads carry `ts`/`seq`; this path ignored them. This is not cosmetic. `SessionDB` restores a conversation with `ORDER BY id` — AUTOINCREMENT insertion order, never timestamp — and the comment at `hermes_state.py:8691-8699` says why: sorting otherwise risks *\"breaking tool-call/response adjacency and triggering an HTTP 400 on replay.\"* Replaying the spool in filename order writes that inversion straight into the row ids. **2 — Fidelity.** The replay forwarded four fields — `session_id`, `role`, `content`, `timestamp` — and then `path.unlink()` at `:349` made the loss permanent. Discarded: `tool_calls`, `tool_call_id`, `tool_name`, the reasoning and codex columns, `platform_message_id`, `observed`, and the `api_content` sidecar. `append_message` (`hermes_state.py:7643`) already accepts every one, and the payload already carries them. Two of those are load-bearing. Losing `tool_calls`/`tool_call_id` orphans a tool result from the call it answers — the exact adjacency `ORDER BY id` exists to protect. Losing `api_content` contradicts the requirement stated at the live writer (`gateway/session.py:3673`): the sidecar *\"must survive any gateway-side persistence path or the next turn's replay diverges at this row.\"* This is such a path. **3 — Partial-failure ordering.** Ordering the files only fixes the happy path. If `append_message` failed partway through — the DB is still unhealthy, which is the situation that created the spool — the loop kept going and wrote the *later* messages. The failed one is retried on a future start and gets a **higher** row id than the messages it originally preceded, so the inversion lands anyway, permanently. `drain_transcript_spool` already states the rule for the same spool: *\"On the first replay failure the drain stops and remaining files are kept for the next attempt (the DB is likely still unhealthy).\"* This restores the contract `de0f20ff05b` set out in its own commit message — to *\"drain and replay spooled messages in drop order\"*, with *\"replay failures keep the spool files for the next attempt\"* — on the restart path, which is the path that never implemented it. **User-visible symptom:** after recovering from the FTS corruption of #78182, restarting the gateway brings the session back scrambled, with tool calls stripped of their results, and the next turn fails with an HTTP 400. ## Related Issue Refs #78182, #82616 (both closed by `de0f20ff05b`; the two defects above are in the recovery path that commit added and are still present on `main`). ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `gateway/shutdown_flush.py` — `_order_flush_files()` + `_sort_number()`: parse each recovery payload once and order by `(ts, seq, filename)`, mirroring `drain_transcript_spool`. Unparseable payloads sort last and are handed back unparsed so the existing loop reports and preserves them exactly as before. - `gateway/shutdown_flush.py` — `_transcript_append_kwargs()`: build the `append_message` call by mirroring `SessionStore._append_transcript_message` field for field, including its role gate on the assistant-only reasoning columns and its `message_id` fallback for `platform_message_id`. Fields are whitelisted explicitly rather than splatted from the message dict, because the payload is arbitrary JSON from disk and an unexpected key would raise `TypeError` and abort the recovery pass. - `gateway/shutdown_flush.py` — `blocked_sessions`: after a failed replay, skip that session's remaining spooled messages and leave them on disk. Scoped per session, since replay order is only defined within a session and this function drains every session in one pass. Non-transcript pending payloads are unaffected. - `tests/gateway/test_shutdown_flush_recovery.py` — new file, 10 tests. Sibling sweep: `recover_pending_to_db` and `drain_transcript_spool` are the only two consumers of this spool (`grep` for `TRANSCRIPT_CAP_DROP_REASON` / `pending-*.json` over non-test sources). `drain_transcript_spool` was already correct on all three points, which is where the idiom came from; this PR brings the second consumer up to it and touches nothing else. ## How to Test ```bash # 1. The new regression suite pytest tests/gateway/test_shutdown_flush_recovery.py -q # 10 passed # 2. Everything that already covered this file and this spool pytest tests/gateway/test_shutdown_flush.py \\ tests/gateway/test_pending_queue_spool.py -q # 11 passed ``` Fails-before / passes-after, verified per hunk by reverting each production change individually against the rest of the branch: | Reverted hunk | Failing tests | |---|---| | ordering (`_order_flush_files`) | 2 — `test_replays_in_drop_order_when_names_disagree`, `test_seq_breaks_ties_within_the_same_second` | | fidelity (`_transcript_append_kwargs`) | 5 — structured fields, tool-result identity, role gate, `message_id` fallback, epoch-0 timestamp | | failure-stop (`blocked_sessions`) | 1 — `test_failure_blocks_later_messages_for_that_session_only` | | all three (clean `origin/main`) | **8 of 10** | The 2 that pass on `main` are deliberate behaviour-preservation assertions, not regression coverage: corrupt payloads stay on disk and are still reported, and `payload[\"ts\"]` remains the `timestamp` fallback. **One planned commit was dropped after checking it.** \"Keep the spool file when replay fails\" looked like a fourth defect, but on `main` `path.unlink()` already sits inside the `try` after `append_message`, so a failed replay never reaches it. Probed directly against unmodified `origin/main` — the file is retained and the test passes without any change. It would have been a commit that passes with or without the fix, so it is not here. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass — *ran the targeted suites listed under \"How to Test\" (20 passed), not the full tree locally; leaving this unticked rather than claiming it.* - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS (Darwin 25.4.0), Python 3.11 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — docstrings on the new helpers and on `recover_pending_to_db` - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A, no config keys - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — pure stdlib `json` / `pathlib` / `sort`, no platform-specific calls added - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A ## Related / Positioning Deduped two ways, because neither net is sufficient alone — `gh search prs` indexes PR title/body text and never changed paths, while `gh pr list --json files` returns newest-first and only samples the head of a ~17.8k-PR queue. **By file** — `gateway/shutdown_flush.py` is touched by exactly 5 open PRs: **#78323**, **#75536**, **#69980**, **#83620**, **#84131**. **By text/symbol** — `recover_pending_to_db` → #75536, #78323. `drain_transcript_spool` → #84131. `shutdown_flush`, `TRANSCRIPT_CAP_DROP_REASON`, `spool_dropped_transcript_message` → nothing. Note the text net **missed #83620 entirely**, because that PR's title is about `SessionDB` connection leaks; only the by-file pass caught it. Conversely the by-file recency query returned empty here — its 300-PR window spans #84168–#84776, so every one of these five is older than it can see. Both passes were necessary. Dispositions: | PR | Author | Relationship | |---|---|---| | **#78323** | @686f6c61 | **Superseded.** Same two defects; `dirty`, cold since 08-05, zero reviews. Credited above. | | #83620 | @JoaoMarcos44 | **Near-adjacent — disclosed.** Edits the *same function* at `@@ -316,6 +316,14 @@`, but for a different concern: closing an owned `SessionDB` on exception paths. Its hunks are at `:316`, `:389`, `:396`; mine are at `:308` and `:342`. Disjoint, and complementary. | | #84131 | @CryptoDombili | **Adjacent, live, complementary.** Its title (\"preserve transcript spool chronology\") is close to this one, so worth being precise: its `shutdown_flush.py` hunks are `@@ -190,7 @@` and `@@ -199,14 @@`, both inside `drain_transcript_spool`, and it fixes ordering between the spool tier and the in-memory queue during **live** operation. It contains zero references to `recover_pending_to_db`, `flush_files`, or `TRANSCRIPT_CAP_DROP_REASON`. Neither PR changes a line the other touches. | | #75536 | @spfcraze | Disjoint — `session_key` resolution on the legacy branch. | | #69980 | @xiaoyaner0201 | Disjoint — trusted-sender envelope; hunks on the text-payload branch. | **Structural argument, verified rather than asserted:** the `TRANSCRIPT_CAP_DROP_REASON` branch this PR fixes did not exist until `de0f20ff05b` (2026-08-09). Checking each rival head with `git merge-base --is-ancestor de0f20ff05b <head>`: ``` #78323 head 3441cc8ba (2026-08-05) -> PREDATES de0f20ff05b #75536 head b1db79117 (2026-07-31) -> PREDATES de0f20ff05b #69980 head edf643f63 (2026-08-05) -> PREDATES de0f20ff05b #83620 head 6eea62f6a (2026-08-10) -> has it #84131 head 3485f0d71 (2026-08-12) -> has it ``` The three that predate it cannot touch this branch at all. The two that postdate it are the two disclosed above, and both are line-disjoint from this diff. **Test files:** deliberately placed in a **new** file. `tests/gateway/test_shutdown_flush.py` is appended near EOF by #75536, #83620 and #69980, and `tests/gateway/test_pending_queue_spool.py` by #84131. This PR touches neither. ## Commits Each independently green (verified by checking out every intermediate SHA and running the touched suites — 11 / 11 / 11 / 14 / 20 / 21 passing): 1. `fix(gateway): replay cap-dropped transcript spool files in drop order on restart` 2. `fix(gateway): preserve structured transcript fields when recovering spooled messages` 3. `fix(gateway): stop a session's spool replay after the first failed append` 4. `test(gateway): cover spool replay ordering across a restart` 5. `test(gateway): cover field fidelity and failure handling in spool recovery` 6. `fix(gateway): fall back to the payload clock only when a timestamp is absent` — addresses the review finding below; epoch 0 is a valid timestamp and the inherited `or` expression would have rewritten it.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84785",
        "timestamp": "2026-08-13T11:30:47Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "P2",
          "needs-decision",
          "sweeper:risk-session-state",
          "area/sessions"
        ],
        "author": "briandevans",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84821",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(desktop): paint agent-created sessions as live in sidebar",
        "text": "## What does this PR do? Sessions created by other surfaces (CLI, TUI, gateway, cron) that are actively running on the backend paint as idle in the desktop sidebar, because the live dot only reacts to renderer-side turn activity. This PR adds backend liveness stamps and exposes foreign (agent-created) live rows via `active_list`, then lets the renderer dot/arc/caption reflect them. ## Related Issue Fixes #85302 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ 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 - Backend: liveness stamps + `active_list` foreign rows (`tui_gateway/*`, `hermes_state.py`) - Renderer: foreign-liveness dot/arc/caption (`apps/desktop/src/store/foreign-live.ts`, sidebar row components) - Tests: `tests/run_agent/test_session_activity_persist.py`, `tests/test_tui_gateway_server.py`, `apps/desktop/src/store/foreign-live.test.ts`, `apps/desktop/src/app/chat/sidebar/session-row.test.tsx`, `apps/desktop/src/app/session/hooks/use-message-stream/cross-profile-change-events.test.tsx` - `contributors/emails/nformenton@gmail.com` — attribution mapping ## How to Test 1. Start a long-running session from the CLI (`hermes`) in a second terminal. 2. In the desktop app, look at the same session in the sidebar — its dot paints live/working while the CLI session is active, without any renderer-side turn. 3. Finish the CLI session; the dot returns to idle on the next `active_list` refresh. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `pytest tests/run_agent/test_session_activity_persist.py tests/test_tui_gateway_server.py -q`: **547 passed, 2 failed** — the 2 failures (`test_load_enabled_toolsets_rejects_disabled_mcp_env`, `test_load_enabled_toolsets_falls_back_when_tui_env_invalid`) are pre-existing upstream failures, unrelated to this PR. - `npm run test:ui` (the three desktop test files above): **15 passed**.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84821",
        "createdAt": "2026-08-12T20:59:47Z",
        "updatedAt": "2026-08-13T13:12:49Z",
        "timestamp": "2026-08-13T13:12:49Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/tui",
          "P3",
          "sweeper:risk-session-state",
          "comp/desktop",
          "area/sessions"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84822",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(desktop): mark a session as unread/read with a persisted watermark",
        "text": "## What does this PR do? The sidebar gets \"mark as unread/read\" backed by the existing `last_read_at` watermark: the backend persists read-state (recursive lineage), the renderer derives a persisted unread dot, and opening a session marks it read. ## Related Issue Fixes #85305 Related (neighboring unread/watermark PRs): #74293, #76504, #82824, #74162 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ 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 - Backend: `set_session_read` watermark + unread derivation (`hermes_state.py`, `hermes_cli/web_*`) - Renderer: persisted unread store + dot-state claim + row/menu wiring (`apps/desktop/src/store/session-unread.ts`, `session-dot-state.ts`, sidebar components) - Tests: `tests/hermes_cli/test_web_server.py`, `apps/desktop/src/store/session-unread.test.ts`, `session-dot-state.test.ts`, `session.test.ts`, `apps/desktop/src/app/chat/sidebar/sessions-section.test.tsx`, `session-row.test.tsx` - `contributors/emails/nformenton@gmail.com` — attribution mapping ## How to Test 1. Right-click a session and choose \"Mark as unread\" — an unread dot appears. 2. Open the session — the dot clears (mark read on open). 3. Restart the app — unread state persists. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `npm run test:ui` (the five desktop test files above): **83 passed**. - `pytest tests/hermes_cli/test_web_server.py -q`: **148 passed, 1 failed** — `TestThemeBootstrapCSS::test_serve_index_injects_bootstrap_for_user_theme` is pre-existing (verified failing on clean upstream/main, unrelated to this PR).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84822",
        "createdAt": "2026-08-12T21:01:45Z",
        "updatedAt": "2026-08-13T13:12:57Z",
        "timestamp": "2026-08-13T13:12:57Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "comp/gateway",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery",
          "comp/desktop",
          "area/sessions",
          "area/i18n"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84846",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(sessions): heal non-chat orphan rows after 24h idle",
        "text": "## What does this PR do? One-shot surfaces (cli, acp, cron, subagents) create session rows that never close, accumulating as open rows with no liveness. This adds a heal pass: non-chat rows idle for 24h+ get closed (`end_session('orphan_heal')`). ## Related Issue Fixes #85304 Related (same bug class, upstream PRs): #44088, #47292, #56603, #65422, #76995, #50881 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ 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 - Heal pass closing non-chat orphan rows after 24h idle (`hermes_state.py`) - Tests: `tests/test_hermes_state.py` - `contributors/emails/nformenton@gmail.com` — attribution mapping ## How to Test 1. Create a one-shot session (`hermes -z`) and leave it. 2. Run the heal pass (or wait for the scheduled run). 3. The row shows `ended_at`/`end_reason='orphan_heal'` while chat rows (desktop/TUI) are untouched. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `pytest tests/test_hermes_state.py -q`: **221 passed, 1 failed** — `TestFTS5Search::test_search_projection_skips_context_enrichment_queries` is pre-existing (verified failing on clean upstream/main, unrelated to this PR).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84846",
        "createdAt": "2026-08-12T22:12:17Z",
        "updatedAt": "2026-08-13T13:12:56Z",
        "timestamp": "2026-08-13T13:12:56Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/tui",
          "P2",
          "sweeper:risk-session-state",
          "area/sessions"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84847",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): resume is read-only; liveness needs real activity",
        "text": "## What does this PR do? Two stale-end fixes, stacked on #84821: 1. Resuming (mounting) a finalized session is now read-only: it no longer reopens the row (`ended_at` cleared) or re-lights the live dot. Only the first real turn reopens it (`_reopen_if_finalized`). 2. DB-derived liveness now requires real recent activity: `active_list` foreign rows need `last_activity_at` within a 90s window, matching the renderer's expectations. Depends on #84821; when #84821 merges, this diff shrinks to the delta. ## Related Issue Fixes #85303 Related: #62038, #62012 (resume/reopen behavior family) ## Type of Change - [x] 🐛 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 - `resume` is read-only; first real turn reopens an ended session (`tui_gateway/*`, `hermes_state.py`) - DB liveness requires real recent activity (90s window) (`hermes_state.py`, `active_list`) - `_reopen_if_finalized` exposed on the server namespace (rebind-bound handler) - Tests: `tests/run_agent/test_session_activity_persist.py`, `tests/test_tui_gateway_server.py`, `apps/desktop/src/store/foreign-live.test.ts`, `apps/desktop/src/app/chat/sidebar/session-row.test.tsx`, `apps/desktop/src/app/session/hooks/use-message-stream/cross-profile-change-events.test.tsx` - `contributors/emails/nformenton@gmail.com` — attribution mapping ## How to Test 1. Let a session finish (or close it from the CLI). Open it in the desktop app — the row shows as read-only history, no live dot. 2. Send a message in it — the session reopens and starts a real turn. 3. Watch `active_list` rows for a session with no activity for >90s: they drop out of the live set. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `pytest tests/run_agent/test_session_activity_persist.py tests/test_tui_gateway_server.py -q`: **549 passed, 2 failed** — the 2 failures are the same pre-existing upstream pair as in #84821. - `npm run test:ui` (the three desktop test files above): **17 passed**.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84847",
        "createdAt": "2026-08-12T22:12:32Z",
        "updatedAt": "2026-08-13T13:12:47Z",
        "timestamp": "2026-08-13T13:12:47Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/tui",
          "P2",
          "sweeper:risk-session-state",
          "comp/desktop",
          "area/sessions"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84879",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(telegram): scope batch queues by routed profile",
        "text": "## Coordinated successor to #69156 This PR is the current-main, behavior-tested successor to the still-open #69156 — **not an independent competing implementation**. Its first commit is #69156 rebased onto current `main`, preserving **Shunkleburger** as that commit's author. The second commit is the minimal follow-up required by current ingress behavior: a routed `event.source.profile` is authoritative; `adapter.profile_name` is only a fallback for a secondary adapter before its wrapper can stamp the event. A direct stack on #69156's old branch was conflict-heavy because that branch is substantially behind `main`. Please review this successor as the coordinated integration path and do not merge overlapping implementations independently. ## Root cause Telegram delayed ingress paths construct keys before the profile-scoped message handler necessarily runs: - a shared adapter may already have a profile selected by `gateway.profile_routes` on `event.source`; - a secondary adapter may only know its owning profile on the adapter instance; - album queues previously used raw Telegram `media_group_id` values. Those paths could therefore fall back to `agent:main`, or merge album buffers that shared a raw Telegram ID. ## Changes - Preserve a source-routed profile before busy-session routing; use the adapter-owned profile only when the source is unstamped. - Register the owning secondary-adapter profile before handlers are installed. - Use the same precedence for delayed Telegram text, photo, image-document, generic-document, and album keys. - Namespace media-group buffers as `agent:<profile>:...:album:<media_group_id>` rather than raw `media_group_id`. - Preserve legacy `agent:main` behavior when neither source nor adapter has a profile. ## Regression coverage - shared adapter: routed source profile wins over adapter profile; - secondary adapter: profile fallback exists before its wrapper executes; - legacy no-profile behavior; - busy-session lookup; - receive-path text batching; - photo bursts, native-photo albums, image-document albums, and generic-document albums; - two profiles receiving the same raw `media_group_id` do not merge. ## Validation ```text scripts/run_tests.sh tests/gateway 620 files, 5335 passed, 0 failed, 8 skipped (Windows-only) ``` The full gateway suite passed on the exact final source tree. The subsequent final rebase added only upstream changes outside `gateway/`, `plugins/platforms/telegram/`, and `tests/gateway/`; the final two-commit branch has the same Git tree as that fully tested candidate. On the final current-main base (`715d26cdf492a412a60e0c0349c8c3d5bd6a6b04`), the focused integration matrix also passed: ```text 90 passed, 0 failed ``` Static validation passed: targeted `ruff`, `py_compile`, `git diff --check`, and `scripts/check-windows-footguns.py --all`. ## Scope boundaries - Does not duplicate #82980's clarify-bypass work. - Does not duplicate #81256's image-during-clarify work. - Does not modify configuration, persistent state, credentials, gateway service, or production runtime. ## Rollback No migration is involved. Reverting these two commits restores prior keying behavior.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84879",
        "createdAt": "2026-08-12T23:43:35Z",
        "updatedAt": "2026-08-13T13:21:36Z",
        "timestamp": "2026-08-13T13:21:36Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "platform/telegram",
          "P2",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery"
        ],
        "author": "fmercurio",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84922",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): mark truncated reply-to snippet with \"... [N more chars]\" suffix",
        "text": "Fixes #84920. ## What The `[Replying to: \"...\"]` pointer injected into inbound messages caps the quoted text at 500 chars (`gateway/run.py`). The cut was silent — the agent receiving the context could not tell a truncated quote from a short original, and (see issue) has misdiagnosed it as a truncated *outbound* delivery, then redundantly re-sent the \"missing\" remainder to the user. ## Change When the quoted text exceeds 500 chars, append an explicit `... [N more chars]` marker to the injected snippet, following the existing convention in `gateway/platforms/api_server.py`. Quotes of exactly 500 chars are unaffected (no spurious marker). Before: ``` [Replying to: \"<500 chars, ends mid-sentence>\"] ``` After: ``` [Replying to: \"<500 chars>... [137 more chars]\"] ``` ## Test plan - Added two tests in `tests/gateway/test_reply_to_injection.py`: - `test_reply_snippet_marks_truncation_when_quote_exceeds_500_chars` — 600-char quote gains the `...[100 more chars]` marker - `test_reply_snippet_unmarked_when_quote_within_500_chars` — exactly-500-char quote stays unmarked (boundary) - `pytest tests/gateway/test_reply_to_injection.py` — 4 passed (2 pre-existing + 2 new) - Adjacent suites (`test_session.py`, `test_shared_group_sender_prefix.py`, `test_telegram_audio_vs_voice.py`, `test_video_context_note.py`) — 65 passed - `ruff check` clean",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84922",
        "timestamp": "2026-08-13T11:35:15Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "P2",
          "sweeper:risk-message-delivery"
        ],
        "author": "LLQWQ",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84925",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): honor per-platform session isolation overrides",
        "text": "## Summary - honor per-platform `group_sessions_per_user` and `thread_sessions_per_user` session-key overrides - preserve nested `<platform>.extra` values while loading config - keep authorization, persisted `/resume` scoping, Discord prospective-thread attribution, and sender attribution aligned with the same effective per-platform isolation policy ## Test plan - `scripts/run_tests.sh tests/gateway/test_session.py tests/gateway/test_resume_command.py tests/gateway/test_config.py tests/gateway/test_whatsapp_text_batching.py tests/gateway/test_whatsapp_group_gating.py -q` - `ruff check gateway/config.py gateway/run.py gateway/session.py gateway/slash_commands.py tests/gateway/test_config.py tests/gateway/test_session.py tests/gateway/test_resume_command.py` - `git diff --check` Adapted from #81208 while preserving original authorship in the first two commits.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84925",
        "createdAt": "2026-08-13T00:53:25Z",
        "updatedAt": "2026-08-13T17:27:53Z",
        "timestamp": "2026-08-13T17:27:53Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "area/config",
          "P2",
          "sweeper:risk-session-state",
          "sweeper:risk-compatibility"
        ],
        "author": "Diaspar4u",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84926",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(whatsapp): observe authorized group context before response",
        "text": "## Summary - optionally retain authorized unmentioned WhatsApp group traffic in the shared transcript - invoke the agent only when trusted native WhatsApp mention metadata targets the bot - keep observed chatter context-only on later addressed turns - canonicalize Baileys device-qualified IDs so native bot mentions match reliably - bypass plain-text heuristics, wake-word regexes, slash commands, quoted replies, and free-response exemptions in observation mode - check authorization and group allowlists before persistence - retain sender attribution, cached attachment paths, and native mention metadata ## Test plan - `scripts/run_tests.sh tests/gateway/test_session.py tests/gateway/test_resume_command.py tests/gateway/test_config.py tests/gateway/test_whatsapp_group_gating.py tests/gateway/test_whatsapp_formatting.py tests/gateway/test_whatsapp_text_batching.py tests/gateway/test_whatsapp_cloud.py tests/gateway/test_75349_whatsapp_multiplex_secret_scope.py tests/gateway/test_replay_entry_fields.py tests/gateway/test_telegram_group_gating.py -q` - `ruff check gateway/config.py gateway/run.py gateway/session.py gateway/slash_commands.py gateway/platforms/whatsapp_common.py plugins/platforms/whatsapp/adapter.py tests/gateway/test_config.py tests/gateway/test_session.py tests/gateway/test_resume_command.py tests/gateway/test_whatsapp_group_gating.py` - `node --check scripts/whatsapp-bridge/bridge.js` - `node scripts/whatsapp-bridge/bridge.native.test.mjs` - `git diff --check` ## Dependency Stacked on #84925. Review and merge #84925 first; this PR will be rebased onto `main` after it merges.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84926",
        "createdAt": "2026-08-13T00:54:03Z",
        "updatedAt": "2026-08-13T17:27:52Z",
        "timestamp": "2026-08-13T17:27:52Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "comp/plugins",
          "platform/whatsapp",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery",
          "sweeper:risk-compatibility"
        ],
        "author": "Diaspar4u",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84954",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(logging): isolate multiplexed profile log files",
        "text": "## Summary A gateway using `gateway.multiplex_profiles` runs all profiles in one process. `setup_logging()` adds every profile's file handlers to one root-logger `QueueListener`, so once a second profile initializes, records are written to **all** profile log files. In a live multiplexed Telegram deployment, this placed 132 lines from the owner's session — including inbound message text — in a guest profile's `agent.log`. Warnings also leaked into the guest's `errors.log`. This PR routes each log record only to handlers belonging to the profile that emitted it. ## Root cause - File handlers are process-global and intentionally additive. - A single `QueueListener` fans every record out to all registered handlers. - The active profile is a context-local Hermes home override. - The listener formats records on another thread, where that context is no longer available. ## Fix - Tag each `LogRecord` at **creation time** with the active context-local Hermes home. The record factory runs on the emitting thread, before the queue boundary. - Add a profile-home filter to every file handler. - Route untagged startup/cron/housekeeping records to the first (primary) handler set, preserving single-profile behavior and avoiding silent log loss. - Allow multiple conjunctive handler filters, so `gateway.log` and `gui.log` keep their existing component filters alongside profile isolation. - Reset the primary-home anchor in the existing queue-handler reset helper. No prompt, message, or logger call-site changes are required. ## Verification Targeted upstream tests on a clean fork checkout: ```text 35 passed, 2 skipped in 5.01s ``` This includes existing logging tests plus new regression coverage for: - owner records do not enter guest `agent.log` or `errors.log`; - guest records do not enter owner logs; - guest warnings still reach the guest's own `errors.log`; - untagged process-level records still reach the primary logs; - single-profile behavior is unchanged; - the profile filter composes correctly with the existing gateway component filter. The new tests fail against current `main` on both cross-profile directions and pass with this change.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84954",
        "timestamp": "2026-08-13T11:17:23Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "P2",
          "sweeper:risk-session-state",
          "area/profiles"
        ],
        "author": "michaldziwisz",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:84984",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(desktop): drag to reorder profile groups in All-profiles sidebar",
        "text": "## What does this PR do? The All-profiles aggregate view (sidebar grouped by \"profile\") rendered groups in a fixed order — `default` pinned first, the rest alphabetical — with no way to reorder. This adds drag-to-reorder for profile group headers, reusing the same `ReorderableList` + grab-handle machinery already used for flat session rows and project overview rows. - Profile group headers become draggable in the All-profiles view. The lead glyph becomes a grab handle (grabber on hover), the label is grab surface, and rows inside groups stay static. - `default` stays a fixture pinned to the top (mirrors the `Home` fixture in the project overview, and the rail where default is position 0). - The order persists in the existing `hermes.desktop.profileOrder` store (`$profileOrder`), shared with the profile rail — dragging a group here reorders the rail and the ⌘N hotkeys coherently. No new storage key. - Profiles absent from the stored order alphabetise at the tail (same rule the rail uses). A drag persists the full visible named-profile list, so previously unranked profiles pin too. - Source/workspace groups and every non-profile grouped view render exactly as before — the sortable path is gated on `group.mode === 'profile'` plus a wired handler. ## Related Issue Fixes #84987 Related: #77716 (profile rail grouping request) — this PR shares the same single persisted profile order the rail uses. ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ 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 - `apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts` — new `sortProfileGroups`: default fixture first, stored order next, unranked alphabetical tail; pure, unit-tested. - `apps/desktop/src/app/chat/sidebar/sessions-section.tsx` — new `onReorderGroups` prop; the groups branch wraps named profile groups in `ReorderableList` and renders `default` outside it; `SortableWorkspaceGroup` wrapper. - `apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx` — accepts the same reorder contract as `ProjectOverviewRow` (`reorderable` / `dragging` / `dragHandleProps` / `ref` / `style`) and renders a `SidebarRowGrab` lead when sortable. - `apps/desktop/src/app/chat/sidebar/index.tsx` — sorts `profileGroups` with `sortProfileGroups(…, $profileOrder)` and persists drops via `setProfileOrder`. - `apps/desktop/src/app/chat/sidebar/projects/index.ts` — exports `sortProfileGroups`. - Tests: `workspace-groups.test.ts` (6 sorting unit tests), `sessions-section.test.tsx` (4 group-branch wiring tests), new `workspace-group.test.tsx` (4 grab-handle/grab-surface component tests). - `contributors/emails/nformenton@gmail.com` — contributor mapping for the attribution check. ## How to Test 1. Open the desktop app with more than one profile, switch the sidebar to \"All profiles\" and group by \"profile\". 2. Hover a named profile group header — the glyph turns into a grabber. Drag it; the group reorders among the named profiles and `default` stays on top. 3. Restart the app — the order persists, and the profile rail (and ⌘N hotkeys) show the same sequence. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate (no PR implements profile-group reorder; nearest open PRs #70223 and #43661 reorder session rows and touch `sessions-section.tsx`/`index.tsx` in different code paths — delta: this PR only changes the profile-mode groups branch) - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass — N/A for this desktop-renderer-only change; ran `npm run test:ui` (415 files, 3,713 tests pass), `npm run typecheck`, `npm run lint` (0 errors) - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 (vitest UI suite + tsc + eslint) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs; dnd-kit + localStorage behave the same on both platforms ## Verification - `npm run test:ui`: **415 files / 3,713 tests passed**, including the 14 new tests. - `npm run typecheck`: clean (renderer + electron + e2e configs). - `npm run lint`: 0 errors on all touched files (88 pre-existing warnings repo-wide, none in touched files).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/84984",
        "createdAt": "2026-08-13T02:37:01Z",
        "updatedAt": "2026-08-13T13:13:48Z",
        "timestamp": "2026-08-13T13:13:48Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "P3",
          "comp/desktop",
          "area/profiles"
        ],
        "author": "Nicolas-Formenton",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85009",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(relay): session-span segmentation for continuous sessions",
        "text": "## Problem Continuous gateway sessions (the normal state for a Telegram/Slack agent) keep the Relay session scope open for days or weeks. Relay export is close-driven, so: - the session root span and its attributes stay unexported until `/new` or idle-end, - any marks attached to the session scope are held with it, - a crash or redeploy loses the entire open segment — nothing exports. In-turn marks already export per-turn since the turn-parenting fix (#83517); this addresses the session-scope remainder. ## Design Opt-in segmentation, config in `config.yaml` (no new env vars): ```yaml gateway: telemetry: session_segments: on_compaction: false # rotate the session scope when the session compacts max_turns: 0 # 0 = unlimited; N = rotate after N turns per segment ``` **Both defaults OFF → scope lifecycle byte-identical to today.** Nothing changes for any existing deployment. Rotation closes the current session scope and pushes the next segment with the same `session_id` attribute plus `hermes.session.segment=N` and `hermes.session.segment_reason=compaction|max_turns` — dashboards keep grouping on `session_id`; per-session queries are unchanged. Key invariants: - **Turn-boundary only.** The scope stack is LIFO; rotation happens exclusively in `begin_turn` before the turn scope pushes. Compaction never rotates directly — it flags `rotate_pending`, consumed at the next turn. A compaction completing mid-turn cannot violate stack order. - **Nothing on the compaction critical path.** The hook in `_notify_context_engine_compression_complete` is observer-semantics: flag set under the session lock, every failure swallowed at DEBUG. - **Legacy rotating compaction handled.** When compaction rotates to a child session id, the old session's scope would orphan unexported forever; the notification closes it (bounded) so the pre-compaction segment exports. - **Bounded like every native scope op.** Rotation rides the shared scope-op executor with `_SCOPE_OP_TIMEOUT` (#83514): a wedged rotation costs one segment span, never the agent. Bookkeeping advances before the native calls so a degraded rotation cannot retry on every turn. - **Subagents:** post-rotation children parent to the new segment handle; live children under the old handle remain valid (parent-ends-before-child is legal tracing). ## Rejected scope (deliberate) - Byte-size segment accounting — span size is not observable pre-close in the SDK - Wall-clock / age-based rotation — parked; turn count and compaction are the meaningful boundaries - OTel span links between segments — shared `session_id` attribute suffices for v1 - Rotation outside turn boundaries; retroactive stitching of pre-crash segments ## Verification - 11 new tests (`tests/agent/test_relay_session_segments.py`): defaults-never-rotate contract, rotation at next begin_turn (not immediately, never mid-turn), segment metadata, max_turns cap arithmetic, zero-cap unlimited, unknown-session/disabled no-ops, rotating-compaction close, wedged-rotation bounded (<5s return), subagent handle continuity. Wedge tests unwedge + drain at teardown (per the #83514 CI lesson). - Mutation check: disabling rotation consumption → exactly the 4 rotation tests fail (4 failed, 7 passed); restore → 11/11. - Gates over `tests/agent/ tests/gateway/relay/ tests/plugins/`: all green except `tests/plugins/memory/test_hindsight_provider.py` (6 failures) — **pre-existing on unmodified origin/main** (`fe8b44dac`), reproduced identically in a clean control worktree; unrelated to this change.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85009",
        "createdAt": "2026-08-13T03:22:35Z",
        "updatedAt": "2026-08-13T17:45:39Z",
        "timestamp": "2026-08-13T17:45:39Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "P3",
          "telemetry",
          "sweeper:risk-session-state",
          "area/compression"
        ],
        "author": "victor-kyriazakos",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85011",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cron): honest fallback-chain alerts + drift-guard alert-once",
        "text": "Cron failure alerts around the fallback chain were lying in three ways, all field-reported from an enterprise fleet running ~40 jobs: 1. Every provider failure said \"Fallback chain was exhausted or unavailable\" even when no chain was configured, sending operators to debug a fallback that never ran. 2. The scheduler's own inactivity watchdog (a stuck tool call) was reported as a \"provider timeout\" — wrong system entirely. 3. A fleet-wide config change made every unpinned job re-alert on every tick: 40 jobs, one identical drift alert each, every tick, until each was hand-pinned. ## What changed - **Cherry-picked #81579** (@georgell-ceo, authorship preserved): `_fallback_chain_phrase()` distinguishes \"no chain configured\" from genuine exhaustion, and inactivity timeouts are classified before the generic timeout match. 6 tests came with it. - **Empty-chain alert names the fix**: `hermes fallback add`, or the `cron.model` + `cron.model_provider` fleet defaults. The exhausted branch stays terse — the chain is intact there, no config command applies. - **Drift-guard skips alert once per job, not per tick**: reuses the exact alert-once shape the blocked-config preflight already established — a persisted `drift_alerted` bit, a `:silent` marker variant that suppresses re-delivery, cleared on the next successful run so a future drift re-alerts. Only the drift branch consults the bit; every other failure still alerts per tick. - **Drift alert delivered untruncated**: the generic summarizer's 180-char cap was cutting the alert off before the pin command. The drift branch formats its own delivery, so the one alert the operator gets contains the actual fix. (Found by mutation-probing the delivery path during review.) ## Verification - 12 new tests across three files, all asserting delivered content through `run_one_job` + `_deliver_result`, not helper internals. - Mutation checks: gutting the chain check fails 2 tests; removing the inactivity branch fails 1; disabling the silent suppression fails the alert-once test. - Full cron suite via `scripts/run_tests.sh tests/cron/`: 47 files, exit 0. ## Not in this PR (parked as follow-ups) - `hermes cron doctor` with bulk re-snapshot/re-pin verbs (a read-only base exists in #43729). - Setup/onboarding prompt for fallback configuration — product UX call first. - No retry machinery added: retry-with-backoff across the chain already exists in the conversation loop once a chain is configured.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85011",
        "createdAt": "2026-08-13T03:27:32Z",
        "updatedAt": "2026-08-13T13:20:35Z",
        "timestamp": "2026-08-13T13:20:35Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/cron",
          "P2",
          "sweeper:risk-message-delivery"
        ],
        "author": "victor-kyriazakos",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85022",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(kanban): emit memory on_session_end deterministically for -Q goal-mode workers",
        "text": "## Problem A kanban worker's memory-provider `on_session_end` fires only when the process reaches `_run_cleanup` -> `shutdown_memory_provider`. - The non-goal `-q` worker reaches it from `_finalize_single_query`'s `finally`. - The fully-quiet `-Q` goal-mode worker relied **solely on `atexit`**. `atexit` is bypassed by the kanban `os._exit(0)` signal handler, by the exit watchdog, and by hard kills. So for `-Q` workers the provider's session-end hook never runs. ## Scope (deliberately stated narrowly) This is a **lifecycle correctness fix, not a memory-loss fix**. On our setup the configured provider's `on_session_end` performs a clean shutdown rather than an ingest, so the practical effect of the bug is that the provider is never closed cleanly on `-Q` worker exits. Providers that *do* ingest at session end would lose their final turns, which is the more general case this guards. A hook documented to run at session end should run on every exit path, not only the ones that happen to reach `atexit`. ## Fix Emit the session boundary deterministically at the worker session boundary, on both the success and failure paths. Existing `_cleanup_done` guarding makes it idempotent, and a raising provider is swallowed by `MemoryManager`, so a failing plugin cannot fail the card. Interactive behaviour is unchanged. ## Tests `tests/cli/test_worker_session_end_emission.py` — 5 tests: emission on success, on failure, exactly once, hook exceptions do not propagate, and interactive paths unchanged. Scope is minimal: 24 added lines in `cli.py` plus the test file, no deletions and no unrelated changes. --- Diagnosed, implemented and tested autonomously by the HSCC cluster (a self-hosted DGX Spark fleet running hermes-agent).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85022",
        "createdAt": "2026-08-13T03:53:25Z",
        "updatedAt": "2026-08-13T17:32:04Z",
        "timestamp": "2026-08-13T17:32:04Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/cron",
          "tool/memory",
          "P3"
        ],
        "author": "pom11",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85082",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): mark reply-to snippet truncation explicitly",
        "text": "Fixes #84920. ## The bug `gateway/run.py` cut the quoted reply text at 500 chars with no marker, so an agent receiving `[Replying to: \"...\"]` could not tell a genuinely short quote from a long one that had been clipped. The failure mode in the issue needs both halves to bite: a user replies to a long report *the agent itself delivered*, the agent sees its own text ending mid-sentence, concludes the **outbound** delivery was truncated, and re-sends the \"missing\" remainder — when the delivery was complete and only the injected quote was cut. ## The change The 500-char cap stays. The cap is reasonable; the silence is the bug. Truncation is now marked with the existing `\"...[N more chars]\"` convention from `api_server.py:5056`, so the boundary reads the same wherever an agent meets it, and the elided count makes fetching the original a decision rather than a guess. Two details worth calling out: - **The marker only appears when the text actually exceeded the cap.** A quote at or under 500 chars is byte-identical to today. Marking a complete quote would assert data loss that did not happen — the same ambiguity in reverse. - **Both render branches** (own-message and plain) read the same `reply_snippet`, so one change covers both. Test coverage asserts that explicitly rather than assuming it. The cap moves to a module constant instead of being repeated in the slice and the comparison. ## Not doing: a shared truncation helper There are only two sites and they sit in different layers, so extracting a util would widen the diff past the bug. Happy to follow up if you'd rather have one home for the convention. ## Verification `tests/gateway/test_reply_to_injection.py` — **7 passed** (2 existing, 5 new): - a 637-char quote gets `...[137 more chars]` - lengths 1 / 499 / 500 are left untouched, with no marker (parametrized boundary check) - the own-message branch carries the marker too With the fix reverted, the two marker tests fail. The three boundary tests pass either way by design — they are guards against over-marking, not the regression itself. Note this does not address #69060 (raw Markdown structure cut mid-syntax), as the issue says — it only makes the boundary explicit.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85082",
        "timestamp": "2026-08-13T11:35:27Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/gateway",
          "P2",
          "sweeper:risk-message-delivery"
        ],
        "author": "deepspace28",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85187",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): pool-route voice RPCs so STT lazy install can't block WS sends",
        "text": "## Problem Desktop \"send message\" does nothing for minutes after a fresh launch, even though the backend boots fine (`Hermes backend is ready`). Sending a message (WS `prompt.submit`) never reaches the agent. ## Root cause `voice.toggle` (status action) → `check_voice_requirements()` → STT provider auto-detect → `_try_lazy_install_stt()` → `ensure(\"stt.faster_whisper\")` → **synchronous** `subprocess.run(uv pip install ...)` with a **300s timeout**. On the desktop WebSocket this runs **inline on the WS reader thread** (`handle_ws` awaits `dispatch` before reading the next frame), so `prompt.submit` / `session.list` frames queued behind a `voice.toggle` sit unread for minutes — the UI shows the message but nothing happens. The faster-whisper install (~50MB of wheels incl. ctranslate2/onnxruntime) fails or aborts repeatedly, so every launch re-triggers the blocking install. Reproduced with a WS probe against a real `serve` backend: `voice.toggle` → `session.list` times out at 40s+; without the voice RPC both respond in 0.0s. ## Fix Route `voice.toggle` / `voice.record` / `voice.tts` to the RPC pool via `_LONG_HANDLERS` (same bug class as #21123 / #50005: anything that can stall for seconds must stay off the reader thread). Verified end-to-end: after the patch, `voice.toggle` runs in the background while `session.list` responds in 0.0s and `prompt.submit` streams immediately. ## Tests - New regression test `test_voice_handlers_are_pool_routed` (test_protocol.py) asserting the voice RPCs stay pool-routed. - Adapted existing voice handler tests to drive the handler inline via a small `_dispatch_sync` helper (preserving transport-binding semantics) since `dispatch()` now returns `None` for pool-routed methods. - Note: `test_wake_owner_is_sticky_and_routes_detection_to_first_transport` fails on clean `main` too when the full file runs (test-order dependency, pre-existing); it passes in isolation and with `-k` filtering.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85187",
        "createdAt": "2026-08-13T09:32:08Z",
        "updatedAt": "2026-08-13T17:35:36Z",
        "timestamp": "2026-08-13T17:35:36Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/tui",
          "tool/tts",
          "P1"
        ],
        "author": "hustwkr",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85212",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(skills): skip glob and directory refs in support-path extraction (#85172)",
        "text": "## What does this PR do? `hermes skills install <url>` fails with a misleading `Could not fetch '<url>' from any source` for large SKILL.md files that mention glob patterns or directory paths under allowed support dirs. Root cause: `_referenced_support_paths()` in `tools/skills_hub.py` treats any `references|templates|scripts|assets|examples/...` token (matched by `_LOCAL_LINK_RE`) as a downloadable support file. `UrlSource.fetch()` then tries to download each referenced path — glob patterns like `scripts/*` and directory references like `scripts/lib/` 404 on raw.githubusercontent.com, which makes the whole fetch return `None` and surface as \"Could not fetch\". `hermes skills inspect` works on the same URL because it only reads the SKILL.md itself. Fix: skip glob patterns (containing `*`, `?`, `[`, `]`) and directory references (ending in `/`) when extracting support paths. Real files are still fetched, and the existing path-traversal guard (`_SUSPICIOUS_LOCAL_REF_RE` → `None`) is unchanged. ## Related Issue Fixes #85172 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `tools/skills_hub.py`: `_referenced_support_paths()` now skips glob patterns and directory references. - `tests/tools/test_skills_hub.py`: new `TestReferencedSupportPaths` covering glob/dir filtering, traversal rejection, and empty input. ## How to Test 1. `hermes skills inspect https://raw.githubusercontent.com/mvanhorn/last30days-skill/main/skills/last30days/SKILL.md` → succeeds (unchanged). 2. Before fix: `hermes skills install <same URL> --yes` → `Error: Could not fetch ...` (repro from #85172). 3. After fix: install proceeds; only real referenced files (`scripts/last30days.py`, `scripts/lib/categories.py`, `references/save-html-brief.md`) are fetched, glob/dir refs are skipped. 4. Unit: `python -m pytest tests/tools/test_skills_hub.py -k ReferencedSupportPaths` (verified standalone since the repo's full test env isn't available here). ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass (no local pytest; new logic verified standalone, existing behavior unchanged) - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS (logic-level verification with the repro file from #85172) ### Documentation & Housekeeping - [x] N/A — no docs, config-keys, or architecture changes.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85212",
        "createdAt": "2026-08-13T09:59:12Z",
        "updatedAt": "2026-08-13T12:54:12Z",
        "timestamp": "2026-08-13T12:54:12Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/gateway",
          "tool/skills",
          "P2"
        ],
        "author": "SeashoreShi",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85222",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(desktop): add 'Add as context' right-click to message blocks and file explorer",
        "text": "## What does this PR do? Adds right-click \"Add as context\" to both message blocks (selected text) and file explorer rows, staging content as composer attachment chips that ride along with the next prompt. **Two surfaces, one architecture:** | Surface | Gesture | Result | |---|---|---| | Message thread (user + assistant) | Select text → right-click → \"Add as context\" | Text chip staged in composer strip, serialized as `> Referenced from earlier...` quoted block on submit | | Message thread (user + assistant) | Select text → right-click → \"Paste as text\" | Quoted text inserted directly into composer input | | File explorer (browser + review/git trees) | Right-click file/folder → \"Add as context\" | File/folder chip staged in composer strip (same code path as shift+click) | Both features converge on the existing `ComposerAttachment` system. A new `'text'` attachment kind carries selected message text; the existing `'file'`/`'folder'` kinds are reused for file explorer right-clicks. Nothing is sent until the user submits — attachments just stage in the composer strip, and multiple items can accumulate before sending. ## Related Issues - Closes #73138 (Feature Request: Quote-to-reply — right-click context menu on selected text) - Closes #69741 (Files panel — no quick-to-add-input shortcut) - Related: #73658, #70550 (prior art using direct-insertion approach; this PR uses chip-staging instead) ## Type of Change - [x] ✨ New feature ## Changes Made | File | Change | |---|---| | `apps/desktop/src/store/composer.ts` | Add `'text'` kind to `ComposerAttachment` union, `textContent`/`sourceMessageId` fields, `addComposerTextAttachment()` helper | | `apps/desktop/src/components/assistant-ui/thread/message-context-menu.tsx` | **NEW** — shared `MessageContextMenu` component (Radix ContextMenu) with \"Add as context\" and \"Paste as text\" | | `apps/desktop/src/components/assistant-ui/thread/user-message.tsx` | Wrap bubble content in `MessageContextMenu` | | `apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx` | Wrap content area in `MessageContextMenu` | | `apps/desktop/src/app/chat/composer/attachments.tsx` | Handle `'text'` kind in `AttachmentPill` with `MessageSquareText` icon | | `apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts` | Serialize text attachments as quoted blocks prepended to message text | | `apps/desktop/src/app/right-sidebar/file-actions.tsx` | Add `attachFileAsContext()` helper and \"Add as context\" menu item to `FileEntryContextMenu` | | `apps/desktop/src/store/composer.test.ts` | Tests for text attachment kind | | `apps/desktop/src/components/assistant-ui/thread/message-context-menu.test.tsx` | **NEW** — tests for context menu rendering | ## How to Test 1. Start the desktop app in dev mode: `cd apps/desktop && npm run dev` 2. Start a conversation and get a response 3. **Message text → chip:** Select text in any message → right-click → \"Add as context\" → verify a text chip appears in the composer attachment strip → type a prompt and submit → verify the selected text appears as a `> Referenced from earlier...` quoted block 4. **Message text → paste:** Select text → right-click → \"Paste as text\" → verify quoted text appears directly in the composer input 5. **File explorer:** Open the file explorer pane → right-click any file or folder → \"Add as context\" → verify a file/folder chip appears in the composer → submit → verify the `@file:` reference is in the message 6. **Multi-item staging:** Stage a text chip, a file chip, and type a prompt → submit → verify all three serialize together ## Screenshots ### 1. Add as Context → Add Text Chip Right-clicking selected text in a message and choosing \"Add as context\" stages a text chip in the composer attachment strip. On submit, the text is serialized as a `> Referenced from earlier...` quoted block. ![Add text chip](https://raw.githubusercontent.com/kumamaster-web/hermes-agent/feat/desktop-add-as-context/apps/desktop/screenshots/add-text-chip.png) ### 2. Add as Context → Paste as Text Right-clicking selected text and choosing \"Paste as text\" inserts the quoted text directly into the composer input at the cursor position. ![Paste as text](https://raw.githubusercontent.com/kumamaster-web/hermes-agent/feat/desktop-add-as-context/apps/desktop/screenshots/paste-as-text.png) ### 3. Add as Context → Add File Chip Right-clicking a file or folder in the file explorer and choosing \"Add as context\" stages a file chip in the composer attachment strip. ![Add file chip](https://raw.githubusercontent.com/kumamaster-web/hermes-agent/feat/desktop-add-as-context/apps/desktop/screenshots/add-file-chip.png) ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains only changes related to this feature - [x] I've run `npx tsc --noEmit` and all checks pass - [x] I've added tests for my changes (6 tests, all passing) - [x] I've tested on my platform: Windows 10 ### Documentation & Housekeeping - [x] N/A — no config keys, tool schemas, or architecture changes - [x] N/A — cross-platform: TypeScript/React only, no native modules or shell commands",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85222",
        "createdAt": "2026-08-13T10:27:25Z",
        "updatedAt": "2026-08-13T13:40:19Z",
        "timestamp": "2026-08-13T13:40:19Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "P3",
          "comp/desktop"
        ],
        "author": "kumamaster-web",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85227",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(agent): add model-agnostic session compiler and native lifecycle",
        "text": "## Problem Hermes persisted visible transcripts, but request construction and durable session ownership were split across the normal agent loop, Codex app-server, clients, and provider continuation state. That made history correctness depend on the selected transport and left model/provider switching, restart recovery, idempotency, and large tool envelopes without one enforceable contract. ## Architecture This PR makes Hermes state the authoritative session boundary and converges model invocation on one provider-neutral compilation path: - defines `TurnCommand`, `SessionSnapshot`, canonical events, `ModelCapabilities`, `CompiledTurn`, typed compilation failures, `ModelAdapter`, and disposable `ProviderContinuation` - routes the normal loop and Codex app-server through the same context compiler; fallback model/provider changes re-resolve capabilities and recompile - accounts once for instructions, history, current input, tools, fixed envelope overhead, and output reserve - retains full tool-call/result groups and returns typed no-fit before provider invocation instead of silently dropping prior history - adds versioned native session RPCs for create/open/snapshot/append-turn/cancel with exact session identity and no client-selected profile database ## Authoritative lifecycle and recovery - appends the user event before inference using stable turn and idempotency IDs plus expected session revision - records accepted/running/completed/failed/canceled lifecycle and expiring execution leases - replays duplicate live or terminal delivery without duplicating the user event - reclaims an expired running turn as a new attempt while preserving the original event identity - fences external tool effects by `(session_id, turn_id, tool_call_id)` and payload hash; completed results replay, retry-safe abandoned tools may retry, and ambiguous effectful calls become explicit `uncertain` rather than running twice - emits terminal `session.turn.updated` events with the canonical revision used by the durable receipt ## Append-only journal State schema 28 adds monotonic canonical/event revisions and projection transitions. Ordinary rewrite, compaction, rewind, and sidecar enrichment preserve source message rows; snapshots rebuild from recorded projection lineage instead of mutable `active` flags. Legacy rows use their existing durable row IDs as the migration floor, so startup does not copy entire databases. Memory-prefetch/API sidecars are written back to the exact accepted user event. Compaction checkpoints expose their source event IDs, and native turns use in-place compression so session identity remains stable. `ScopedSessionDB` is explicitly documented as a migration bridge with a deletion condition: native stateful tools must consume the canonical Hermes session snapshot/authorization API and stop inspecting SQLite internals. ## Verification After rebasing on current `origin/main`: - `scripts/run_tests.sh` across compiler, Codex integration, turn context, tool journal, session journal, state, native RPC, failure retention, in-place compaction, run-agent, and full gateway coverage: **1,140 passed, 2 skipped, 0 failed**, with no flaky retry - full `tests/test_tui_gateway_server.py`: **551 passed** - Ruff across the affected runtime/test surfaces: passed (two pre-existing invalid-`noqa` warnings) - `git diff --check`: passed Synthetic coverage includes a 165-tool Office Manager-scale catalog, dependent second-turn history, model/provider switching over one Hermes session, Hebrew UTF-8 accounting, atomic tool-call/result retention, typed mandatory-envelope no-fit with no provider call, journal reconstruction after projection-flag corruption, duplicate delivery, expired-turn restart recovery, side-effect fencing, and exact sidecar event persistence. ## Coordination and remaining integration This remains draft while the native Desktop client, addon canonical reader migration, cross-process/cross-model E2E, review, and CI gates are completed. Provider continuation remains optional and is not a correctness dependency. Compatible finalization/event-projection work from #61751 and #63798 remains reusable. This design does not adopt provider-thread persistence from #41905 as session truth and does not replace focused compaction work in #73715. No release, deployment, tag, or installation is part of this PR. Related: #73503, #26035, #41904",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85227",
        "createdAt": "2026-08-13T10:32:32Z",
        "updatedAt": "2026-08-13T13:29:42Z",
        "timestamp": "2026-08-13T13:29:42Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "P3",
          "needs-decision",
          "sweeper:risk-session-state"
        ],
        "author": "leonagente",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85228",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(delegation): allow safe per-task model selection",
        "text": "## What does this PR do? Adds opt-in, per-call/per-task `model` and `reasoning_effort` selection to `delegate_task`. Hermes already supports a global `delegation.model`, which is a good default for inexpensive workers, but one quality-sensitive task in a batch may need stronger compute. Today that requires mutating global config (racy for parallel work) or launching a separate Hermes process. This change keeps the existing behavior by default. An operator must enable selection and provide exact allowlists. The model can select compute already approved by the operator, but cannot select a provider, endpoint, credential, wire protocol, request override, or ACP transport. A related historical experiment existed at `7ba55aa793`, but this implementation is written against the current delegation architecture and uses exact allowlists instead of fuzzy model resolution. ## Related Issue No issue found after searching open and closed PRs/issues for per-task delegation model selection. ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [x] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - Add disabled-by-default delegation config: - `allow_model_selection` - `allowed_models` - `allowed_reasoning_efforts` - Dynamically expose `model` and/or `reasoning_effort` only when their operator allowlist is non-empty. - Support both the single-goal form and independent selections on `tasks[]` items. - Validate the entire batch before creating transcripts or child agents, so a bad later task cannot partially spawn work. - Reject malformed, empty, and non-allowlisted values fail-closed. - Preserve the configured provider, base URL, API key, API mode, request overrides, and ACP command/args. - Pass task-local reasoning config directly to child construction without mutating global config. - Document configuration and usage in the feature guide, configuration reference, and `cli-config.yaml.example`. Example: ```yaml delegation: model: gpt-5.6-luna reasoning_effort: xhigh allow_model_selection: true allowed_models: [gpt-5.6-luna, gpt-5.6-sol] allowed_reasoning_efforts: [high, xhigh] ``` ## How to Test ```bash pytest \\ tests/tools/test_delegate.py \\ tests/tools/test_delegate_output_schema.py \\ tests/tools/test_delegate_safe_model_selection.py \\ -q ruff check \\ hermes_cli/config_defaults.py \\ run_agent.py \\ tools/delegate_tool.py \\ tests/tools/test_delegate_safe_model_selection.py ``` Local result: **120 passed**; Ruff lint passed. Coverage includes: - feature disabled/default config; - dynamic schema and empty-allowlist behavior; - exact model/effort allowlists; - malformed values; - provider/transport immutability; - single-goal multiline context preservation; - independent Luna/Sol parallel child construction; - omitted fields preserving delegation defaults; - invalid later batch item spawning zero children. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit message follows Conventional Commits - [x] I searched for existing PRs and issues - [x] My PR contains only changes related to this feature - [ ] I've run the entire repository test suite - [x] I've added focused tests for my changes - [x] I've tested on Linux (Debian 13, Python 3.13) ### Documentation & Housekeeping - [x] I've updated relevant documentation - [x] I've updated `cli-config.yaml.example` - [x] `CONTRIBUTING.md` / `AGENTS.md` changes are N/A - [x] I've considered cross-platform impact; the change is platform-neutral Python/schema/config logic - [x] I've updated the dynamic tool schema ## Screenshots / Logs ```text 120 passed in 3.84s All checks passed! ```",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85228",
        "timestamp": "2026-08-13T12:05:02Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/cli",
          "tool/delegate",
          "area/config",
          "P3",
          "needs-decision",
          "sweeper:risk-compatibility"
        ],
        "author": "OldKrab",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85236",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(desktop): complete and polish German locale",
        "text": "## What does this PR do? Completes and polishes the German (`de`) locale for Hermes Desktop. This draft builds on #51762 and contains its original locale commit plus two focused follow-up commits. The original locale covered the translation catalog, but current desktop components still contained renderer-level English strings and newer catalog fields could fall back to English in German. The result keeps English-only product terms where they are established in the UI while making the visible German desktop surface consistent and explicit. ## Related Issue Related to #51217 and #51762. This is intentionally a draft follow-up/replacement for #51762, not an unrelated translation implementation. ## Type of Change - [x] ✨ New feature (non-breaking change that adds functionality) - [x] 🐛 Bug fix (non-breaking change that fixes untranslated UI) - [x] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) ## Changes Made - Retains the German locale, language aliases, catalog registration, tests, and i18n documentation from #51762. - Completes and polishes the German catalog in `apps/desktop/src/i18n/de.ts`. - Routes previously hard-coded UI copy through i18n in: - composer intro and “Add context” - sidebar grouping, ordering, display, status, PR, profile, project, archive, and read-state controls - profile rail accessibility label - Appearance theme search, no-result state, built-in descriptions, and Marketplace source label - Mixture of Agents preset, reference-model, and aggregator controls - Adds the corresponding typed English source keys in `en.ts` and `types.ts`. - Uses `defineLocale()` for the Chinese catalog so new translation-contract keys fall back safely instead of requiring unrelated Chinese copy in this PR. - Keeps the existing randomized English intro; non-English locales use a concise localized intro body. ## How to Test 1. Run `cd apps/desktop && npm run typecheck`. 2. Run `npm run build`, `npm run test:ui`, and `npm run lint`. 3. Start the local mock desktop, select **Deutsch**, then verify Home, Sidebar filters, Settings → Appearance, and Settings → Models → Mixture of Agents. ## Verification - `npm run typecheck` — passed - `npm run build` — passed - `npm run test:ui` — **421 test files / 3,777 tests passed** - `npm run lint` — **0 errors**; 100 existing warnings in untouched files - Prettier check — passed - `git diff --check` — passed - German catalog audit — **0 implicit English fallbacks** - Visual smoke test on macOS with the local mock backend — German labels rendered without observed clipping or overflow ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched existing PRs; this draft explicitly builds on #51762 rather than presenting an unrelated duplicate - [x] My PR contains only changes related to the German desktop locale - [ ] `pytest tests/ -q` — N/A: Desktop TypeScript/i18n-only change - [x] Locale normalization/resolution tests are included - [x] Tested on macOS with the local mock desktop backend ### Documentation & Housekeeping - [x] Desktop i18n documentation is updated - [x] `cli-config.yaml.example` — N/A: no config keys changed - [x] `CONTRIBUTING.md` / `AGENTS.md` — N/A: no workflow or architecture change - [x] Cross-platform impact considered — string/catalog-only renderer changes - [x] Tool descriptions/schemas — N/A: no tool behavior changed ## Screenshots / Logs The validation counts and visual-smoke coverage are documented above.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85236",
        "timestamp": "2026-08-13T12:14:58Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "P3",
          "comp/desktop",
          "area/i18n"
        ],
        "author": "kevin-hillman",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85239",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(relay): bound ensure_session's interpreter-shutdown fallback",
        "text": "## Summary `RelayRuntime.ensure_session()`'s own `except RuntimeError:` branch — taken when the shared `_scope_op_executor()` refuses new futures during interpreter shutdown — still ran the native `scope.push` synchronously and unbounded on the calling thread: ```python except RuntimeError: # Interpreter shutdown: executor refuses new futures; # push synchronously (no agent turn waits at exit). session.handle = context.run( self.relay.scope.push, ... ) ``` This window's two prior relay hardening commits fixed exactly this defect class — a wedged native call on the shutdown lane blocking process exit forever — for the module's other two shutdown-fallback lanes: - `run_in_session()`'s own `except RuntimeError:` branch (bound via `_run_bounded_on_exit_thread`) - `close_session()`'s subscriber-flush `except RuntimeError:` branch (same helper) `ensure_session()`'s own fallback was the one remaining lane still using the old, unbounded synchronous pattern that the second of those two commits explicitly called out as the bug class to eliminate (\"The executor-refused (interpreter shutdown) fallback ran the native call UNBOUNDED on the calling thread — a wedged pipeline would block process exit forever\"). ## Fix Route `ensure_session()`'s shutdown fallback through the same `_run_bounded_on_exit_thread(fn, _SCOPE_OP_TIMEOUT)` helper the two sibling lanes already use — no new mechanism, just closing the third instance of the pattern. ## Testing - Added `TestBoundedShutdownFallbackLane` to `tests/agent/test_relay_runtime_bounded_scope_ops.py`, mirroring the file's existing wedge-test conventions: - `test_ensure_session_returns_when_executor_refuses_and_push_wedges`: simulates \"executor refuses new futures\" (a fake executor whose `.submit()` raises `RuntimeError`) combined with a wedged native `scope.push` (blocks on an `Event` that's never set). Asserts `ensure_session()` returns within the bound instead of hanging, and that the wedge surfaces as a clean `TimeoutError` (matching the sibling lanes' contract) rather than hanging or silently succeeding. - `test_ensure_session_shutdown_fallback_result_propagates`: healthy-path check — a non-wedged push on the shutdown-fallback lane still sets `session.handle` correctly. - Extended the shared `_FakeScopeModule`/`_FakeRelay` fixtures with `wedge_push` support (mirroring the existing `wedge_pop`/`wedge_flush` params), and extended the autouse teardown fixture to release the new wedge type too, consistent with the file's existing anti-CI-hang discipline. - **Mutation-verified**: reverted the production fix (kept the new tests) and confirmed `test_ensure_session_returns_when_executor_refuses_and_push_wedges` genuinely fails (the call hangs past the bounded-thread join timeout) against the pre-fix code. - `tests/agent/test_relay_runtime_bounded_scope_ops.py`: 8/8 passed. - Broader neighbor sweep — every test file that imports `agent.relay_runtime` (`test_relay_tools.py`, `test_auxiliary_relay.py`, `test_relay_llm.py`, `test_nemo_relay_mark_turn_parenting.py`, `test_relay_nested_execution.py`, `test_nemo_relay_plugin.py`): 41/41 passed. - `ruff check` clean on both touched files. ## Notes for reviewers - Open PR #85009 (\"session-span segmentation for continuous sessions\") also touches `agent/relay_runtime.py`, adding a new `rotate_session_scope()` method immediately after `ensure_session()`. It's a textual-proximity-only overlap, not a semantic conflict: that PR doesn't touch `ensure_session()`'s existing body (its diff hunk starts right after `ensure_session` returns), and this fix only touches the internal `except RuntimeError:` branch inside `ensure_session()`.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85239",
        "timestamp": "2026-08-13T11:12:08Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P3",
          "telemetry",
          "sweeper:risk-session-state"
        ],
        "author": "pierrenode",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85240",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix: honor max turns in ACP sessions",
        "text": "ACP currently loads config.yaml but omits agent.max_turns when constructing AIAgent, leaving ACP/Buzz sessions capped at the constructor default of 90. This passes a validated positive agent.max_turns as max_iterations while preserving the constructor default for missing or invalid values. Verification: - python -m pytest tests/acp/test_session.py -o \"addopts=\" -q (24 passed) - python -m pytest tests/acp -o \"addopts=\" -q (135 passed, 2 pre-existing AsyncMock warnings) - python -m compileall -q acp_adapter/session.py tests/acp/test_session.py - git diff --check",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85240",
        "timestamp": "2026-08-13T11:13:17Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/acp",
          "area/config",
          "P4"
        ],
        "author": "s-c-h-a-f-e-r",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85241",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): route integrated terminals by active profile",
        "text": "## Summary Fix Hermes Desktop's integrated terminal routing when the window's primary profile is local but the selected profile uses profile-scoped SSH. Before this change, chat, files, and sessions followed the selected profile's backend, but the integrated terminal resolved only the window's primary connection. A Windows Desktop configured as: ```text All profiles -> Local venture -> SSH -> Mac Mini ``` therefore produced a split backend: the `venture` chat ran on the Mac Mini while its integrated terminal opened on Windows. Configuring `All profiles -> SSH` happened to work because the primary connection was then SSH, which hid the profile-routing defect. This PR makes user terminal tabs profile-owned and routes each PTY through that profile's resolved SSH scope. ## Related Issue No existing open or closed issue/PR matched after searching the repository for combinations of `integrated terminal`, `profile-scoped SSH`, `active profile`, and `Desktop SSH terminal`. The report was reproduced from a real Windows Desktop -> macOS SSH setup while configuring separate local and remote profiles. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Refactoring (no functional changes) - [ ] Performance improvement - [x] Test coverage improvement ## Changes Made ### Root cause The renderer's terminal-start IPC payload carried only `cwd`, `cols`, and `rows`. Electron therefore called `activeSshTerminalTarget()` without the profile owning the terminal. The resolver fell back to `primaryProfileKey()`, so a local primary profile always started a local PTY even if the active profile was connected through profile-scoped SSH. ### Implementation - Add a `profile` field to user terminal-tab state. - Snapshot the active gateway profile when a terminal tab is created. - Persist that binding with the tab so app restart doesn't silently move a shell to another machine. - Pass the bound profile through `TerminalInstance` -> `useTerminalSession` -> preload IPC -> Electron. - Validate renderer-supplied profile names in Electron and retain the primary-profile fallback for older callers. - Resolve terminal SSH precedence in `connection-config`: 1. profile-scoped SSH, 2. URL/cloud/environment remote with no interactive SSH shell, 3. inherited app-global SSH, 4. local PTY. - If a configured profile SSH backend is still starting, join `ensureBackend(profile)` before spawning the PTY rather than incorrectly falling back to a local shell. - Keep existing terminal tabs alive when the user changes profiles. Opening/revealing the pane selects an existing user tab for the active profile or creates one; it never teleports a live shell between machines. ### Backward compatibility - Persisted terminal entries from older Desktop builds have no `profile` field. They remain readable and adopt the profile that first revives them after upgrade. - Agent-mirror terminal tabs stay runtime-only and are not assigned a user-profile binding. - URL/cloud profile routes retain the existing local integrated-terminal fallback because they do not expose an interactive SSH transport. - Invalid or missing IPC profile values retain the existing primary-profile fallback. ## Testing ### Desktop tests — pass - [x] `cd apps/desktop && npm run typecheck` - renderer, Electron, and E2E TypeScript projects pass - [x] `cd apps/desktop && npm run test:ui` - 421 files passed - 3,786 tests passed - [x] `cd apps/desktop && npm run test:desktop:platforms` - 86 files passed, 1 skipped - 1,058 tests passed, 2 skipped - [x] Focused renderer regressions - terminal creation snapshots the active profile - profile ownership survives persistence/reload - legacy tabs adopt a profile once - `ensureTerminal()` selects/creates a same-profile tab instead of reusing another machine's tab - cwd-driven selection does not cross profile boundaries - the persistent terminal follows profile switches without killing prior shells - [x] Focused Electron regressions - local primary + profile SSH resolves the selected profile's SSH scope - app-global SSH is inherited only when no profile override shadows it - URL/cloud/environment remotes do not incorrectly fall through to global SSH - terminal startup joins an in-flight selected-profile backend - unavailable configured SSH throws instead of starting a local PTY - [x] `cd apps/desktop && npm run build` - Vite renderer, Electron main/preload bundles, and native dependency staging pass - [x] `cd apps/desktop && npm run lint` - 0 errors - 88 existing repository warnings; this patch adds none - [x] `git diff --check` ### Required repository Python suite - [x] `HERMES_PYTHON=/path/to/current-dev-venv/bin/python HERMES_TEST_WORKERS=8 scripts/run_tests.sh -q` — completed; no branch-specific failures The shell's default Python 3.14 is outside this project’s supported range and did not have pytest installed. Following `CONTRIBUTING.md`, a clean Python 3.11 virtualenv was created outside the worktree and installed from this checkout with: ```bash uv venv /path/to/current-dev-venv --python 3.11 uv pip install --python /path/to/current-dev-venv/bin/python -e \".[all,dev]\" ``` The repository's canonical `scripts/run_tests.sh` runner was used rather than direct `pytest`; it runs each file in a fresh subprocess, scrubs credentials, and matches CI behavior. Result on this branch: - 2,825 isolated test files executed - 14 files failed / 25 tests failed - no failing file is under `apps/desktop` - this patch changes no Python production or test file Every failing file was then rerun under the same clean environment against a pristine worktree at current `origin/main`. All 14 files reproduced with the same per-file failure counts (25 failures total): - `tests/gateway/test_api_server.py` — 1 - `tests/gateway/test_readiness.py` — 1 - `tests/gateway/test_shutdown_forensics.py` — 1 - `tests/gateway/test_systemd_notify.py` — 1 - `tests/hermes_cli/test_service_manager.py` — 1 - `tests/plugins/memory/test_hindsight_provider.py` — 6 - `tests/test_hermes_state.py` — 1 - `tests/tools/test_approval.py` — 1 - `tests/tools/test_computer_use.py` — 1 - `tests/tools/test_execution_flag_detection.py` — 3 - `tests/tools/test_file_tools.py` — 2 - `tests/tools/test_transcription_tools.py` — 2 - `tests/tools/test_voice_mode.py` — 3 - `tests/tools/test_wake_word.py` — 1 One deterministic example: `test_health_detailed_returns_ok` hard-codes `updated_at = 2026-04-14T00:00:00Z`; the current August runtime correctly treats it as stale and returns `degraded`, while the test expects `ok`. The exact assertion reproduces on pristine `origin/main`. Conclusion: the canonical Python suite has existing baseline failures in this environment, but this branch introduces none. ## Manual Verification The code path reproducing the report is now locked by regression tests. The expected manual matrix is: | Primary route | Selected profile route | Expected integrated terminal | |---|---|---| | Local Windows | `venture` -> SSH Mac | Mac SSH shell | | Local Windows | `default` -> Local | Windows local shell | | Global SSH Mac | Profile with no override | Mac SSH shell | | Global SSH Mac | Profile URL/cloud override | Existing local fallback | A terminal tab remains bound to the backend where it was created after switching profiles. Reopening the terminal pane on another profile selects/creates that profile's terminal without closing prior tabs. ## Cross-Platform Compatibility - [ ] Manually verified on Linux - [x] Verified on macOS host with platform-neutral and simulated cross-platform tests - [ ] Manually verified on Windows - [x] No platform-specific regressions introduced The routing decision is platform-neutral. Remote PTY creation continues to use the existing platform-specific paths: - Windows Desktop host: `%SystemRoot%\\\\System32\\\\OpenSSH\\\\ssh.exe` - macOS/Linux Desktop host: `ssh` - Windows SSH target: existing PowerShell interactive command builder - POSIX SSH target: existing login-shell command builder - local terminals: existing per-platform shell selection No shell command construction or credential handling was changed. ## Checklist - [x] My code follows the project's code style - [x] I have performed a self-review of my own code - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing Desktop tests pass locally with my changes - [ ] All repository tests pass locally with my changes — the canonical suite has 25 baseline failures; all reproduce on pristine current `origin/main` with identical per-file counts - [x] I have checked for duplicate pull requests - [x] I have checked for duplicate issues - [x] My changes generate no new warnings - [x] I have rebased onto the latest `main` (`fa83af3f9a42790730b8966ff67e7d9fb627899f`) ## Screenshots / Logs No visual UI change. This is backend-routing and terminal-tab ownership behavior. ## Additional Context The bug is easiest to miss because app-global SSH works. It only appears when Desktop uses a local primary connection and a named profile has an SSH override—the exact arrangement needed to keep Windows-local diagnostics available while using macOS profiles through the same Desktop UI. The key invariant introduced here is: > A user terminal tab belongs to the gateway profile/backend that created it. Profile switching may select another tab, but must not silently move a live shell between hosts.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85241",
        "timestamp": "2026-08-13T11:11:58Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "backend/ssh",
          "P2",
          "sweeper:risk-compatibility",
          "comp/desktop",
          "area/profiles"
        ],
        "author": "addelh",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85242",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): page remote profile session reads",
        "text": "## What does this PR do? Prevents Hermes Desktop sessions for remote-gateway profiles from disappearing after a profile switch when the sidebar requests a logical window larger than the remote `/api/sessions` endpoint allows. Desktop's aggregate profile endpoint accepts up to 500 rows, and active sidebar filters raise the requested window to 300. The Electron remote-profile splice previously forwarded that `limit=300` directly to `/api/sessions`, whose intentional per-database cap is 100, producing a 422 and leaving the optimistic session row without an authoritative refresh. This PR keeps the backend cap intact and pages remote reads in chunks of at most 100. It preserves the requested logical limit/offset, query filters, total metadata, and `/api/sessions` pinned-row backfill semantics while deduplicating pins repeated across pages. ## Related Issue Fixes #85237 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `apps/desktop/electron/profile-session-routing.ts` - add bounded pagination for remote-profile `/api/sessions` reads - preserve logical window metadata and pinned-row ordering/deduplication - treat malformed totals, including fractional row counts, as unknown rather than generating invalid fractional page limits - retain the single-request path for windows at or below the API cap - `apps/desktop/electron/main.ts` - route both specific remote-profile reads and aggregate remote-profile splices through the bounded helper - `apps/desktop/electron/profile-session-routing.test.ts` - reproduce the 300-row/422 boundary - cover nonzero offsets, total-based early termination, pinned backfill deduplication, query preservation, and the small-request fast path ## How to Test 1. Configure one local Desktop profile and one profile with a remote gateway override. 2. On the remote profile, activate a sidebar filter that expands the session window, send a message, switch profiles, then switch back. 3. Confirm the session remains visible and the remote requests use page limits `<= 100` rather than one rejected `limit=300` request. 4. Run: ```bash npm --workspace apps/desktop run test:desktop:platforms npm --workspace apps/desktop run typecheck npm --workspace apps/desktop run build ``` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: CachyOS Linux 7.1.8 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — N/A; no user-facing behavior or configuration changes - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — pure Electron request routing; no platform-specific APIs - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A ## Screenshots / Logs Regression before the fix: ```text GET /api/sessions?...&limit=300... 422 Unprocessable Entity limit: Input should be less than or equal to 100 ``` Verified after the fix: ```text Test Files 86 passed | 1 skipped (87) Tests 1060 passed | 2 skipped (1062) ``` Additional verification: - focused regression/integration slice: 37 passed - Electron typecheck: passed - ESLint on changed files: passed - production Desktop build + `assert-dist-built`: passed - mutation check: changing the remote page cap from 100 to 300 makes both oversized-window tests fail The full repository Python suite was started but is currently not a usable signal in this local environment: it timed out at 19% after 10 minutes with many unrelated baseline/plugin logging failures. This PR changes only Desktop TypeScript.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85242",
        "timestamp": "2026-08-13T11:35:11Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "P3",
          "sweeper:risk-session-state",
          "comp/desktop",
          "area/profiles"
        ],
        "author": "frizikk",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85243",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix: hide Windows console-flash on 3 new/untreated subprocess spawn sites",
        "text": "## Summary This repo has an established, well-tested convention (`hermes_cli/_subprocess_compat.py::windows_hide_flags()`, `tests/test_windows_subprocess_no_window_flags.py`) for hiding the console window a short-lived console subprocess would otherwise flash on Windows. Three spawn sites don't follow it: 1. **`hermes_cli/session_lost_and_found.py`** (brand-new file, last-resort page-level session-DB salvage): `_cli_supports_recover()`'s capability probe (`subprocess.run`) and `run_cli_lost_and_found_recover()`'s dump/load `Popen` pair. This feature (`hermes sessions recover --allow-partial`) is meant to work cross-platform — the `sqlite3` CLI it shells out to ships for Windows too — and has no platform guard restricting it to POSIX. 2. **`tools/subagent_worktree.py`** (brand-new file, opt-in per-subagent git-worktree isolation): `_run_git()`, the single chokepoint all 7 call sites (`rev-parse --show-toplevel`, `rev-parse HEAD`, `worktree add`, `rev-list --count`, `status --porcelain`, `worktree remove`, `branch -D`) go through. Fires once per delegated subagent when `delegation.worktree_isolation` is enabled, so the flash frequency here is higher than the other two sites. 3. **`hermes_cli/plugins_cmd.py`**: 6 git `subprocess.run` sites (clone, fetch `--depth 1`, `checkout --detach`, `remote set-url` credential-scrub, HEAD rev-parse, and the shared `_run_plugin_git` helper used by plugin-pack update flows) — otherwise carefully hardened (`noninteractive_git_env()`, `stdin=DEVNULL`, credential-scrubbed error text, timeouts) but missing this one flag. ## Fix Add `creationflags=windows_hide_flags()` to all 9 spawn sites across the 3 files, matching the exact pattern already used throughout `tools/browser_tool.py`, `tools/env_probe.py`, `tools/lazy_deps.py`, etc. `windows_hide_flags()` returns `0` on non-Windows, so this is a no-op there. ## Testing - Added 4 new tests to `tests/test_windows_subprocess_no_window_flags.py` (the repo's existing, growing audit suite for exactly this bug class), following its established `monkeypatch.setattr(<module>, \"windows_hide_flags\", lambda: _CREATE_NO_WINDOW)` + fake `subprocess.run`/`Popen` + assert-on-`creationflags` pattern: - `test_session_lost_and_found_recover_probe_hides_console_window` - `test_session_lost_and_found_recover_run_hides_console_window` (covers both `Popen` calls in the dump/load pipeline) - `test_subagent_worktree_run_git_hides_console_window` - `test_plugins_cmd_git_helpers_hide_console_window` (spot-checks the two standalone chokepoints, `_git_head_revision` and `_run_plugin_git`) - **Mutation-verified**: reverted the 3 production files (kept the new tests) and confirmed all 4 new tests fail against pre-fix code — the module doesn't even expose `windows_hide_flags` to monkeypatch before the fix, since it isn't imported yet. - `tests/test_windows_subprocess_no_window_flags.py`: 11 passed, 3 skipped (unrelated `windows_only`-marked tests). - Broader neighbor sweep — every existing test file for the 3 touched modules (`test_session_recovery_lost_and_found.py`, `test_session_recovery.py`, `test_subagent_worktree.py`, `test_plugins_cmd.py`, `test_plugins_cmd_category_discovery.py`, `test_plugins_cmd_list.py`, `test_plugins_cmd_enable_disable_nested.py`): 95/95 passed. - `ruff check` clean on all 4 touched files. No behavior change on non-Windows or to stdio/timeout/env handling on any platform.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85243",
        "timestamp": "2026-08-13T11:21:05Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P2",
          "sweeper:risk-platform-windows",
          "platform/windows"
        ],
        "author": "pierrenode",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85245",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(plugins): route platform_actions through profile-aware adapter resolution",
        "text": "## Summary `PlatformActions._resolve_adapter()` (`hermes_cli/platform_actions.py`, the `ctx.platform_actions` facade landed by #84989) unconditionally reads `runner.adapters` — the **default profile's** adapter registry: ```python adapter = getattr(runner, \"adapters\", {}).get(platform_enum) ``` Every other adapter-resolution path in this codebase is careful about multiplex/Team-Gateway deployments: `GatewayAuthorizationMixin._authorization_adapter()` (`gateway/authz_mixin.py`) — used both for authorization checks and by the plugin message-injection dispatch path — resolves a secondary profile's adapters from `runner._profile_adapters[profile]`, and explicitly **fails closed** when a stamped secondary profile has no registry entry, rather than falling back to the default profile's adapter of the same platform: > Fail closed: a stamped secondary profile with no registry entry (e.g. its adapter failed to connect) must NOT fall back to the default profile's adapter — that sends replies out the wrong bot. `ctx.platform_actions` had none of that. In a Team-Gateway deployment where the default profile and a secondary profile both run the same platform (e.g. both run Discord), a plugin scoped to the secondary profile calling `add_reaction`/`set_thread_title` would silently act through the **default profile's** adapter/bot identity instead of its own. If the default profile doesn't run that platform at all, the feature is simply broken (`adapter_not_registered`) for every non-default profile. ## Fix Route `_resolve_adapter()` through the same `_authorization_adapter(platform, profile)` lookup the rest of the codebase uses, resolving the calling profile via `hermes_cli.profiles.get_active_profile_name()` (which reflects the per-task `HERMES_HOME` override that multiplex profiles already propagate via `set_hermes_home_override`/`_profile_runtime_scope`). Falls back to the old bare `runner.adapters` lookup only when the runner predates `_authorization_adapter` (defensive; not expected on any real `GatewayRunner` instance, which includes `GatewayAuthorizationMixin`). ## Testing - Added `TestMultiplexProfileRouting` to `tests/hermes_cli/test_platform_actions.py`, with a `_MultiplexRunner` test double that mirrors `_authorization_adapter`'s real fail-closed contract: - `test_secondary_profile_routes_to_its_own_adapter_not_default`: a plugin scoped to a secondary profile acts through that profile's adapter, not the default profile's. - `test_secondary_profile_with_no_registry_entry_fails_closed`: a stamped secondary profile with no adapter registered returns `adapter_not_registered`, never silently using the default profile's adapter. - `test_default_profile_still_routes_to_default_adapter` / `test_active_profile_matching_secondary_name_uses_default_adapters`: healthy-path regression guards — default-profile and \"this IS the process's active profile\" routing is unchanged. - **Mutation-verified**: reverted the production fix (kept the new tests) and confirmed the two bug-proving tests fail against pre-fix code — the default-profile adapter gets awaited (wrong bot) instead of failing closed. - `tests/hermes_cli/test_platform_actions.py`: 29/29 passed (25 pre-existing unmodified + 4 new) — the existing tests build their fake runner as a bare `SimpleNamespace(adapters=...)` with no `_authorization_adapter`, so they correctly exercise the defensive fallback branch unchanged. - Broader neighbor sweep (`test_plugin_capabilities.py`, `test_relay_upstream_authz.py`, `test_multiplex_profile_authz.py`): 47/47 passed. - `ruff check` clean on both touched files.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85245",
        "timestamp": "2026-08-13T11:21:01Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/plugins",
          "P3",
          "area/profiles"
        ],
        "author": "pierrenode",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85246",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(reasoning): hide unsupported effort choices in model pickers",
        "text": "## What does this PR do? Model pickers in Desktop and Dashboard currently offer the full reasoning-effort scale for every reasoning model, including levels the selected model cannot represent. This change preserves exact models.dev reasoning controls in the shared model-options payload and filters both pickers, preventing users from saving unsupported choices while keeping the existing full-list fallback for models without metadata. ### Symptom Selecting a model with a restricted reasoning scale, such as a model that supports only low, high, and max, still shows minimal, medium, xhigh, and ultra. The Off choice also appears even when the model exposes effort selection without toggle support. ### Impact Desktop and Dashboard users can select reasoning values that the active model does not support. Those choices are then folded or ignored downstream, so the picker advertises control that the model cannot honor. ### Bug Cause **Trigger:** `hermes_cli/inventory.py:404` / `_apply_capabilities`, followed by the Desktop and Dashboard reasoning pickers. **Causal chain:** 1. models.dev supplies exact `reasoning_options` for a model. 2. The shared model-options payload reduces that metadata to one `reasoning` boolean. 3. Both UI surfaces render their static canonical option lists and expose unsupported values. **Why it is wrong:** The backend discards the model-specific effort and toggle capabilities before either UI can use them. **Working sibling / contrast:** Models without known metadata safely use the existing complete effort list. The new filtering applies only when exact capability metadata is available. **Ruled out:** Provider request serialization is not the source of the picker mismatch. The unsupported choices are already visible before a request is sent, and tests reproduce the defect entirely in metadata parsing, payload shaping, and UI option filtering. ### Fix Parse models.dev effort and toggle options into shared model capabilities, expose them through `/api/model/options`, and consume the same metadata in Desktop and Dashboard. The UI helpers preserve canonical ordering, omit Off when toggling is unsupported, select a supported fallback when a saved/default effort is invalid, and retain the full scale for unknown or older metadata. ## Related Issue Closes #85209 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `agent/models_dev.py` - preserve exact reasoning efforts and toggle support from models.dev metadata. - `hermes_cli/inventory.py` and `hermes_cli/web_server.py` - include model-specific reasoning controls in the shared model-options payload. - `apps/desktop/src/` - filter model effort rows and toggle behavior with supported fallbacks. - `web/src/` - filter Dashboard reasoning options from the same capability payload. - `tests/`, `apps/desktop/src/**/*.test.ts*`, and `web/src/**/*.test.ts` - cover metadata parsing, payload propagation, fallback compatibility, and both UI consumers. ## How to Test 1. Open a model picker for a model whose metadata declares a restricted effort set and verify only those levels appear. 2. Verify Off appears only when the model supports disabling reasoning, and that models without exact metadata retain the complete list. 3. Run the related automated tests: ```bash scripts/run_tests.sh tests/agent/test_models_dev.py tests/hermes_cli/test_inventory.py cd web && npm exec vitest -- run src/lib/reasoning-effort.test.ts && npm run typecheck cd apps/desktop && npx vitest run src/lib/reasoning-effort.test.ts src/app/shell/model-edit-submenu.test.tsx ``` Verified results: 36 Python tests passed, 9 Dashboard tests passed with Dashboard typecheck, and 15 Desktop tests passed. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run the repository test entry on the relevant Python tests and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Windows 11 ### Documentation & Housekeeping - [x] Relevant documentation update: N/A - no user-facing configuration or workflow changed - [x] `cli-config.yaml.example` update: N/A - no config keys changed - [x] `CONTRIBUTING.md` or `AGENTS.md` update: N/A - no architecture or workflow changed - [x] I've considered cross-platform impact per the compatibility guide - the changed logic is platform-independent - [x] Tool descriptions/schemas update: N/A - no model tool behavior changed ## Screenshots / Logs Automated behavior tests cover the exact supported-option sets, toggle visibility, supported fallback selection, and unknown-metadata compatibility.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85246",
        "timestamp": "2026-08-13T11:20:57Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/cli",
          "comp/tools",
          "P3",
          "comp/desktop",
          "comp/dashboard"
        ],
        "author": "fangliquanflq",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85247",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(kanban): pre-compute task_graph_context inside connect_closing() block (#11)",
        "text": "## Problem In `_cmd_show()` display mode, `task_graph_context(conn, task.id)` was called after the `connect_closing()` with-block had already closed the connection, raising: ``` sqlite3.ProgrammingError: Cannot operate on a closed database. ``` Fixes https://github.com/mjbfg1995/agent-control/issues/11 ## Fix Pre-compute the graph dict inside the `with` block while the connection is still open, then pass the cached graph to `compute_task_diagnostics()` that runs after the block exits. ## Regression Test - Direct reproduction of the closed-connection error - Verification that pre-computing avoids the error - End-to-end `_cmd_show` display-mode test Dogfooding: t_a43d0077 All 89 kanban tests green, 2 skipped (platform-specific).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85247",
        "timestamp": "2026-08-13T11:30:34Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/cron",
          "P3"
        ],
        "author": "mjbfg1995",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85249",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(plugins): scope register_context_reference to profile + track in ownership ledger",
        "text": "## Summary `register_context_reference_provider()` (`agent/context_references.py`) writes into one bare, process-global dict (`_context_reference_providers`) with no profile key at all. It's the one registration surface the \"widen ownership ledger to all registration surfaces\" effort (commits `2219747990`, `85020f2238`) missed: 20 of 21 `PluginContext.register_*` methods call `self._track()`/`_track_replacement()` to record their registration in the ownership ledger; `register_context_reference` is the sole exception. **Concrete effect:** a multiplex/Team-Gateway process runs one `PluginManager` per profile. If two profiles both load a plugin that registers the same `@`-prefix (e.g. both enable the same community/bundled plugin), the second profile's registration raises `ValueError: prefix 'X' is already registered` against the first profile's still-present global entry. `PluginContext.register_context_reference` swallows that `ValueError` itself — a log warning, nothing propagates to the plugin's own `register()` — so the failure is silent: the second profile's `@prefix:` references simply never resolve (the built-in reference pattern falls through to \"unsupported reference type\"). The same collision hits a *single* profile across a force-reload too, since nothing ever removes the stale entry — a legitimate re-registration after unload permanently fails the same way. ## Fix Mirrors `agent/tts_registry.py`'s established scoped-registry shape (also used by `browser_registry.py`, `image_gen_registry.py`, `secret_sources/registry.py`, etc.): - **`agent/context_references.py`**: - `register_context_reference_provider()` takes an optional `scope` key, storing into a per-scope dict (`_scoped_context_reference_providers[scope]`) instead of the single global one when passed. `scope=None` (the default) preserves the exact pre-fix behavior for any other caller. - New `restore_context_reference_registration()` — the ownership-ledger dispose callback, only acts when the registration being torn down is still the one currently installed (matches `tts_registry.restore_registration`'s contract). - New `_all_context_reference_providers()` — merges the global dict + the active scope's registrations, defaulting `scope` to `hermes_home_key()` (the current profile) like the sibling registries' `list_providers()` do. - `parse_context_references()` and `expand_reference()` — the two internal call sites that read `_context_reference_providers` directly rather than through `get_context_reference_providers()` — now go through the merged, scope-aware view too, so a scoped registration is actually resolved when a user types `@prefix:...`. - **`hermes_cli/plugins.py`**: `PluginContext.register_context_reference()` now passes `scope=self._manager.scope_key`, tracks the registration via `_track_replacement` (so it returns `Optional[PluginRegistration]` like every sibling `register_*` method — was unconditionally `None` before), and wires the restore callback so unload/force-reload correctly frees the prefix. ## Testing - Added `TestScopedRegistration` (low-level `agent.context_references` API) and `TestPluginContextRegistrationIsScopedAndTracked` (integration level, via real `PluginContext`/`PluginManager` instances) to `tests/agent/test_plugin_context_references.py` — 12 new tests covering: two scopes registering the same prefix without colliding, same-scope duplicates still rejected, scope isolation, `scope=None` backward compatibility, restore semantics (including the \"stale dispose callback must not evict what's currently installed\" case), unregister-then-reregister, and the integration-level handle/dispose/reregistration lifecycle through `PluginContext`. - Updated the file's shared `_clean_registry` autouse fixture to also clear the new `_scoped_context_reference_providers` dict, so the new tests don't leak into the 13 pre-existing ones. - **Mutation-verified in two passes**: (1) reverted both production files — the test module fails to even *import* against pre-fix code, since the new scope-aware API doesn't exist yet; (2) a more precise pass reverting *only* `hermes_cli/plugins.py` (keeping the new low-level API in `context_references.py`, which the test module needs to import) — the 3 integration-level tests fail exactly as expected, with the captured log showing the real bug: `Plugin 'ctx-ref-fixture' context reference registration failed: prefix 'test' is already registered`. - `tests/agent/test_plugin_context_references.py` + `tests/agent/test_context_references.py` + `tests/cli/test_cli_codex_context_reference.py`: 39/39 passed (13 pre-existing unmodified + 12 new + 14 in the other two files). - Broader neighbor sweep (`test_plugin_ownership_ledger.py`, `test_plugins.py`, `test_context_refs_concurrent.py`, `test_context_ref_expansion_runtime.py`, `test_image_input_routing_runtime.py`): 85/85 passed. - `ruff check` clean on all 3 touched files.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85249",
        "timestamp": "2026-08-13T11:30:27Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/cli",
          "comp/plugins",
          "P3",
          "area/profiles"
        ],
        "author": "pierrenode",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85250",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): detect actual systemd scope before timing-alignment check",
        "text": "## Summary Fixes a false-positive diagnostic in `check_systemd_timing_alignment()` (`gateway/shutdown_forensics.py`): system-level gateway installs (`hermes gateway install --system`) logged `\"Stale systemd unit detected: ... TimeoutStopSec=90s but drain_timeout=180s\"` on every boot even though the actual unit was configured correctly (e.g. `TimeoutStopUSec=210s`). ## Root Cause The function queried `systemctl --user show <unit> --property=TimeoutStopUSec` **first** and only fell back to system scope when that call returned a non-zero exit code. That assumption is wrong: `systemctl --user show` on a unit unknown to the user manager does **not** fail — it returns `rc=0` with the user manager's own `DefaultTimeoutStopUSec` (typically 90s). The loop accepted that value as authoritative, never queried the system manager, and compared `drain_timeout` against the wrong number. ## Change - The owning systemd manager is now inferred from `/proc/self/cgroup` (already read by the function to find the unit name): a path under `/user.slice/` → user manager (`systemctl --user`); anything else (`/system.slice/`, `/init.scope`) → system manager. The scope inferred from the cgroup is queried **first**. - `LoadState` is requested in the same `systemctl show` call. If the queried manager reports `LoadState=not-found` (which is how a manager signals it doesn't own the unit, still with `rc=0`), the function falls back to the other scope instead of trusting the manager default. - Behavior stays best-effort and never raises; user-scope installs (the common case) keep querying the user manager first, system-level installs now read the real value from the system manager, and genuine mismatches are still reported. ## Verification - `pytest tests/gateway/test_shutdown_forensics.py -q` → **14 passed** (10 pre-existing + 4 new). - New regression tests mock `/proc/self/cgroup` + `subprocess.run` and cover: - **Issue scenario**: unit under `/system.slice/` → system scope consulted first, user manager never queried, real `210s` value used → no mismatch for `drain_timeout=180`. - User-scope install → user manager queried first (common case preserved). - Primary scope reports `LoadState=not-found` → falls back to the other scope and uses its value. - Genuine system-scope mismatch (90s < 180s + headroom) still reported. ## Closes Closes #85117",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85250",
        "timestamp": "2026-08-13T11:30:25Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/gateway",
          "P3"
        ],
        "author": "webtecnica",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85253",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): invalidate composer suggestion-provider caches on profile switch",
        "text": "## Summary `apps/desktop/src/store/suggestion-providers/mcp.ts` and `skill.ts` each keep a 5-minute TTL cache (`configuredNames` / `index`) of profile-scoped REST reads (`listMcpServers()` / `getSkills()`). Both already export an `invalidate*SuggestionIndex()` function — `mcp.ts`'s own docstring literally says *\"Drop the configured-servers cache (**profile switch** / after an install)\"* — but neither was ever actually wired to a profile switch, only to post-install/post-setup success paths (`mcp.ts:156`, `mcp-setup-tool.tsx`, and a `skill_manage` gateway event for `skill.ts`). `store/profile.ts`'s `$activeGatewayProfile.subscribe` handler is the established chokepoint for exactly this class of cache: it already invalidates React Query's profile-scoped cache (`invalidateProfileScopedQueries()`), the memory graph (`resetStarmapGraph()`), and the cron model-impact scope state on a real profile change — but not these two. **Concrete effect:** a multi-profile desktop user switches from Profile A to Profile B within the 5-minute cache window. The suggestion providers still answer against Profile A's data: - `mcp.ts`: if Profile B already has a server configured that Profile A didn't, the stale `configuredNames` set omits it, so the pill re-offers \"Add `<server>`\" for an already-configured server on Profile B. Clicking it runs `addMcpServer` + a fresh OAuth flow against a server that's already set up. - `skill.ts`: offers \"prefix with `/skillname`\" for a skill that may not exist (or differs) on Profile B. ## Fix Call `invalidateMcpSuggestionIndex()` and `invalidateSkillSuggestionIndex()` from the same `if (_lastRoutedProfile !== null && _lastRoutedProfile !== key)` block in `profile.ts` that already invalidates the other profile-scoped caches. ## Testing - Added two tests to `apps/desktop/src/store/profile.test.ts`'s existing `profile-scoped cache invalidation` describe block, mocking the two suggestion-provider modules the same way `resetStarmapGraph` is already mocked: - `drops the composer suggestion-provider caches (MCP configured-servers, skill index) on profile switch` - `does not invalidate the suggestion caches when re-set to the same profile` (guards the existing \"real change only\" contract) - **Mutation-verified**: reverted the production fix (kept the tests) and confirmed the profile-switch test fails against pre-fix code (`expected \"vi.fn()\" to be called 1 times, but got 0 times`), while the same-profile no-op test still passes. - `npx vitest run src/store/profile.test.ts src/store/suggestion-providers/mcp.test.ts src/store/suggestion-providers/skill.test.ts`: 28/28 passed (11 pre-existing in profile.test.ts + 2 new + 15 in the two suggestion-provider test files, unmodified). - `npx tsc --noEmit`: clean. - `npx eslint` couldn't run in this workspace-scoped install (`eslint.config.mjs` needs the root-only `globals` peer dependency, a known limitation unrelated to this change) — the diff is small and mirrors this file's existing import/invalidation style, so this wasn't blocking.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85253",
        "timestamp": "2026-08-13T11:30:18Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "P3",
          "comp/desktop",
          "area/profiles"
        ],
        "author": "pierrenode",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85254",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(telegram): always deliver TTS replies as native voice bubbles",
        "text": "## Summary Telegram only renders a true voice message (round bubble, working speed control, no filename) for Ogg/Opus. Free **Edge TTS** emits MP3/M4A, which `send_voice` was routing through `sendAudio` as a plain audio *file* (shows a filename, no speed control). Delivery also depended on the agent emitting an optional `[[audio_as_voice]]` marker — fragile, so some voice replies arrived as raw `.ogg`/`.mp3` files instead of bubbles. ## Changes - `gateway/platforms/base.py`: auto-route TTS audio (`.ogg`/`.oga`/`.mp3`/`.m4a`) as voice regardless of the `[[audio_as_voice]]` marker. - `plugins/platforms/telegram/adapter.py`: add `_convert_audio_to_ogg` (ffmpeg → Opus/OGG) and use it in `send_voice`, so MP3/M4A is converted to native Opus/OGG and sent as a real voice bubble. ## Testing Voice message in → native voice bubble out, speed control works. No special config needed — works on a clean Edge-TTS setup.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85254",
        "timestamp": "2026-08-13T11:39:52Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "comp/plugins",
          "tool/tts",
          "platform/telegram",
          "P3",
          "sweeper:risk-message-delivery"
        ],
        "author": "yacineBougrinat",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85255",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(state): evict a poisoned pooled read connection instead of requeuing it",
        "text": "## Summary `_reconnect_after_notadb()` self-heals the single shared **write** connection (`self._conn`) when it starts raising `sqlite3.DatabaseError(\"file is not a database\")` — the runtime-corruption signature left when a sibling process (a forked curator agent, an external repair pass) replaces/truncates the backing file out from under a live connection. It's called from exactly one place: `_execute_write`'s retry loop. `_read_ctx()`'s pooled **read** connections (`self._read_pool`, opened via `_get_read_conn()`) have no equivalent. Its `finally` block unconditionally requeues the checked-out connection regardless of whether the `yield conn` block raised: ```python conn = self._checkout_read_conn() if conn is not None: try: yield conn finally: ... self._read_pool.put_nowait(conn) # returned even if the read above raised ``` If the same corruption class hits a pooled read connection, every subsequent read that draws that connection from the pool hits the identical `\"file is not a database\"` error, forever — unlike the writer's `self._conn`, nothing ever reopens a pooled connection once it starts failing, since the write-side self-heal only touches `self._conn` and has no visibility into (or reach into) the read pool's separate `mode=ro` connections. Up to `_READ_POOL_MAX` (8) read slots can become permanently wedged for the life of the process this way, even after the write path has already self-healed. ## Fix Scope the fix to the exact same signature `_is_not_a_database_error()` already detects for the write path. On that specific error, close/discard the pooled connection (releasing its descriptor permit via the existing `_close_read_conn()`) instead of returning it to the pool — a fresh connection opens on the next miss via `_get_read_conn()`. Any other exception (a caller's own bad query, a `DatabaseError` of a different class) still requeues the connection exactly as before: a healthy connection must not be discarded just because the code using it raised for an unrelated reason. ## Testing - Added `TestReadPoolEvictsPoisonedConnection` to `tests/test_state_db_notadb_selfheal.py` (the existing home for this exact self-heal mechanism's tests), mirroring its established real-`SessionDB`-instance testing style: - `test_poisoned_connection_is_evicted_not_requeued`: checks a connection out of the pool twice (proving it's the same pooled object), simulates the corruption signature on the second use, and verifies the pool is empty afterward and a third read opens a genuinely new connection rather than the poisoned one. - `test_other_database_errors_still_requeue_normally`: proves the eviction is scoped to the specific signature — an unrelated `DatabaseError` (e.g. \"database disk image is malformed\") still requeues a healthy connection. - **Mutation-verified**: reverted the production fix (kept the tests) and confirmed `test_poisoned_connection_is_evicted_not_requeued` fails against pre-fix code (`assert 1 == 0` — the poisoned connection was requeued). - `tests/test_state_db_notadb_selfheal.py`: 11/11 passed (9 pre-existing + 2 new). - Broader neighbor sweep: `tests/test_hermes_state.py` — 221/222 passed. The one failure (`TestFTS5Search::test_search_projection_skips_context_enrichment_queries`) is **pre-existing and unrelated**: verified by reverting the fix and re-running that single test in isolation — it fails identically with or without this change. `tests/test_wal_checkpoint_strategy.py`, `tests/test_state_db_malformed_repair.py`, `tests/test_state_db_stats.py`: 36/36 passed. - `ruff check` clean on both touched files.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85255",
        "timestamp": "2026-08-13T11:39:48Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P2",
          "sweeper:risk-session-state",
          "area/sessions"
        ],
        "author": "pierrenode",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85256",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(url_safety): allow QQ Bot API hosts to resolve to fake-ip range",
        "text": "## Problem When the local proxy (SpeedCat/Clash) runs in **TUN mode with fake-ip DNS**, `bots.qq.com` and `api.sgroup.qq.com` resolve into the `198.18.0.0/15` benchmark range. The SSRF guard in `tools/url_safety.py` then blocks the QQ Bot adapter's requests: ``` Reconnect failed: Failed to get QQ Bot access token: Blocked request to private/internal address during connect: bots.qq.com -> 198.18.0.79 ``` The qqbot adapter gets stuck in an infinite reconnect loop (observed at attempt 39+), and the bot is unusable. ## Root cause `multimedia.nt.qq.com.cn` was already whitelisted in `_TRUSTED_PRIVATE_IP_HOSTS` for exactly this reason — QQ hosts legitimately resolve into `198.18.0.0/15` behind local proxy/benchmark infrastructure (see the existing comment). But the two hosts the qqbot adapter actually talks to (`TOKEN_URL` → `bots.qq.com`, `API_BASE` → `api.sgroup.qq.com`) were missing from the whitelist. ## Fix Add `bots.qq.com` and `api.sgroup.qq.com` to `_TRUSTED_PRIVATE_IP_HOSTS`. The whitelist stays narrow: only these two QQ Bot API hosts bypass the IP-class check; everything else resolving into the same range is still blocked. ## Verification - `is_safe_url(\"https://bots.qq.com/app/getAppAccessToken\")` → `True` (was `False` under fake-ip) - `is_safe_url(\"https://api.sgroup.qq.com/gateway\")` → `True` - Control: an unrelated host resolving into the same fake-ip range (`example.com → 198.18.0.84`) → still `False` - After patching the live install and restarting the gateway, the qqbot adapter connects: token refreshed → gateway URL fetched → WebSocket connected → `Ready, session_id=...` - No behavior change for non-fake-ip environments (hosts resolve to public IPs, whitelist not consulted)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85256",
        "timestamp": "2026-08-13T11:39:45Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/tools",
          "platform/qqbot",
          "P2"
        ],
        "author": "Sand-85",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85258",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(dashboard): stamp web bundle provenance so the deployed bundle identifies itself",
        "text": "## Problem `hermes_cli/web_dist` is gitignored **and** is the live static root the dashboard serves. `web/vite.config.ts` sets `outDir: \"../hermes_cli/web_dist\"` explicitly, so any `cd web && npm run build` — including the exact recovery command `_do_build_web_ui` prints on failure — writes the current working tree into production. The deployed bundle carries no record of what it is. There is no way to answer *\"which commit is serving, and was the tree clean when it was built?\"* without rebuilding and comparing hashes. On a machine I maintain, the served `index.html` hashed to `d00ffc0c…` while `HEAD` was a completely different commit, and nothing on disk connected them. ## Approach Make the deploy **visible**, not blocked. `outDir` is unchanged and the dashboard's startup build, staleness sentinel, and flock serialization are untouched — a mitigation that broke the sanctioned build path would be the wrong mitigation. A Vite plugin (`apply: \"build\"`, emitted via `generateBundle`) writes `build-provenance.json` into whatever `outDir` is, so **every** build path is stamped — the `hermes dashboard` startup build and an ad-hoc `npm run build` alike: ```json { \"schemaVersion\": 1, \"commitSha\": \"…\", \"branch\": \"…\", \"dirty\": false, \"builtAt\": \"2026-08-12T18:54:08.853Z\", \"invocation\": \"npm run build -> vite build\" } ``` - Git-derived when building from a work tree; `dirty` uses `--untracked-files=normal`, so an untracked file still marks the bundle dirty. - No `.git` (Docker, Nix): falls back to already-available build metadata — Docker CI forwards `github.sha`/ref, Nix forwards its flake rev and clean state. **Unknown values stay `null` rather than being guessed.** - `SOURCE_DATE_EPOCH` is honoured when present, so reproducible package builds do not get wall-clock entropy injected. `hermes doctor` reads the bundle the server would actually serve (respecting `HERMES_WEB_DIST`, so the packaged Desktop case is correct too) and reports source identity, dirty state, build time/invocation, and drift against current HEAD: ``` ◆ Web Dashboard Bundle ✓ Web bundle source: 3952df8d70d1 (…), clean → Built at: 2026-08-12T18:54:08.853Z ✓ Web bundle matches current source HEAD (3952df8d70d1) ``` It is **advisory only** — missing or malformed provenance warns; it never builds, repairs, places, or deletes a bundle. ## Verification - Built from a deliberately dirty tree and confirmed `dirty: true` with `commitSha`/`branch` matching git exactly; built clean and confirmed `dirty: false`. - Deployed on a live install: the new bundle differed from its predecessor by **exactly one file** (`build-provenance.json`), `index.html` byte-identical — provenance gained with zero application change. - New tests are behaviour contracts, not snapshots: no literal commit SHA is asserted and no test reads source text. 3 vitest cases + 3 Python tests, including one asserting the doctor check is advisory and does not repair. - `web/vitest.config.ts` change is a single line adding `vite.config.test.ts` to `include`; the test passes under the existing `environment: \"node\"` with no `setupFiles`. `tests/hermes_cli/test_doctor.py`: **54 passed, 1 failed**. That failure — `test_doctor_reports_vercel_backend_diagnostics` — is **pre-existing**: unmodified `main` at `fa83af3f9a` gives 51 passed / 1 failed on the same test, and this branch touches nothing related to it. ## Notes Implemented by Galadriel (a Hermes agent) and independently reviewed before landing.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85258",
        "timestamp": "2026-08-13T11:39:41Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "area/docker",
          "area/nix",
          "P3",
          "sweeper:risk-compatibility",
          "sweeper:risk-automation",
          "comp/dashboard"
        ],
        "author": "virtualex-itv",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85259",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "Add model picker card and prevent cron output overwriting",
        "text": "## What does this PR do? <!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? --> ## Related Issue <!-- Link the issue this PR addresses. If no issue exists, consider creating one first. --> Fixes # ## Type of Change <!-- Check the one that applies. --> - [ ] 🐛 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 <!-- List the specific changes. Include file paths for code changes. --> - ## How to Test <!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. --> 1. 2. 3. ## Checklist <!-- Complete these before requesting review. --> ### Code - [ ] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [ ] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) 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: <!-- e.g. Ubuntu 24.04, macOS 15.2, Windows 11 --> ### Documentation & Housekeeping <!-- Check all that apply. It's OK to check \"N/A\" if a category doesn't apply to your change. --> - [ ] 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](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [ ] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## For New Skills <!-- Only fill this out if you're adding a skill. Delete this section otherwise. --> - [ ] This skill is **broadly useful** to most users (if bundled) — see [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#should-the-skill-be-bundled) - [ ] SKILL.md follows the [standard format](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#skillmd-format) (frontmatter, trigger conditions, steps, pitfalls) - [ ] No external dependencies that aren't already available (prefer stdlib, curl, existing Hermes tools) - [ ] I've tested the skill end-to-end: `hermes --toolsets skills -q \"Use the X skill to do Y\"` ## Screenshots / Logs <!-- If applicable, add screenshots or log output showing the fix/feature in action. -->",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85259",
        "timestamp": "2026-08-13T11:39:37Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "comp/cron",
          "comp/plugins",
          "platform/feishu",
          "P3",
          "sweeper:risk-message-delivery"
        ],
        "author": "Bin-Peng",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85260",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): idempotent beginEdit guard for 'Edit already in progress' refresh race",
        "text": "## Linked Issue Closes #84058 ## Description The desktop app intermittently drops the cursor from the composer input (reads as an app \"refresh\") when an agent tool call starts streaming. Root cause: a re-mounting edit-composer subscription calls `beginEdit(messageId)` a second time for a message whose composer is already open, and the vendor base class `ExternalStoreThreadRuntimeCore` **throws** `Edit already in progress`. The uncaught renderer error unwinds to the root error boundary, which auto-recovers by remounting the tree — dropping the caret in the active composer. This adds an idempotent override on `IncrementalExternalStoreThreadRuntimeCore.beginEdit` that returns early when `getEditComposer(messageId)` is already open, so the duplicate call is a no-op and the open composer stays intact. Verified against the running app's bundles: the guarded bundle (`index-BRhtfo9X.js`) produced zero `Edit already in progress` errors in `desktop.log`, while the current unguarded build (rebuilt from `main` after a local-only fix was wiped by `hermes update`) inherits the vendor throw again. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Test Coverage - [x] Tested manually: guard present in the Aug-11 bundle eliminated all `Edit already in progress` renderer-console errors (17 logged occurrences in older bundles → 0 after guard); the same throw is confirmed live in the unguarded bundle built from `main`. ## Checklist - [x] Code follows the project's style guidelines - [x] Self-review performed - [x] New and existing tests pass locally (change is a pure early-return override; no test suite touched)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85260",
        "timestamp": "2026-08-13T11:39:33Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "P3",
          "comp/desktop"
        ],
        "author": "sanyamjain2014",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85261",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix: restore session model on resume instead of falling back to config default",
        "text": "## Summary When resuming a session with `hermes --resume` or `/resume`, the model would silently revert to the config default instead of using the model the session was actually using. This affected any session where the user had switched models mid-conversation with `/model`. **Root cause — two bugs:** 1. **CLI `/model` switch didn't persist to the session DB.** The gateway calls `update_session_model()` after a `/model` switch, but the CLI path only updated in-memory state (`self.model`, `self.provider`) and the agent's runtime — it never wrote the new model to the `sessions.model` column. So the DB row always kept the original model from session creation. 2. **Resume didn't restore model/provider from the session row.** `_preload_resumed_session` and the `_init_agent` resume path restored CWD and YOLO from `session_meta`, but never read `session_meta[\"model\"]` back into `self.model`/`self.provider`. So even if the DB had the right model, resume would use whatever was in `config.yaml`. ## Changes - **`cli.py` — `_handle_model_switch` / `_apply_model_switch_result`**: After a session-scoped `/model` switch, call `update_session_model()` to persist the new model to the session DB row (skipped for `--once` since it's ephemeral, and `--global` since config.yaml is the source of truth). Mirrors the gateway's existing `update_session_model()` call. - **`cli.py` — new `_restore_session_model()` method**: Restores model/provider from `session_meta` on resume, with provider/base_url/api_mode from `model_config.gateway_runtime`. Also swaps the running agent in-place for mid-chat `/resume` (when `self.agent` is already built). Skips when the CLI was launched with an explicit `-m`/`--model` override (user intent wins). - **`cli.py` — `__init__` / `new_session`**: Track `_explicit_model_override` flag when `-m`/`--model` is passed, cleared on `/new` so the override only applies to the original session. - **`hermes_cli/cli_agent_setup_mixin.py`**: Call `_restore_session_model()` from both resume paths (`_preload_resumed_session` and `_init_agent`). - **`hermes_cli/cli_commands_mixin.py`**: Call `_restore_session_model()` from the mid-chat `/resume` command handler. ## Test plan - [x] `tests/cli/test_resume_display.py` — 15 passed - [x] `tests/hermes_cli/test_cli_model_once.py` — passed - [x] `tests/test_hermes_state.py` — passed - [x] `tests/run_agent/test_switch_model_*.py` — 15 passed - [x] `tests/gateway/test_model_switch_persistence.py` — 7 passed",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85261",
        "timestamp": "2026-08-13T11:39:29Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P2",
          "sweeper:risk-session-state",
          "sweeper:risk-compatibility",
          "area/sessions"
        ],
        "author": "kshitijk4poor",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85263",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(webhook): stop route scripts from losing authenticated delivery identity",
        "text": "## What does this PR do? Webhook route scripts currently receive only payload JSON, so they cannot bind work to the event type and delivery identity that Hermes authenticated. This change passes bounded request metadata through a sanitized environment, rejects script deliveries without an external identity, and applies idempotency before script execution so a retry cannot run a stateful script twice. ### Symptom A signed GitHub webhook reaches a configured route script without `X-GitHub-Event` or `X-GitHub-Delivery`. A repeated delivery can also execute the script again before Hermes recognizes the duplicate. ### Impact Stateful script consumers cannot reliably associate processing with the authenticated delivery. They must otherwise infer identity from attacker-controlled payload data, and duplicate webhook retries can repeat script side effects. ### Bug Cause **Trigger:** `gateway/platforms/webhook.py:738` in `WebhookAdapter._handle_webhook` **Causal chain:** 1. Hermes validates the request signature and resolves the event type from request headers. 2. The adapter calls `run_route_script` with only parsed payload data, and it checks delivery idempotency only after the script returns. 3. The child process cannot observe authenticated metadata, while duplicate deliveries can invoke it more than once. **Why it is wrong:** The trusted request metadata is dropped at the subprocess boundary, and the side-effecting script runs outside the delivery idempotency boundary. **Working sibling / contrast:** Non-script routes do not need subprocess metadata and retain their timestamp fallback when no external delivery ID is present. **Ruled out:** Payload lookalike fields are not a safe substitute because the request body is attacker-controlled even when the signature identifies its sender. Live verification confirmed those fields did not populate the script environment on the base revision. ### Fix Resolve one bounded external delivery ID before script execution, require valid event and delivery metadata for script routes, and pass both values as `HERMES_WEBHOOK_EVENT_TYPE` and `HERMES_WEBHOOK_DELIVERY_ID` in the sanitized subprocess environment. The same delivery ID now gates script execution and downstream processing. Documentation and integration tests cover exact propagation, payload non-override, fail-closed missing identity, and duplicate suppression. ## Related Issue Closes #85244 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `gateway/platforms/webhook.py` - resolve and validate authenticated metadata before idempotency and route-script execution. - `gateway/platforms/webhook_filters.py` - expose bounded event and delivery values to scripts through the sanitized child environment. - `tests/gateway/test_webhook_adapter.py` - cover signed propagation, payload non-override, missing identity, and duplicate execution. - `website/docs/user-guide/messaging/webhooks.md` - document the route-script metadata contract. ## How to Test 1. Send a correctly signed webhook with `X-GitHub-Event` and `X-GitHub-Delivery` to a route with a real script and verify the script receives the exact values. 2. Repeat the same delivery and verify the script invocation count remains one; omit the delivery header and verify Hermes returns HTTP 400 without invoking the script. 3. Run the targeted gateway suite (41 passed locally): ```bash scripts/run_tests.sh tests/gateway/test_webhook_adapter.py tests/gateway/test_webhook_deliver_only.py ``` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run the repository test entry on the relevant tests and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Windows 11 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - [x] `cli-config.yaml.example` is N/A because this change adds no config keys - [x] `CONTRIBUTING.md` and `AGENTS.md` are N/A because this does not change contributor workflows - [x] I've considered cross-platform impact; the existing script launcher remains platform-specific and the metadata contract is platform-neutral - [x] Tool descriptions and schemas are N/A because this does not change a model tool ## Screenshots / Logs Live verification with an aiohttp listener and a real script subprocess reproduced the missing metadata, duplicate invocation, and synthesized missing identity on the base revision. At commit `a8e3dd055`, the script received the exact authenticated values, duplicate invocation remained at one, and missing identity returned HTTP 400 before script execution.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85263",
        "createdAt": "2026-08-13T11:38:59Z",
        "updatedAt": "2026-08-13T17:57:51Z",
        "timestamp": "2026-08-13T17:57:51Z",
        "metrics": {
          "reactions": 0,
          "comments": 8
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "platform/webhook",
          "P2",
          "sweeper:risk-message-delivery"
        ],
        "author": "fangliquanflq",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85264",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): drain every known Windows gateway PID on stop, not just the lock-tracked one",
        "text": "## What does this PR do? On Windows, `hermes gateway stop` skips the graceful drain entirely whenever the gateway runtime lock does not currently name the live gateway — the in-flight agent turn is abandoned and `resume_pending` is never persisted, so nothing auto-resumes on the next boot. That is the exact regression #33798 was merged to fix (`10ee4a729ba`, \"fix(gateway): drain on Windows `hermes gateway stop` so sessions survive restart\"), on the one entry into `stop()` that fix did not cover. This completes it. **POSIX already implements the remedy.** In `hermes_cli/gateway.py`, the POSIX stop path handles the same `pid is None` case explicitly: ```python pid = get_running_pid() if pid is None: return _reap_unsupervised_gateway_orphans() ``` and `_reap_unsupervised_gateway_orphans()` writes the marker for **every** scanned PID before signalling it: ```python orphans = [p for p in find_gateway_pids(exclude_pids=own) if p and p > 0] for pid in orphans: try: write_planned_stop_marker(pid) except Exception: pass try: os.kill(pid, signal.SIGTERM) ``` Windows had no equivalent — it went straight to `schtasks /End` + `taskkill /F`. This is a Windows/POSIX asymmetry, not a speculative bug. `hermes_cli/gateway.py` is cited as evidence only; it is not modified here. ### The defect, in the repo's own words `_drain_gateway_pid`'s docstring states the invariant: > Windows cannot deliver POSIX signals to a Python asyncio loop (``loop.add_signal_handler`` raises NotImplementedError), so writing the marker is the **ONLY** way to ask a running gateway to drain in-flight agents and persist ``resume_pending`` before exit. `stop()`'s own docstring states the marker write as unconditional: > Writes the planned-stop marker first so the gateway can drain in-flight agents and persist ``resume_pending`` before exit ... the marker is our only IPC The code ~100 lines below contradicted both: ```python pid = get_running_pid() stop_pids = _collect_gateway_stop_pids(pid) drained = False if pid is not None: drained = _drain_gateway_pid(pid, _windows_stop_drain_timeout()) ... stop_pids.extend(pid for pid in _collect_gateway_stop_pids() if pid not in stop_pids) killed = _force_terminate_known_gateway_pids(stop_pids) ``` `stop()` trusted the process scan enough to **hard-kill** those PIDs, but not enough to **drain** them. ### Why the skipped state is reachable The two PID sources are structurally independent, so this is an ordinary state rather than a contrived one: - `get_running_pid()` (`gateway/status.py`) returns `None` whenever `is_gateway_runtime_lock_active()` is False **and** the runtime status record is empty — and that lock probe returns False in three concrete ways that say nothing about process liveness: the lock file does not exist, nobody currently holds it (`_try_acquire_file_lock` succeeds), or it could not be opened and was removed as stale. - `_collect_gateway_stop_pids()` goes through `find_gateway_pids()`, which merges **three** sources: `get_running_pid()`, the service-registry lookup, and the live command-line process scan. A `None` from the first is skipped via `_append_unique_pid`, **not** an early return, so the last two still contribute. So `stop_pids` is non-empty precisely when `get_running_pid()` is `None` but a gateway process is live — the exact state that skipped the drain. **Blast radius:** every Windows user whose running gateway is not currently reflected in the runtime lock loses in-flight work on `hermes gateway stop`, and on `restart()`, which calls `stop()`. ### Sibling-site sweep `_force_terminate_known_gateway_pids` has exactly two call sites in `gateway_windows.py`: | site | drains first? | disposition | |---|---|---| | `stop()` | no (this bug) | **fixed here** | | `restart()` escalation | no | **excluded, with reason** — it runs only after `stop()` has already written the markers and `_wait_for_gateway_absent` has waited 30s for them; a second marker there would be a no-op | `install()` reports running PIDs but never terminates them, so it does not share the cause. There is no third site. ## Related Issue Fixes #33778 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [x] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made Four atomic commits, each green on its own: 1. **`refactor(gateway):`** — `hermes_cli/gateway_windows.py`: add `_drain_gateway_pids(pids, drain_timeout)`. No behaviour change; `stop()` still passes exactly the lock-tracked PID. The helper exists because a multi-PID drain cannot simply loop over `_drain_gateway_pid`: - `write_planned_stop_marker` stores the target PID **inside a single global marker file** (`_get_planned_stop_marker_path()`), and `consume_planned_stop_marker_for_self()` only matches when `target_pid` is the reading process. Writing markers for several PIDs up front makes each write clobber the previous one before its target has consumed it — so the drain is **sequential**: marker → wait → next PID. - A per-PID timeout would let total stop latency grow to N × the drain timeout, which `_windows_stop_drain_timeout()` exists to prevent (\"Windows CLI stop must not wedge forever\"). All PIDs therefore share **one deadline**. - It returns True only when every live PID exited in time, so `stop()` cannot print \"drained cleanly\" while a sibling is being hard-killed, and it skips the stopping process itself, as `_force_terminate_known_gateway_pids` already does. 2. **`fix(gateway):`** — `hermes_cli/gateway_windows.py`: `stop()` drains `stop_pids` (the same list Phase 3 hard-kills) instead of only `get_running_pid()`, removing the `if pid is not None` guard. `stop()`'s docstring corrected to match. 3. **`fix(gateway):`** — `hermes_cli/gateway_windows.py`: split the remaining budget across the PIDs still to be drained, so one wedged gateway cannot consume the whole window and leave its siblings with no drain request. A PID that exits early hands its unused share back, so the single-gateway case is unchanged. 4. **`test(gateway):`** — `tests/hermes_cli/test_gateway_windows.py`: four regression tests (below). `_drain_gateway_pid` itself is unchanged — its signature and contract are preserved. ## How to Test `uv run --with pytest --with pytest-asyncio python3 -m pytest tests/hermes_cli/test_gateway_windows.py -q` The tests are host-agnostic (every Windows-specific dependency is mocked, as in the existing `test_install_scheduled_task_recreates_instead_of_change`) and use a fake monotonic clock, so the bounded-deadline assertions cost no wall-clock time. **1. Red before the fix.** With `hermes_cli/gateway_windows.py` restored to unmodified `main` (`fa83af3f9`) and only the new tests applied: ``` FAILED tests/hermes_cli/test_gateway_windows.py::test_stop_drains_scanned_gateway_when_runtime_lock_is_stale FAILED tests/hermes_cli/test_gateway_windows.py::test_stop_drain_shares_one_deadline_across_every_known_pid FAILED tests/hermes_cli/test_gateway_windows.py::test_stop_never_drains_the_stopping_process_itself 3 failed, 6 passed, 2 skipped ``` with the headline failure showing the bug directly — the recorded effects contain a kill and no marker: ``` E AssertionError: no planned-stop marker written for the scanned gateway E assert ('marker', 4242) in [('schtasks', ('/End', '/TN', 'Hermes_Gateway')), ('terminate', 4242)] ``` **2. Green after.** With the production change restored: `9 passed, 2 skipped`. The fourth new test, `test_stop_drains_the_lock_tracked_pid_without_rescanning_it`, passes in **both** states by design — it pins the ordinary single-gateway path as unchanged. Adjacent suites also pass: `tests/gateway/test_restart_drain.py`, `tests/gateway/test_planned_stop_watcher.py`, `tests/gateway/test_stop_thread_sibling.py`, `tests/hermes_cli/test_gateway_platform_gating.py`, `tests/hermes_cli/test_gateway_proc_fallback.py` — `16 passed, 7 skipped`. The tests assert on **ordering**, not on a bare call count: the marker must be written *before* any `schtasks /End` or `taskkill`, because a marker written after the kill is worth nothing. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass <!-- ran the focused + adjacent suites listed under \"How to Test\", not the full tree --> - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (the tests are host-agnostic by construction; I do not have a Windows host, so the Windows behaviour itself is covered by mocks, not by a live run) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — `stop()`'s docstring no longer claims an unconditional marker write; the new helper documents the single-global-marker constraint - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — the change is confined to `gateway_windows.py`, which is Windows-only; POSIX behaviour is untouched and is the model being mirrored - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A ## Related / Positioning Disclosed in full so a reviewer does not have to re-derive it. No open PR fixes this premise; **not one rival removes or alters the `if pid is not None:` drain guard.** | PR | author | overlap | assessment | |---|---|---|---| | **#84549** | Isaac-b-ux | **hunk collision, same three functions** — adds a `lifecycle_action` kwarg to `_drain_gateway_pid`, decorates `stop()` with `@lifecycle_serialized`, and rewrites the `_drain_gateway_pid(pid, ...)` call | **Does not fix this bug.** Its concern is lifecycle serialization/authorization, and it leaves `if pid is not None:` untouched, so the drain-skip survives its patch entirely. Its `pid is not None` deletions are in `hermes_cli/gateway.py` (the POSIX sibling), not this file. If it lands first this is a routine rebase; `_drain_gateway_pid`'s signature is deliberately left alone here to keep that rebase small. | | **#38039** | LiFeiYu-boost | same **test file**; adds a unit test asserting `_drain_gateway_pid` returns True when the PID is already gone | Coverage PR against an older revision of the test file. It exercises the helper in isolation and never tests `stop()`'s guard, so it neither covers nor contradicts this premise. Textual conflict only. | | #50200 | — | inserts an early `if is_service_registered(): stop_service(); return` at the **top** of `stop()` | Different concern (Windows Service backend), above this region. Worth noting that path would bypass the drain too. | | #29355 | — | header reads `@@ def stop()` | Not actually in `stop()` — the inserted code lands after the function ends. | | #81633, #59501, #69080 | — | `start()` tail / inside `restart()` | No overlap with the drain guard. | Prior art this completes: **#33798** (`10ee4a729ba`), merged 2026-05-28, which introduced `_drain_gateway_pid` and the marker-based Windows drain for issue #33778.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85264",
        "timestamp": "2026-08-13T11:49:52Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/gateway",
          "P2",
          "sweeper:risk-session-state",
          "sweeper:risk-platform-windows",
          "platform/windows"
        ],
        "author": "briandevans",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85266",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(kanban): report why work stopped and whether it is moving",
        "text": "Four read-only additions to `hermes kanban`, so a consumer can tell *why* work stopped and *whether it is moving* without opening any task. Stacked on #83348 — it needs the `activity-v1` groundwork. Review that one first; this branch's diff against it is the four commits below. ## What each commit adds **`report why work stopped and whether it is moving`** — `_board_staleness_stats` and `_unresolved_run_stats`. Today a board reports how many rows are blocked but nothing about whether anything is happening. These report the age of the oldest open row and the maximum run count on any unresolved row. Both are stated as peer observations, never combined into a verdict: measured on a live board the row carrying the maximum run count was `scheduled`, not blocked, so folding it into a blocked-cause sentence sends an operator hunting through blocked rows for a repeat that is not among them. **`report every block kind that stops for a person`** — `_needs_input_stats`. `block_kind` already distinguishes `dependency`, `needs_input`, `capability` and NULL, but nothing surfaced the split, so every blocked row looked the same from outside. **`count only what is stopped in front of a person`** — a fix to the above. The first version counted rows that were blocked on other rows, which a person cannot act on. **`separate a review handoff from a question for a person`** — `_awaiting_review_stats`. Measured on a live board, 53 `needs_input` rows were really 28 waiting on a `review-required:` dispatch that never arrives and 25 genuinely waiting on a person. Reporting 53 as one number tells an operator to answer 53 questions when 25 is the real figure. ## Verification `tests/hermes_cli/test_kanban_core_functionality.py` on this branch versus the same file on clean `main`, compared as failure **sets** rather than counts: - clean `main`: 2 failed, 21 passed - this branch: 2 failed, 34 passed - **new failures: 0.** The same two `test_gateway_dispatcher_disables_corrupt_board_without_traceback` cases fail on both; they are pre-existing and untouched here. 13 new tests, all passing. ## Note on the diff size The line counts are larger than the feature because the branch was rebased from an older base. Definitions were checked directly: `kanban_db.py` goes from 224 to 235 top-level definitions with **zero** upstream definitions removed. Nothing existing was deleted.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85266",
        "timestamp": "2026-08-13T12:08:21Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "comp/cron",
          "P3"
        ],
        "author": "moonweave",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85267",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(sessions): report that person-driven work exists, without reporting what it is",
        "text": "`hermes sessions activity` aggregates interactive sessions per workspace and emits counts and timestamps only. Stacked on #85266, which is stacked on #83348. This branch's own change is one commit: **147 lines added, 0 removed.** ## Why A person driving work by hand in a terminal leaves no kanban row. Anything watching the kanban store therefore reports that nothing is happening, which is wrong in a way that is hard to notice — the absence looks identical to an idle system. This reports that such work exists without reporting what it is. The grouping key is a digest of the workspace; no session id, title, path, or message ever leaves the command. A consumer can say *someone is working somewhere* and nothing more. ## What it emits ``` hermes sessions activity --json --window 300 ``` Per workspace digest: session count, active count within the window, and last-activity timestamp. Plus `contract_version`, `scan_limit`, and `scan_truncated`. The scan is bounded at 200 sessions — enough to cover every workspace a person could plausibly have open at once, while keeping the read cheap on a 300+ session store. Rows arrive newest-activity-first, so a truncated scan is reported as `scan_truncated` rather than presented as a complete count. A consumer that cannot distinguish those two would render a partial read as a confirmed total. ## Verification `tests/hermes_cli/test_sessions_activity.py` — 11 tests, all passing. They cover the privacy boundary directly: that no id, title, or path appears in the output, and that the digest is stable across reconnects but disjoint across workspaces. Run together with the kanban suite: 2 failed, 45 passed. Both failures are the pre-existing `test_gateway_dispatcher_disables_corrupt_board_without_traceback` cases that also fail on clean `main`.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85267",
        "timestamp": "2026-08-13T12:08:17Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "comp/cron",
          "P3",
          "sweeper:risk-session-state",
          "area/sessions"
        ],
        "author": "moonweave",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85268",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(guard): tolerate NUL bytes in referenced-script paths (os.open ValueError)",
        "text": "## Problem `_read_referenced_script()` calls `os.open(path)` and catches only `OSError`. A script path containing an embedded NUL byte (e.g. decoded binary contents tokenized as a path, or a crafted command line) makes `os.open` raise `ValueError: embedded null character in path` — which crashes the whole terminal-tool call with a traceback instead of allowing/blocking the command. ## Fix Catch `(OSError, ValueError)` and treat a NUL-containing path like a missing file (`nothing to scan`) — such a path can never name a real script. The existing #76762 fix already tolerates NUL at `Path.resolve` time; this closes the sibling `os.open` path. ## Tests 5 regression cases in `tests/hermes_cli/test_gateway_restart_loop.py`: - absolute NUL path in command - relative NUL path resolved against cwd - NUL in referenced script content (binary-like, skipped) - NUL tolerance does NOT weaken the guard (lifecycle command still blocked) - direct unit: `_read_referenced_script` returns `(None, False)` 87/87 pass in the guard file; 92/92 across both lifecycle suites.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85268",
        "timestamp": "2026-08-13T12:08:15Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/cron",
          "P3"
        ],
        "author": "fontvu",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85269",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(kanban): harden orchestration and add report-only workspace lifecycle",
        "text": "## Summary This PR closes the Hermes Kanban failure chain where recovery/escalation triage could be mistaken for fresh intake, existing graphs could be duplicated, and non-code children could inherit code worktrees. It also introduces a deliberately report-only workspace lifecycle registry and closeout manifest surface. ### Cut 1: safe orchestration boundaries - persist and enforce fresh-intake vs recovery provenance in the kernel - fail closed for legacy/unclassified and escalated triage - reject duplicate open descendant graphs transactionally - allocate workspaces by declared capability; only repo-write children receive isolated worktrees - require real installed profiles at graph commit - serialize graph commits against profile delete/rename using a shared lifecycle lock - refuse delete/rename while profiles own nonterminal Kanban assignments ### Report-only Cut 2 - add an evidence-bound SQLite workspace registry, leases, observations, classification, and closeout manifests - make receipt validation, freshness checks, and insertion one write transaction - expose only inventory, classify, dry-run import, and manifest commands - keep apply/removal authority disabled; manifests always report apply_available=false ## Safety boundaries - no automatic worktree or branch deletion - no apply/removal command - registry is not yet wired into live dispatcher allocation - legacy/unclassified triage and escalated triage cannot be decomposed in place; operators must retire and recreate a reviewed fresh-intake root - unknown or stale evidence fails closed to retention/review ## Verification Exact commit: 4188f082c7954154f5c275d17545c3780839a311 - focused local suite: 132 passed, 2 skipped - independent Hermes QA: 122 passed, 2 skipped - independent Challenger Lab archive run: 124 passed, 2 skipped - full CLI + gateway suite: 10,701 passed, 4 failed, 108 skipped - the 4 failures are unrelated baseline/environment failures previously reproduced on origin/main: - shutdown forensics subprocess spawn on this macOS host - Linux abstract systemd socket on macOS - ambient Qwen OAuth provider resolution - Darwin special mode-bit expectation - git diff --check clean - commit-bound approvals: Hermes Reviewer, Hermes QA, Sentinel, Challenger Lab ## Operational rollout Keep automatic decomposition and child auto-promotion disabled in production until this PR is merged, deployed, and the disposable-board/canary gates pass. This PR does not change live configuration.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85269",
        "timestamp": "2026-08-13T12:08:08Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "comp/gateway",
          "comp/cron",
          "P3"
        ],
        "author": "ahmadashfq",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85270",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(providers): support authentication-free model providers",
        "text": "## Summary Introduces a generic `auth_type=\"none\"` capability on `ProviderProfile` so a model-provider plugin can declare that its HTTP endpoints require **no credentials and no `Authorization` header**. Core invariant, asserted by `tests/providers/test_noauth_provider_flag.py`: - a `none` provider resolves **without a secret** (`api_key == \"\"`, source `\"no-auth\"`) in every credential path — CLI runtime resolver, auxiliary client router, and client bootstrap; - the transport **must not emit an `Authorization` header** (captured on the wire via a recording `httpx` transport, not just asserted on `api_key`). The change is deliberately generic: nothing here is specific to any single provider, model list, or domain. It widens the plugin surface — the same narrow-waist philosophy already applied to `external_process`/`copilot`/`aws_sdk` — so a free-tier endpoint that rejects `Authorization` outright can be shipped as a user plugin with zero core special-casing. ## Motivation / Real-world consumer OpenCode Free (https://opencode.ai free tier, upstream issue #35247) is an example of such a provider: its endpoints reject any `Authorization` header. It is intentionally **not** part of this PR — it ships as a user plugin installed into `~/.hermes/plugins/model-providers/` and works entirely through this generic primitive. `Related: #35247` ## What changed - `providers/base.py` — document `auth_type` values including `none`. - `hermes_cli/auth.py` — register `none` profiles in `PROVIDER_REGISTRY` without env-var key sources; credential resolution forces `api_key=\"\"` with source `\"no-auth\"`; provider status reports `none` as configured/logged-in. - `hermes_cli/runtime_provider.py`, `agent/auxiliary_client.py` — `none` providers resolve with an empty key; explicit keys are discarded (endpoints reject any credential). - `hermes_cli/models.py`, `hermes_cli/main.py`, `hermes_cli/cli_agent_setup_mixin.py`, `hermes_cli/model_setup_flows.py` — `none` participates in the picker/setup/doctor flows like `api_key`, without prompting for a key. - `hermes_cli/doctor.py` — `none` providers are reported as `(no-auth)` under Provider Connectivity, with no credential check or probe. - `agent/agent_init.py` — no spurious \"API key invalid/missing\" warning for `none` providers. - `website/docs/developer-guide/model-provider-plugin.md` — document `auth_type=\"none\"`. - `tests/providers/test_noauth_provider_flag.py` — regression suite including the wire-level no-`Authorization` assertion. ## Testing - `scripts/run_tests.sh tests/providers tests/hermes_cli/test_models.py tests/hermes_cli/test_setup_model_provider.py tests/hermes_cli/test_status_model_provider.py tests/hermes_cli/test_models_dev_preferred_merge.py tests/hermes_cli/test_configured_builtin_models.py tests/hermes_cli/test_model_provider_persistence.py tests/agent/test_models_dev.py tests/agent/test_models_dev_meta_mapping.py tests/providers/test_fetch_models_base_url.py tests/providers/test_noauth_provider_flag.py` — all green. - Manual check of the modified path (`hermes doctor` reports `✓ <provider> (no-auth)`; picker resolves the provider's models with an empty key). - Environment note: 15 tests in `tests/plugins/` (fal, hindsight, disk-cleanup) fail locally because of unavailable external services. These failures reproduce **identically** on a clean `origin/main` checkout and are unrelated to this PR.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85270",
        "timestamp": "2026-08-13T12:17:00Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "duplicate",
          "type/feature",
          "comp/agent",
          "comp/cli",
          "area/auth",
          "P3",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility"
        ],
        "author": "FarisFLAIFIL",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85271",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): restore Docker sandbox artifact preview and downloads",
        "text": "## What does this PR do? Desktop users connected to a remote backend can now preview and download agent-generated files that live in a Docker sandbox bind mount. Previously, the backend treated paths such as `/workspace/report.txt` as host paths and returned 404 even though the artifact existed in the configured sandbox workspace. ### Symptom In an SSH-remote plus Docker topology, agent-produced files appeared in the Desktop downloads shelf, but preview, data URL reads, managed-file reads, and downloads could not retrieve them from the backend. ### Impact Users could generate artifacts successfully but could not preview or download them through Desktop. The failure affected persistent and explicitly configured Docker bind mounts; unmounted container-only files remain unavailable by design. ### Bug Cause **Trigger:** `hermes_cli/web_server.py:1951` / `_agent_read_path()` and the read-only `/api/fs` and `/api/files` routes. **Causal chain:** 1. An agent writes an artifact to a container-visible absolute path such as `/workspace/report.txt`. 2. Desktop sends that path to the remote backend, where read routes previously resolved it only against the backend host filesystem. 3. The host lookup misses the Docker bind mount and returns 404, so preview and download fail. **Why it is wrong:** The backend already knows the container-to-host bind-mount mapping, but the Desktop read routes did not apply it. On Windows, the mapping parser also split forward-slash host paths such as `C:/Users/...:/workspace` at the drive colon. **Working sibling / contrast:** Native MEDIA delivery already translates Docker container paths before host-side validation. This change reuses the same translation for read-only Desktop delivery rather than adding another mapping implementation. **Ruled out:** Client-local `file://` handling is not the cause. The requests reached the backend and failed there with host-side 404 responses. ### Fix The read-only filesystem and managed-file routes now translate agent-visible Docker paths through the existing longest-prefix mount resolver before file access. Container paths retain POSIX semantics on every host, forward-slash Windows volume specs parse correctly, and managed-root containment is enforced after translation. Host paths still use the existing fallback, while unmapped container paths still return 404. ## Related Issue Closes #85238 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `gateway/platforms/base.py` - preserve POSIX container path semantics and parse Windows Docker volume hosts correctly. - `hermes_cli/web_server.py` - translate Docker artifact paths for preview, managed reads, and downloads. - `tests/gateway/test_platform_base.py` - cover forward-slash Windows volume parsing. - `tests/hermes_cli/test_web_server_fs.py` - cover preview reads from the persistent Docker workspace. - `tests/hermes_cli/test_web_server_files.py` - cover managed downloads and post-translation root confinement. ## How to Test 1. Configure a Docker terminal environment with a persistent or explicitly mounted `/workspace`. 2. Generate a file at `/workspace/report.txt` from the container and request preview and download through the backend. 3. Verify all read routes return the container-written bytes, while a translated path outside the managed root returns 403. 4. Run the targeted tests: ```bash scripts/run_tests.sh tests/gateway/test_platform_base.py -k 'DockerContainerMediaPathTranslation' tests/hermes_cli/test_web_server_fs.py tests/hermes_cli/test_web_server_files.py ``` Result: 9 passed. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run the repository test entry on the relevant tests and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Windows 11 with a real Docker-compatible container runtime and live backend ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - N/A, no user-facing configuration changed - [x] I've updated `cli-config.yaml.example` if I added/changed config keys - N/A, no config keys changed - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows - N/A, no architecture or workflow changed - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) - [x] I've updated tool descriptions/schemas if I changed tool behavior - N/A, no tool schema changed ## Screenshots / Logs A live backend reproduced HTTP 404 for preview, data URL, managed read, and managed download before the fix. After the fix, all four routes returned HTTP 200 with byte-identical output, and a managed-root escape remained HTTP 403.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85271",
        "timestamp": "2026-08-13T12:16:53Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "backend/ssh",
          "comp/cli",
          "comp/gateway",
          "backend/docker",
          "P2",
          "sweeper:risk-message-delivery",
          "comp/desktop"
        ],
        "author": "fangliquanflq",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85273",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): avoid false stale for symlinked launchd venvs",
        "text": "## Summary - normalize only the parent directory of the first `ProgramArguments` executable when comparing launchd plists - treat aliases such as `venv -> .venv` as the same virtualenv without collapsing distinct virtualenvs that share a base interpreter - add regression coverage for both alias equality and distinct-venv inequality ## Why On macOS installs, `venv/bin/python` and `.venv/bin/python` can be aliases for the same virtualenv. Literal plist comparison reports `gateway status stale` indefinitely and can trigger unnecessary reloads. Resolving the executable's parent directory canonicalizes the alias while deliberately preserving the executable filename and virtualenv boundary. ## Verification - `HERMES_PYTHON=/Users/caiohenrique/.local/lib/hermes-agent/venv/bin/python scripts/run_tests.sh tests/hermes_cli/test_gateway_service.py -q` — 80 passed, 1 skipped - `python -m compileall -q hermes_cli/gateway.py tests/hermes_cli/test_gateway_service.py` — passed - `ruff check hermes_cli/gateway.py tests/hermes_cli/test_gateway_service.py` — passed - `ruff check .` — passed (pre-existing invalid `# noqa` warning in `run_agent.py`) - `git diff --check origin/main...HEAD` — passed - offline canary with the real `/Users/caiohenrique/.local/lib/hermes-agent/{venv,.venv}/bin/python` paths: before `False`, after `True`, same inode, runtime unchanged - `python scripts/check-windows-footguns.py --diff origin/main` — reports 7 pre-existing test-file findings outside this patch; changed production code introduced no findings ## Scope / safety - no gateway installation, restart, or runtime mutation was performed - source patch is cherry-picked from `55dc00e02f890d8dbee5a03a912b218da47e202d` onto current `origin/main` - no merge requested or performed",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85273",
        "timestamp": "2026-08-13T12:16:49Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "area/config",
          "P2",
          "sweeper:risk-compatibility"
        ],
        "author": "CAIOHSR",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85274",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): bound large composer image previews",
        "text": "## What does this PR do? Fixes #41169. Supersedes #68744 while preserving David Metcalfe's original authorship on its two commits, replayed onto current `main`. Follow-up commits harden the fix for the reproduced 72-image failure and the async races exposed during independent review. The Desktop composer previously retained full-resolution image data URLs and rendered them as attachment thumbnails. Large images forced Chromium to decode and rasterize full-size sources on renderer display surfaces. With 72 images, the renderer emitted: ```text Failed to serialize op in 16777152 bytes webContents became unresponsive render-process-gone reason=crashed exitCode=133 ``` The `Failed to serialize op` message comes from Chromium's raster/PaintOp path, not Electron IPC. The parent Electron process, backend, and gateway remained alive; Electron replaced the failed renderer. The original PR's 2048×2048 thumbnail ceiling can itself decode to 16,777,216 RGBA bytes — 64 bytes above the logged 16,777,152-byte PaintOp serialization failure — so the hardened ceiling is intentionally 512px rather than 2048px. This PR bounds the complete composer-preview pipeline rather than only the final `<img>` dimensions: - serializes local/gateway reads and thumbnail creation so only one full image is read/decoded at a time; - creates display thumbnails with `createImageBitmap` + `OffscreenCanvas`, with a 512px longest edge; - closes decoded `ImageBitmap` resources and fails closed to a 1×1 placeholder instead of rendering the original when conversion is unavailable or fails; - retains only the bounded `thumbnailUrl` in new composer attachment state, rather than dozens of multi-megabyte full-resolution data URLs; - reads the original path only when the lightbox is opened, then releases that data URL when the lightbox closes; - keeps model input full resolution: submit/upload reads the authoritative on-disk original independently of the display thumbnail; - binds async preview completion to a renderer-lifetime per-occurrence UUID that survives session-draft cloning, so both A → B → A switching and remove + same-path reattach remain race-safe; - retains local-first reads with gateway fallback for local and remote project paths; - after cross-filesystem staging rewrites `path`, falls back to the preserved original host path in `detail` for lightbox/download; - uses the bounded thumbnail for the optimistic in-flight message bubble too, and never falls back to rendering `@image:<path>` while a queued thumbnail is still pending. No new custom protocol, URL allowlist, or navigation permission is introduced. The existing `MEDIA:` / transcript image path tracked by #42109 remains out of scope. ## Related Issue Fixes #41169 Supersedes #68744. A maintainer can close the conflicted original after accepting this attributed replacement. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [x] ♻️ Refactor (no intended external behavior change beyond fixing the failure) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `apps/desktop/src/lib/image-resize.ts` - serial one-at-a-time resize queue; - 512px default longest edge; - explicit bitmap cleanup and fail-closed fallback. - `apps/desktop/src/app/chat/hooks/use-composer-actions.ts` - serializes the full read + resize pipeline; - stores only the bounded thumbnail for new image chips; - updates only the exact optimistic attachment occurrence captured before async preview work, preserving identity across draft clones while preventing same-path replacement races. - `apps/desktop/src/lib/desktop-fs.ts` - centralizes local-first image reads with active-gateway fallback. - `apps/desktop/src/app/chat/composer/attachments.tsx` - renders bounded thumbnails; - keys each pill by occurrence identity so a removed occurrence's deferred lightbox read cannot populate a same-path replacement; - loads the full image on demand for lightbox/download, falls back from a staged backend path to the preserved host path, and releases it on close. - `apps/desktop/src/lib/chat-runtime.ts` - uses the bounded thumbnail in the optimistic in-flight bubble. - `apps/desktop/src/store/composer.ts` - documents thumbnail versus legacy/on-demand full-source semantics; - provides UUID-backed occurrence-identity conditional patching shared by main and tile composers; - patches the main live atom and inactive per-session draft stashes while merging into the latest occurrence state, so thumbnail and staging completions cannot overwrite each other; - removes only the attachment occurrences captured by a successful submit: UUID-backed chips match by occurrence token, while legacy occurrence-less chips match by exact object identity; this preserves newer same-ID file/URL replacements while still clearing successfully staged submitted files. - `apps/desktop/src/app/chat/session-tile-actions.ts` - applies the same occurrence-aware staging merge in independently mounted session-tile composers, rejecting stale upload completion after remove + same-path reattach. - `apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts` - merges cross-filesystem staging metadata into the current occurrence instead of replacing concurrent preview state. Regression coverage verifies: - 72 image reads run one at a time; - 72 bitmap decodes run one at a time and every generated canvas is 512×512 or smaller; - composer state contains 72 bounded thumbnails and no full-resolution preview data; - removing an attachment during queued resize does not resurrect it; - removing and reattaching the same path cannot receive the removed occurrence's late thumbnail; - a delayed thumbnail resolving while another session is active updates only the original session's stashed draft and appears after returning; - thumbnail generation and submit-time staging may finish in either order in both the main and session-tile composers without dropping thumbnail, staged path, session ownership, or upload state; - remove + same-path reattach rejects stale staging completion in both composer scopes; - successful main and tile submits remove only their captured occurrences, preserve newer same-ID image and occurrence-less URL replacements added while submission is in flight, still clear successfully staged legacy files, and emit cleanup state exactly once; - an old occurrence's deferred full-image read cannot open or populate a same-path replacement lightbox; - lightbox full-source data is read only on open and released on close; - a split-filesystem image remains previewable after its `path` is rewritten to a gateway-staged path; - local-first and gateway-fallback paths remain supported; - optimistic messages prefer the bounded thumbnail and render no path-backed image while resize is pending; - resize failures and unsupported APIs fail closed. ## How to Test 1. Attach or paste one large screenshot and verify the composer remains responsive. 2. Attach many large images (the reproduced report used 72) and verify the renderer remains responsive. 3. Verify attachment pills and the optimistic user bubble render bounded thumbnails. 4. Open/download an attachment and verify the original full-resolution image is used. 5. Send the prompt and verify the model receives the original image, not the thumbnail. 6. Remove a chip while thumbnails are still being created and verify it does not reappear. Automated verification on CachyOS Linux with Node 22.23.2: ```bash LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 TZ=UTC NODE_ENV=test \\ NODE_OPTIONS='--max-old-space-size=8192 --localstorage-file=/tmp/hermes-pr68744-full-ui-en-localstorage.json' \\ npm --workspace apps/desktop run test:ui # 423 files passed; 3,819 tests passed NODE_ENV=test NODE_OPTIONS='--max-old-space-size=8192 --localstorage-file=/tmp/hermes-pr68744-exact-localstorage.json' \\ npm --workspace apps/desktop exec -- vitest run --project ui \\ src/lib/image-resize.test.ts \\ src/app/chat/hooks/use-composer-actions.test.ts \\ src/lib/chat-runtime.test.ts \\ src/app/chat/composer/attachments.test.tsx \\ src/lib/desktop-fs.test.ts \\ src/store/composer.test.ts \\ src/app/chat/composer/hooks/use-composer-draft.test.tsx \\ src/app/session/hooks/use-prompt-actions/index.test.tsx \\ src/app/chat/session-tile-attachments.test.tsx # 9 files passed; 216 tests passed NODE_ENV=test npm --workspace apps/desktop run typecheck # passed NODE_ENV=test npm --workspace apps/desktop run lint # 0 errors; repository baseline warnings only, none in changed files NODE_ENV=production npm --workspace apps/desktop run build # production renderer and Electron bundles built; assert-dist-built passed git diff --check # passed ``` Running the full UI suite without forcing an English locale initially produced four locale-sensitive assertion failures (`1 234 567`/`25 USD` formatting versus en-US expectations). Re-running under the CI locale passed all 3,813 tests. Mutation proof: in an isolated worktree of the exact candidate, bypassing the end-to-end preview queue made the 72-image regression fail with `maxActiveReads = 72` versus the required `1`. Restoring the candidate returned the focused suite to 74/74. ## Live CI Exact head `cd265e005e6a49319d97ca2fcf1b1d59edf4bf76`: [CI run 31726943548](https://github.com/NousResearch/hermes-agent/actions/runs/31726943548) completed successfully. `All required checks pass` is green, including Desktop UI, Desktop platform tests, lint, OSV, and both amd64/arm64 Docker builds. Final failed/pending counts: **0 / 0**. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for existing PRs; this is an attributed current-main replacement for conflicted #68744 - [x] My PR contains only changes related to this fix - [x] I've run the relevant Desktop automated tests - [x] I've added regression tests for the reported 72-image case - [x] I've tested on CachyOS Linux with Node 22.23.2 and a production Electron build ### Documentation & Housekeeping - [x] User documentation — N/A; no user-facing command or configuration changed - [x] `cli-config.yaml.example` — N/A; no config keys changed - [x] `CONTRIBUTING.md` / `AGENTS.md` — N/A; no workflow contract changed - [x] Cross-platform impact considered — uses Chromium/Electron-standard APIs with a safe fallback - [x] Tool descriptions/schemas — N/A; no agent tool changed ## For New Skills N/A — this PR does not add a skill.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85274",
        "createdAt": "2026-08-13T12:11:02Z",
        "updatedAt": "2026-08-13T17:47:18Z",
        "timestamp": "2026-08-13T17:47:18Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "P3",
          "comp/desktop"
        ],
        "author": "frizikk",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85275",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(webhook): queue verified events to target sessions",
        "text": "## Summary Adds an opt-in `queue_to` setting for verified webhook routes. It delivers the rendered webhook prompt as a normal, full user turn into an explicitly configured platform/chat/thread session, using the gateway runner’s existing FIFO machinery. - Preserves current independent webhook delivery when `queue_to` is absent. - Busy target sessions receive an ordered queued turn without inheriting the normal human-input interrupt behavior. - Validates malformed, self-referential, and unknown target configuration with safe 400 responses; unavailable targets return 503 and never fall back to an autonomous webhook session. - Pins target events to the authenticated webhook route’s effective profile (`default` included), preventing target chat profile routing from crossing profile boundaries. - Disables gateway slash-command handling for webhook-originated queued text. ## Validation - `python3 -m pytest -q tests/gateway/test_webhook_adapter.py::TestQueuedSessionAdmission tests/gateway/test_webhook_adapter.py::TestSessionIsolation tests/gateway/test_queue_consumption.py` - `14 passed` - `python3 -m py_compile gateway/platforms/webhook.py gateway/run.py tests/gateway/test_webhook_adapter.py tests/gateway/test_queue_consumption.py` - Direct malformed-platform webhook probe returns `400 {\"error\": \"Unknown queue_to.platform\"}`. - `git diff --check`",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85275",
        "timestamp": "2026-08-13T12:26:17Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "platform/webhook",
          "area/config",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery",
          "sweeper:risk-compatibility",
          "area/sessions"
        ],
        "author": "kaspernj",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85276",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): propagate compression exhaustion result",
        "text": "## What does this PR do? `TurnRunner.run_sync()` forwarded `compression_exhausted` from its empty-response return path, but dropped the flag when the agent returned a non-empty final response. Compression exhaustion itself produces a non-empty error response, so the gateway never received the flag and its existing session auto-reset recovery could not run. Subsequent messages therefore kept routing to the same exhausted session. This change forwards `compression_exhausted` from the normal return path as part of the turn-result contract. It also adds a behavioral regression test that exercises the real `TurnRunner.run_sync()` path with a non-empty exhaustion response and verifies that downstream consumers receive the flag. ## Related Issue Fixes #85145 ## Type of Change - [x] 🐛 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 - `gateway/run.py`: preserve `compression_exhausted` in the normal non-empty-response result returned by `TurnRunner.run_sync()`. - `tests/gateway/test_turn_context.py`: add a behavioral regression covering a non-empty compression-exhaustion response through the real turn runner. ## How to Test 1. Run: ```bash scripts/run_tests.sh \\ tests/gateway/test_turn_context.py \\ tests/gateway/test_35809_auto_reset_clean_context.py \\ tests/gateway/test_compression_deferred_soft_result.py \\ tests/run_agent/test_1630_context_overflow_loop.py -q ``` 2. Confirm all 20 focused tests pass. 3. Confirm `test_normal_response_preserves_compression_exhausted` returns `compression_exhausted=True`; before this patch it fails with `KeyError: 'compression_exhausted'`. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: native Windows, Python 3.11 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — N/A; no documentation changes are needed - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A; no config keys changed - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A; no architecture or workflow changes - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no platform-specific behavior was added - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A; no tool behavior changed ## Screenshots / Logs ```text Focused tests: 20 passed, 0 failed Ruff: passed Windows footgun scan: passed git diff --check: passed ``` The full local suite was attempted but is not marked green. After installing the locked `dev,all` dependencies, the ACP subset reported 139 passed and two existing native-Windows failures unrelated to this change: - `tests/acp/test_ping_suppression.py`: the Proactor event loop cannot attach the test's `os.pipe()` to IOCP (`WinError 6`). - `tests/acp_adapter/test_acp_images.py`: a Windows `file:///C:/...` URI is converted to a WSL `/mnt/c/...` path. Neither failing test imports or exercises the modified gateway return path.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85276",
        "timestamp": "2026-08-13T12:26:12Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "P1",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery",
          "area/sessions",
          "area/compression"
        ],
        "author": "uperLu",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85277",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(mcp): normalize numeric stdio environment values",
        "text": "## What does this PR do? Normalizes numeric YAML scalars in stdio MCP `env` mappings before constructing `StdioServerParameters`. OS process environments and the MCP SDK require string values. Hand-written YAML such as: ```yaml mcp_servers: my-server: command: /path/to/server env: MY_FLAG: 1 ``` currently loads `MY_FLAG` as an integer. `_build_safe_env()` passes it through unchanged, so the MCP SDK raises a Pydantic validation error, the server is parked, and its tools never register. The fix preserves existing strings, converts finite integer/float scalars to strings, and rejects ambiguous values such as booleans, nulls, containers, and non-finite floats instead of silently converting them to Python representations like `\"True\"` or `\"None\"`. ## Related Issue Fixes #85223 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `tools/mcp_tool.py` - Add a narrow stdio environment-value normalizer. - Preserve strings and stringify finite numeric YAML scalars. - Fail closed for booleans, nulls, containers, and non-finite floating-point values. - `tests/tools/test_mcp_tool.py` - Add regression coverage for integer and float normalization. - Add rejection coverage for ambiguous YAML values. - Verify `MCPServerTask` passes the normalized string to `StdioServerParameters`. ## How to Test 1. Configure a stdio MCP server with an unquoted numeric environment value such as `COUNT: 1`. 2. Start or refresh MCP discovery. 3. Verify the SDK receives `{\"COUNT\": \"1\"}` and no longer raises an `env.COUNT` validation error. Automated verification: ```bash HERMES_PYTHON=/Users/wyy/.hermes/venvs/hermes-contrib/bin/python \\ scripts/run_tests.sh \\ tests/tools/test_mcp_tool.py \\ tests/hermes_cli/test_mcp_config.py ``` Result: ```text 141 tests passed, 0 failed ``` Additional checks: ```text ruff check: passed compileall: passed git diff --check: passed independent review: passed after remediation ``` The full test suite was also attempted in the clean dev environment. It reached 53.9% before being stopped after seven unrelated baseline/environment failures. A representative failure, `tests/gateway/test_shutdown_forensics.py`, reproduced independently and does not touch the files changed here. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 13.7.8, Python 3.11.15 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — docstring updated; standalone docs N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A - [x] I've considered cross-platform impact (Windows, macOS) per the compatibility guide — process environments are string-valued on all supported platforms - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A ## Screenshots / Logs RED before the implementation: ```text AssertionError: {'COUNT': 1, 'PORT': 8080} != {'COUNT': '1', 'PORT': '8080'} ``` GREEN after the implementation: ```text === Summary: 2 files, 141 tests passed, 0 failed === ```",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85277",
        "timestamp": "2026-08-13T12:26:08Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "tool/mcp",
          "area/config",
          "P2",
          "sweeper:risk-compatibility"
        ],
        "author": "w-up",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85279",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(plugins): queue-safe message injection, non_control H-107, on_session_open hook",
        "text": "## What this adds Three features not on upstream main, built on current `nous/main` (fa83af3f9): 1. **`mode=queue/steer/interrupt` with queue as safe default** — busy sessions are never interrupted; messages queue at a safe boundary and inject after the active tool completes. Upstream main's `inject_message` INTERRUPTS busy sessions. 2. **`non_control` flag (H-107 inert-control guarantee)** — injected text can never reach slash-command or shell dispatch. Upstream main has no `non_control` guard. 3. **`on_session_open` lifecycle hook + session-open injection router** — host-owned router registry for TUI + gateway surfaces. Concrete consumer: the peer delivery seam (walkie-talkie). Also threads `old_session_id` through `on_session_reset` for idempotent session-open release. ## Why Upstream main already absorbed a simpler `inject_message` API via a sibling PR. This PR is a safer superset: queue-safe injection prevents active-tool interruption, `non_control` prevents injected text from reaching dangerous dispatch paths, and `on_session_open` enables host-driven injection routing. ## Testing - 36 related tests pass: - `tests/hermes_cli/test_plugin_message_injection.py` - `tests/gateway/test_plugin_message_injection.py` - `tests/gateway/test_async_session_store.py` - `tests/gateway/test_unknown_command.py` - Full gateway suite: 5404 passed, 9 failed — all 9 verified as order-dependent flakes or pre-existing on clean main (reproduced on clean HEAD via stash test). - `py_compile` passes on all 8 changed files. ## Files changed (8, +663/-87) - `cli.py` — `is_injected_input` barrier + H-107 guards on bang-shell and slash-command dispatch; `old_session_id` threading; CLI `inject_message` with mode/session_key - `gateway/run.py` — `inject_plugin_message` (async seam), `non_control` gate in `_handle_message`, `_construct_agent_with_session_open`, router registration, async session-store facade - `gateway/platforms/base.py` — `non_control` field on `MessageEvent` - `hermes_cli/plugins.py` — `mode=queue/steer/interrupt` + `session_key`, `on_session_open` release machinery, `_INJECTION_ROUTERS` registry - `tui_gateway/server.py` — `inject_external_message` (host router) - `tui_gateway/methods_session.py` — 3x `_notify_session_open` call-sites - `website/docs/user-guide/features/plugins.md` — documented - `tests/hermes_cli/test_plugin_message_injection.py` — updated 2 CLI tests to mock `cli.inject_message` (new API contract)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85279",
        "timestamp": "2026-08-13T12:25:24Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "Sahil-SS9",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85280",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): collapse provider aliases in the fallback-chain dedup",
        "text": "Fixes #85235 ## What & why The fallback-chain dedup compares each entry's provider id as a raw, lowercased string. A provider reachable under two ids — the canonical models.dev id (`opencode`) and a legacy rename alias (`opencode-zen`, the pre-rename name that survives only as a compatibility alias) — is kept as two separate slots in the chain. That shifts the fallback chain by one slot and lets an alias slip past the same-backend skip: the turn can land on the alias slot, the rate-limit recovery pool keeps returning true against a pool that gets re-seeded between retries, and the turn dies with \"API call failed after 3 retries\" without ever reaching the paid fallback. The skip is also silent (`logger.debug`). ## The fix Normalize the provider id (via a lazy `normalize_provider` import, to avoid module cycles) before computing `_entry_identity`, so aliases collapse onto the canonical id for dedup. The returned chain still carries each entry's **original** provider id — only the dedup identity is normalized, so the canonical id wins and the alias is dropped. ## How I tested it - New `test_fallback_chain_alias_dedup.py` reproduces the bug first (red: `['opencode', 'opencode-zen', 'anthropic']`), then passes after the fix (`['opencode', 'anthropic']`). - `test_custom_provider_session_persistence.py` + `test_scheduler_cron_session_isolation.py`: 12 passed. - `ruff check` and `ruff format` clean. ## Checklist - [x] PR title follows conventional commits - [x] Reproduced the bug on current `main` before fixing - [x] Tests red-before / green-after - [x] Lint clean",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85280",
        "timestamp": "2026-08-13T12:26:08Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "andyst-dev",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85289",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(models): add openrouter.show_all_models opt-in to surface every live tool-capable model",
        "text": "## Summary Adds an opt-in config flag, `openrouter.show_all_models: true`, that makes the OpenRouter picker return **every live model supporting tool-calling** from `GET /v1/models` instead of only the curated subset. Default behavior is unchanged (curated list). This lets BYOK users — or anyone who wants the full list — see models that aren't in the curated catalog, e.g. brand-new models released between catalog refreshes. ## Motivation The curated catalog is a deliberate UX choice, but users who Bring-Your-Own-Key (e.g. Google Gemini keys via OpenRouter BYOK) frequently find the models they configured aren't in the curated list. Rather than expanding the curated list (which maintainers have declined for good reason), this makes the full live list available behind an opt-in flag — no curated-list changes needed. ## Changes - `hermes_cli/models.py`: in `fetch_openrouter_models()`, when `openrouter.show_all_models` is truthy in config.yaml, rebuild `preferred_ids` from every live model that passes `_openrouter_model_supports_tools()`. - `tests/hermes_cli/test_models.py`: two new tests — opt-in path surfaces uncurated tool-capable models (and still filters non-tool ones); default path keeps non-curated models hidden. ## Usage ```yaml # config.yaml openrouter: show_all_models: true ``` ## Testing `python -m pytest tests/hermes_cli/test_models.py` → 32 passed.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85289",
        "createdAt": "2026-08-13T12:44:22Z",
        "updatedAt": "2026-08-13T12:59:59Z",
        "timestamp": "2026-08-13T12:59:59Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "provider/openrouter",
          "P3",
          "sweeper:risk-compatibility"
        ],
        "author": "TCK0423",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85290",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(delegation): independent fallback chain for subagents",
        "text": "## What does this PR do? Adds `delegation.fallback_providers` so subagents can run on a fallback chain that differs from the parent agent's top-level `fallback_providers`. Today `_build_child_agent` unconditionally passes the parent's `_fallback_chain` as the child's `fallback_model`, so a subagent pinned to a cheap/fast provider still fails over to the main agent's fallback models instead of a subagent-appropriate backup. Concrete use case: main agent runs `openrouter/deepseek-v4-pro-0813` with `openrouter/glm-5.2` as fallback, while subagents run `openrouter/deepseek-v4-flash-0731` and should fail over to a local `ascent-dspark` endpoint — not to the main agent's fallback chain. ## Related Issue Fixes #65038 ## Type of Change - [x] ✨ New feature (non-breaking change that adds functionality) - [x] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) ## Changes Made - `hermes_cli/config_defaults.py` — new `delegation.fallback_providers: []` default (empty = inherit parent chain, backward compatible) - `tools/delegate_tool.py` — `_normalize_delegation_fallback_chain()` validates entries against the provider+model contract; `_resolve_delegation_credentials()` forwards the chain; `_build_child_agent()` accepts `override_fallback_providers` and falls back to the parent chain when unset/empty; delegate tool description mentions the knob - `tests/tools/test_delegate.py` — 3 new tests: override wins, empty list inherits parent chain, malformed entries are dropped - `website/docs/user-guide/configuration.md` + `website/docs/user-guide/features/fallback-providers.md` — document the new key",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85290",
        "createdAt": "2026-08-13T12:46:50Z",
        "updatedAt": "2026-08-13T12:59:54Z",
        "timestamp": "2026-08-13T12:59:54Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "tool/delegate",
          "area/config",
          "P3",
          "sweeper:risk-compatibility"
        ],
        "author": "motochan",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85291",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): preserve governing outcome across compaction handoffs",
        "text": "## Summary - preserve the user's governing outcome separately from the current subtask, latest correction, and one reference-only next step in local compaction handoffs - validate the continuation schema before persistence, including deterministic fallback and no-user-provenance paths - keep micro-compaction noncanonical while preserving chronological authority across post-summary and merged-tail continuations ## Root cause The local context summarizer preserved recent task history but did not encode which user outcome remained authoritative versus a subtask, correction, or candidate next action. After compaction, a context-dependent follow-up such as \"continue\" or \"what next?\" could therefore resolve to a stale or superseded route. ## Scope This is intentionally limited to the local `ContextCompressor` handoff described in #78457. It does not change native server compaction or material-intervention/tool-lifecycle behavior. #85156 remains complementary: it preserves plaintext user messages before native compaction checkpoints, while this change strengthens the semantic handoff produced by the local summarizer. ## Validation - focused compaction matrix: 271 passed - neighboring compaction matrix: 88 passed - ghost-skill regression suite: 13 passed - compressed-summary metadata suite: 9 passed - native compaction non-interference suite: 45 passed - full `tests/agent/`: no additional failures versus a clean `origin/main` baseline; the same 18 files / 32 environment or baseline failures reproduced on both trees - `git diff --check` Addresses the compaction-handoff portion of #78457.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85291",
        "createdAt": "2026-08-13T12:50:54Z",
        "updatedAt": "2026-08-13T12:59:45Z",
        "timestamp": "2026-08-13T12:59:45Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P2",
          "needs-decision",
          "sweeper:risk-session-state",
          "area/compression"
        ],
        "author": "akivavh",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85292",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cli): override MiniMax API-key overlay to Anthropic endpoint",
        "text": "## What does this PR do? The MiniMax **API-key** overlay advertises `anthropic_messages` but had no `base_url_override`. `get_provider()` therefore kept the models.dev OpenAI-style URL (`https://api.minimax.io/v1`). Worse, `hermes setup` persists that `/v1` URL into `model.base_url`, and the runtime pool path treated it as an explicit override of the registry `/anthropic` default — so every call 404'd. This PR: 1. Sets `base_url_override` on the API-key overlays (`minimax` / `minimax-cn`) to the same Anthropic endpoints as OAuth / `PROVIDER_REGISTRY`. 2. Remaps only the known stale catalog URLs (`api.minimax.io/v1`, `api.minimaxi.com/v1`) to `/anthropic` when resolving a MiniMax runtime. A user-set China or custom host is left alone. `MINIMAX_BASE_URL` still wins. ## Related Issue Fixes #84838 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_cli/providers.py`: set `base_url_override` on `HERMES_OVERLAYS[\"minimax\"]` and `[\"minimax-cn\"]`. - `hermes_cli/runtime_provider.py`: remap persisted `/v1` catalog defaults on both the pool and env-key MiniMax paths. - Tests: overlay vs fake models.dev `/v1`; runtime remaps persisted `/v1`; explicit custom `/anthropic` host is kept. ## How to Test 1. On `origin/main`, `HERMES_OVERLAYS[\"minimax\"].base_url_override` is empty. `get_provider(\"minimax\")` can inherit models.dev `https://api.minimax.io/v1`. 2. On this branch, `pytest tests/hermes_cli/test_api_key_providers.py::TestProviderRegistry::test_minimax_overlay_overrides_models_dev_openai_url tests/hermes_cli/test_api_key_providers.py::TestRuntimeProviderResolution::test_runtime_minimax -q` passes. 3. `MINIMAX_API_KEY=… hermes` with provider `minimax` and no custom `model.base_url` should talk to `https://api.minimax.io/anthropic`. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85292",
        "createdAt": "2026-08-13T12:55:34Z",
        "updatedAt": "2026-08-13T15:16:09Z",
        "timestamp": "2026-08-13T15:16:09Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "provider/minimax",
          "P3"
        ],
        "author": "Christopher-Schulze",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85293",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat: add Nous Blue Contrast theme",
        "text": "## Summary - add a separate `nous-blue-contrast` dashboard preset that preserves Nous Blue chrome while using a navy, high-contrast embedded terminal - add a matching built-in TUI skin with a complete audited dark palette - expose both choices in dashboard configuration and document them - keep the existing `nous-blue` theme unchanged ## Test plan - `scripts/run_tests.sh tests/hermes_cli/test_skin_engine.py tests/hermes_cli/test_skin_palettes.py` (35 passed) - `npm run check --workspace web` (200 tests passed; typecheck and lint passed) ## Notes - independent pre-commit review found no blocking, security, or privacy issues - lint reports only the existing repository warnings, with no errors",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85293",
        "createdAt": "2026-08-13T12:56:52Z",
        "updatedAt": "2026-08-13T16:18:40Z",
        "timestamp": "2026-08-13T16:18:40Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "area/config",
          "P3",
          "comp/dashboard"
        ],
        "author": "gedigi",
        "state": "closed",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85296",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(delegation): wait before parent conclusions",
        "text": "## Summary - make model-facing `delegate_task` calls synchronous by default so parents receive every subagent result before concluding - preserve detached execution behind explicit `background=true` for independent work - keep orchestrator workers synchronous even if a model supplies `background=true` - update the tool schema, bundled skill reference, and delegation docs ## Why Automatic background delegation lets the parent send an answer before its evidence-gathering children finish. Their later completion then re-enters as a second turn, often producing a repetitive correction. Waiting by default gives the parent one consolidated evidence set and one conclusion. ## Validation RED: the new default-wait dispatch test failed against the previous forced-background behavior. GREEN: `scripts/run_tests.sh tests/tools/test_delegate.py tests/tools/test_async_delegation.py tests/tools/test_delegate_apiserver_background.py tests/cli/test_cli_delegate_background_notice.py -q` Result: 92 passed. ## Compatibility Explicit `background=true` keeps the current detached completion path for callers that truly want independent work. Direct Python callers already defaulted to synchronous execution.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85296",
        "createdAt": "2026-08-13T12:59:07Z",
        "updatedAt": "2026-08-13T13:13:57Z",
        "timestamp": "2026-08-13T13:13:57Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "tool/delegate",
          "P2",
          "needs-decision"
        ],
        "author": "etothej7",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85298",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(discord): free-response channel を thread-first に保つ",
        "text": "## 概要 - Discord の `free_response_channels` を「メンション不要」に限定し、auto-thread 抑止条件から外しました。 - 親チャンネルへ直返信する制御は `no_thread_channels` のみが担当するようにしました。 - free-response / no-thread / 両方指定時の routing 契約をテストで固定しました。 ## 背景 `discord.auto_thread=true` かつ `discord.free_response_channels` 指定時に、free-response が auto-thread 抑止としても扱われるため、mentionless な親投稿へスレッドを作らず親チャンネルへ直接返信していました。 ## 実施したテスト - `uv run --extra dev pytest tests/gateway/test_discord_free_response.py`",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85298",
        "createdAt": "2026-08-13T12:59:13Z",
        "updatedAt": "2026-08-13T13:05:30Z",
        "timestamp": "2026-08-13T13:05:30Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [],
        "author": "metamon-p",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85299",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(install): provision Node 26 so managed npm satisfies engines",
        "text": "## What does this PR do? `scripts/install.sh` still provisioned Hermes-managed **Node 22**, whose stock npm is **11.16.0**. Root `engines.npm` is `<11.10.0 || >=11.17.0` (npm 11.10–11.16 ignore `.npmrc` `min-release-age-exclude`, so that band is fatal under `engine-strict=true`). A fresh managed install therefore dies at the first `npm ci` with EBADENGINE — the report in #80769. Node 26 ships npm 11.17.0. Point `NODE_VERSION` at 26 so the runtime we download satisfies the floor we declare. `install.sh` already warns \"Hermes requires Node >=26\"; the pin was just stale. ## Related Issue Fixes #80769 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `scripts/install.sh`: `NODE_VERSION=\"26\"` (latest-v26.x from nodejs.org). - `tests/test_engines_satisfiable.py`: assert the stock npm bundled with the managed Node major satisfies `engines.npm`. ## How to Test 1. On `origin/main`, `NODE_VERSION` is `22`. Node 22's stock npm is 11.16.0, which `engines.npm` rejects. 2. `pytest tests/test_engines_satisfiable.py -q` on this branch — 11 passed. 3. A fresh `install.sh` managed Node now comes from `latest-v26.x`. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — or N/A - [x] I've updated `cli-config.yaml.example` — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — or N/A - [x] I've considered cross-platform impact — or N/A - [x] I've updated tool descriptions/schemas — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85299",
        "createdAt": "2026-08-13T13:03:09Z",
        "updatedAt": "2026-08-13T13:13:51Z",
        "timestamp": "2026-08-13T13:13:51Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P1",
          "sweeper:risk-compatibility",
          "area/install-update"
        ],
        "author": "Christopher-Schulze",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85300",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): classify local EMFILE/ENFILE failures",
        "text": "## Summary - classify local POSIX `EMFILE`/`ENFILE` descriptor exhaustion in gateway error envelopes - show a static, actionable gateway-host diagnostic on chat surfaces without leaking local paths or exception details - preserve raw diagnostics for programmatic/local/API/webhook surfaces and avoid rewriting ordinary prose - add focused gateway classification tests and a bounded SessionDB load/teardown regression test ## Scope This PR contains only classification/diagnostic behavior and its regression coverage. It does **not** change connection/session lifecycle management, reconnect teardown, SessionDB pooling, or launchd resource limits; those lifecycle/resource fixes are already upstream in `origin/main` (commits `5986cdd380`, `4b06c98fe4`, `87aedbe7b6`, `0472c31aa1`, and `585cee1a42`). ## Verification - `HERMES_PYTHON=/Users/caiohenrique/.local/lib/hermes-agent/.venv/bin/python scripts/run_tests.sh tests/gateway/test_telegram_noise_filter.py tests/test_session_db_read_conn_pool.py` - 168 tests passed - `py_compile` on all three changed files - `git diff --check` No runtime files under `/Users/caiohenrique/.local/lib/hermes-agent` were modified.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85300",
        "createdAt": "2026-08-13T13:03:22Z",
        "updatedAt": "2026-08-13T13:13:46Z",
        "timestamp": "2026-08-13T13:13:46Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "P2",
          "sweeper:risk-message-delivery"
        ],
        "author": "CAIOHSR",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85309",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat: add Nous Blue Contrast theme",
        "text": "## Summary - add a separate `nous-blue-contrast` dashboard theme that preserves the existing Nous Blue chrome while using a dark navy embedded terminal - add an independently selectable TUI/CLI skin with a complete high-contrast Nous Blue palette - expose both additions through existing catalogs and settings, with user documentation ## Motivation The existing `nous-blue` dashboard theme intentionally remains a light theme. The embedded TUI can benefit from a dark terminal canvas and matching truecolor skin without changing that existing appearance. ## Test plan - [x] `npm test -- --run src/themes/presets.test.ts` (4 passed) - [x] `npm run typecheck` - [x] `npm run build` - [x] targeted Python skin/palette tests before rebasing (35 passed) - [x] targeted dashboard theme tests before rebasing (9 passed) - [ ] post-rebase Python rerun unavailable locally: the only standalone pytest interpreter is Python 3.9, while current `main` imports Python 3.10+ union syntax; CI should run these with the supported interpreter ## Notes for reviewers - `nous-blue` is unchanged. - Dashboard themes and TUI skins remain independent configuration layers; selecting one does not silently activate the other. - The palette tests cover dark polarity, foreground contrast, shared identity anchors, and built-in registration.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85309",
        "createdAt": "2026-08-13T13:06:47Z",
        "updatedAt": "2026-08-13T13:25:48Z",
        "timestamp": "2026-08-13T13:25:48Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "duplicate",
          "type/feature",
          "comp/cli",
          "area/config",
          "P3",
          "comp/dashboard"
        ],
        "author": "gedigi",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85310",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(compress): persist in-place prompt with transcript rewrite",
        "text": "## What does this PR do? In-place context compaction already rewrites the transcript atomically via `archive_and_compact()`, then called `update_system_prompt()` in a **second** write. A crash or SQLite failure between those two commits left a session with compacted messages and the old system prompt (split-brain on resume). Rotation already published prompt + handoff in one transaction. This PR gives the in-place path the same contract: `archive_and_compact(..., system_prompt=...)` stores the new prompt in the same `BEGIN IMMEDIATE` as the archive. A failed prompt store rolls back the transcript rewrite. ## Related Issue Fixes #84722 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_state.py`: optional `system_prompt` on `archive_and_compact`, written in the same transaction as the message archive / `model_config_patch`. - `agent/conversation_compression.py`: in-place path passes `new_system_prompt` into `archive_and_compact` and no longer calls `update_system_prompt` afterwards. - `tests/run_agent/test_compression_persistence.py`: success path plus a store-failure rollback. ## How to Test 1. On `origin/main`, in-place compaction calls `update_system_prompt` after `archive_and_compact` (two writes). 2. `pytest tests/run_agent/test_compression_persistence.py::TestFlushAfterCompression::test_archive_and_compact_persists_system_prompt_in_same_write -q` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — or N/A - [x] I've updated `cli-config.yaml.example` — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — or N/A - [x] I've considered cross-platform impact — or N/A - [x] I've updated tool descriptions/schemas — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85310",
        "createdAt": "2026-08-13T13:07:22Z",
        "updatedAt": "2026-08-13T13:25:39Z",
        "timestamp": "2026-08-13T13:25:39Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P2",
          "sweeper:risk-session-state",
          "area/sessions",
          "area/compression"
        ],
        "author": "Christopher-Schulze",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85311",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(whatsapp): do not send pairing codes in self-chat mode",
        "text": "## What does this PR do? WhatsApp **self-chat** is the owner's personal number, not a public bot. With the default `WHATSAPP_DM_POLICY=pairing`, unknown DMs still received `Here's your pairing code: …` — confusing and unprofessional (#84706). Self-chat now defaults unauthorized DMs to **ignore**, even when `dm_policy` is `pairing`. An explicit `unauthorized_dm_behavior` override still wins. Bot mode is unchanged and still pairs when no allowlist is set. ## Related Issue Fixes #84706 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `plugins/platforms/whatsapp/adapter.py`: remember `_whatsapp_mode` from config/env. - `gateway/authz_mixin.py`: `_get_unauthorized_dm_behavior` returns `ignore` for WhatsApp self-chat. - `tests/gateway/test_unauthorized_dm_behavior.py`: self-chat + pairing policy stays silent; bot-mode pairing test sets `WHATSAPP_MODE=bot`. ## How to Test 1. `pytest tests/gateway/test_unauthorized_dm_behavior.py -q` 2. Configure `WHATSAPP_MODE=self-chat`, `WHATSAPP_DM_POLICY=pairing`, allowlist yourself; a stranger DM must not receive a pairing code. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — or N/A - [x] I've updated `cli-config.yaml.example` — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — or N/A - [x] I've considered cross-platform impact — or N/A - [x] I've updated tool descriptions/schemas — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85311",
        "createdAt": "2026-08-13T13:12:25Z",
        "updatedAt": "2026-08-13T13:25:33Z",
        "timestamp": "2026-08-13T13:25:33Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "comp/plugins",
          "platform/whatsapp",
          "area/config",
          "P3",
          "sweeper:risk-message-delivery",
          "sweeper:risk-compatibility"
        ],
        "author": "Christopher-Schulze",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85312",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "docs: add Arabic (ar) translation for README",
        "text": "## Description Adds an Arabic translation of `README.md`. Arabic is already a supported language in the product (`agent/i18n.py`, desktop/web `ar.ts` locales) but had no README translation. ### Changes made: - Added `README.ar.md` following the `README.ur-pk.md` conventions: `<div dir=\"rtl\">` wrapper, `<div dir=\"ltr\">` around code blocks and command tables. - Terminology matches the repo's existing `ar.ts` locales. Technical terms without an established Arabic form keep the English in parentheses on first use — e.g. «بنية بلا خوادم (serverless)». - Added the Arabic language badge to the four existing READMEs. The badge uses pre-shaped Presentation Forms-B glyphs (`ﺍﻟﻌﺮﺑﻴﺔ`) because shields.io does no Arabic shaping — plain `العربية` renders as disconnected letters. ## Type of change - [x] Documentation / Translation addition ## How Has This Been Tested? - Rendered in Chromium: RTL prose, LTR code blocks, tables, and the badge all display correctly. - Checked cross-links between all five READMEs. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own changes - [x] I have made corresponding changes to the documentation",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85312",
        "createdAt": "2026-08-13T13:15:40Z",
        "updatedAt": "2026-08-13T13:25:28Z",
        "timestamp": "2026-08-13T13:25:28Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/docs",
          "P3",
          "area/i18n"
        ],
        "author": "aghyad97",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85313",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): replay MiniMax thinking blocks on Anthropic endpoint",
        "text": "Fixes #85251 ## Problem `_manage_thinking_signatures` in `agent/anthropic_adapter.py` strips **all** thinking blocks from replayed assistant turns for third-party Anthropic-Messages endpoints. MiniMax was grouped into that generic \"cannot validate them and will reject them outright\" bucket, but that premise does not hold for MiniMax: it returns *signed* thinking blocks and accepts them back verbatim. MiniMax is an interleaved-thinking model, so stripping them costs real agentic performance — by MiniMax's own published figures, Tau² 87 → 64 and BrowseComp 44.0 → 31.4. Kimi (#13848) and DeepSeek (#16748) each already have an exception for the same class of problem; MiniMax had none. ## Fix Give MiniMax the same replay-as-is treatment as the Kimi family: in `_manage_thinking_signatures`, add a branch for `_is_minimax_anthropic_endpoint` (which already exists and covers both `api.minimax.io/anthropic` and `api.minimaxi.com/anthropic`) that preserves thinking blocks on replay. Also drop MiniMax from the docstring's \"will reject them outright\" list, since that sentence made the old behavior look intentional. ## Validation - New regression test `tests/agent/test_minimax_thinking_replay.py`: - Both MiniMax hosts keep their signed thinking blocks on replay (fail before the fix, pass after). - An unrelated third-party host still loses them (control). - Existing Kimi / DeepSeek / MiniMax / thinking-block-order suites: 30 passed. - `ruff check` clean. Diff is minimal: one branch + docstring in the adapter, one new test file.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85313",
        "createdAt": "2026-08-13T13:17:42Z",
        "updatedAt": "2026-08-13T13:25:24Z",
        "timestamp": "2026-08-13T13:25:24Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/agent",
          "provider/minimax",
          "P3",
          "needs-decision",
          "sweeper:risk-compatibility"
        ],
        "author": "andyst-dev",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85314",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(plugins): keep lifecycle hooks aligned across session rotation surfaces",
        "text": "## What does this PR do? This fixes lifecycle plugin hooks that received different session transition and workspace payloads depending on whether `/new` ran through the CLI, TUI, or messaging gateway. Without this fix, plugins cannot reliably release old-session resources, initialize the replacement session, or associate model turns with the authoritative task workspace. ### Symptom An explicit `/new` produced incomplete or inconsistent `on_session_finalize` and `on_session_reset` keyword arguments across surfaces. `pre_llm_call` also omitted the authoritative task/session workspace, while some lifecycle paths could substitute process state for an unproven session cwd. ### Impact Native plugins that coordinate per-session state cannot distinguish the old and new session consistently across CLI, TUI, and Gateway. Workspace-aware plugins can attribute lifecycle events or model turns to the wrong directory, and TUI prewarming could emit reset before the old session was finalized. ### Bug Cause **Trigger:** `cli.py::_start_new_session`, `ui-tui/src/app/useSessionLifecycle.ts::startNewSession`, `gateway/slash_commands.py::_handle_new_session`, and `agent/turn_context.py::build_turn_context` **Causal chain:** 1. A user starts a new session or a model turn from a surface with an authoritative session/task workspace. 2. Each surface constructs plugin hook payloads independently, omitting transition IDs or cwd on some paths. The TUI also created the replacement session before associating it atomically with teardown of the old session. 3. Plugins observe surface-dependent payloads, and TUI plugins can observe reset before finalize. **Why it is wrong:** Lifecycle payloads describe one semantic session transition, so their identifiers, reason, cwd, and ordering must not depend on the client surface. A process cwd is not evidence of a session workspace. **Working sibling / contrast:** Gateway already supplied old and new session IDs for explicit rotation, but it lacked authoritative cwd. CLI, TUI, and pre-LLM paths each supplied different subsets of the same context. **Ruled out:** This is not a plugin callback compatibility issue. Hook dispatch already filters additive keyword fields for callbacks with narrow signatures; the failure was at the surface-specific payload construction and TUI transition ordering. ### Fix - Add a shared authoritative session cwd resolver that uses recorded task/session state and returns an empty value when no cwd is proven. - Add consistent `old_session_id`, `new_session_id`, `reason`, and `cwd` fields to explicit session rotation hooks across CLI, TUI, and Gateway. - Include authoritative cwd in `pre_llm_call` payloads. - Associate TUI rotations atomically and defer the replacement-session reset until old-session finalization, without fabricating transitions for ordinary startup, resume, or shutdown. ## Related Issue Closes #85262 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `agent/runtime_cwd.py` and `agent/turn_context.py` - resolve and expose authoritative session/task cwd without a process-cwd fallback. - `cli.py` - emit complete explicit session transition payloads. - `gateway/run.py` and `gateway/slash_commands.py` - propagate current session identity and authoritative cwd to model-turn and rotation hooks. - `tui_gateway/methods_session.py`, `tui_gateway/server.py`, `ui-tui/src/app/useSessionLifecycle.ts`, and `ui-tui/src/gatewayTypes.ts` - preserve old-session workspace context and enforce finalize-before-reset ordering. - `tests/` and `ui-tui/src/__tests__/useSessionLifecycle.test.ts` - cover payload parity, unknown cwd behavior, rotation ordering, and gateway/TUI races. ## How to Test 1. Register a native plugin that records `pre_llm_call`, `on_session_finalize`, and `on_session_reset` keyword arguments. 2. Start a session in an explicit workspace through CLI, TUI, or Gateway, send one turn, and run `/new`. 3. Confirm finalize precedes reset, both lifecycle hooks carry identical transition IDs, reason, and authoritative cwd, and ordinary startup does not invent a transition. 4. Run the related automated suites: ```bash scripts/run_tests.sh tests/agent/test_runtime_cwd.py tests/agent/test_api_content_sidecar.py tests/cli/test_session_boundary_hooks.py tests/test_tui_gateway_server.py tests/gateway/test_35994_reset_button_deadlock.py cd ui-tui && npm test -- --run src/__tests__/useSessionLifecycle.test.ts cd ui-tui && npm run typecheck ``` The Python suite passed 597 tests, the targeted TUI suite passed 7 tests, TypeScript typecheck passed, and the lifecycle contract was verified with a real native plugin across CLI, TUI, Gateway, and pre-LLM paths on Windows 11. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run the repository test entry on the relevant suites and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Windows 11 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - N/A, no user-facing configuration or documented API changed - [x] I've updated `cli-config.yaml.example` if I added/changed config keys - N/A, no config keys changed - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows - N/A, the existing additive plugin hook contract is unchanged - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) - [x] I've updated tool descriptions/schemas if I changed tool behavior - N/A, no model tool behavior changed ## Screenshots / Logs Not applicable. Verification results are listed in How to Test.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85314",
        "createdAt": "2026-08-13T13:21:13Z",
        "updatedAt": "2026-08-13T16:12:24Z",
        "timestamp": "2026-08-13T16:12:24Z",
        "metrics": {
          "reactions": 0,
          "comments": 3
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/cli",
          "comp/gateway",
          "comp/tui",
          "comp/plugins",
          "P3",
          "sweeper:risk-session-state",
          "area/sessions"
        ],
        "author": "fangliquanflq",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85316",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(title): honor reasoning_effort so thinking can be disabled on title generation",
        "text": "## Summary Closes part of #83390 (the pre-existing DeepSeek thinking-leak on `title_generation`). The `title_generator.py` docstring claimed thinking was disabled, but `generate_title()` set neither `reasoning_config` nor `reasoning_effort`. On reasoning-aware providers whose default is thinking ON (DeepSeek V4 family, including via OpenCode Go), the model burns the 64-token title budget on `reasoning_content` and returns an empty `content`. This PR reuses Hermes' existing provider-agnostic `auxiliary.title_generation.reasoning_effort` knob instead of hardcoding a provider-specific wire field: - When `auxiliary.title_generation.reasoning_effort: none` (or a disabled synonym) is configured, `generate_title()` now passes `reasoning_config={\"enabled\": False}`. - Reasoning-aware provider profiles (`DeepSeekProfile`, `OpenCodeGoProfile`) translate `reasoning_config={\"enabled\": False}` into native wire controls (`thinking.type: disabled`). - When unset, the provider default stands (thinking ON for DeepSeek V4). Providers without reasoning controls are untouched. ## Why provider-agnostic instead of hardcoding `thinking`? PR #83725 proposes unconditionally setting `extra_body.thinking = {\"type\": \"disabled\"}` on the title retry path. That works for DeepSeek, but hardcodes a vendor wire parameter on a generic auxiliary path — non-DeepSeek OpenAI-compatible upstreams that reject unrecognised `extra_body` fields will HTTP 400. Routing through `reasoning_config` keeps the field provider-agnostic: each `ProviderProfile.build_api_kwargs_extras()` decides how to translate `reasoning_config` into its vendor's wire shape. ## Verification 1. **Unit tests**: added 2 unit tests covering `reasoning_effort: none` → `reasoning_config={\"enabled\": False}` mapping and the unset fallback (`tests/agent/test_title_generator.py`). Full suite green (35 passed). 2. **Wire inspection**: verified `_build_call_kwargs` with `reasoning_effort: none` emits `thinking: {\"type\": \"disabled\"}` for `opencode-go` / `deepseek-v4-flash`. 3. **Live generation**: verified against `deepseek-v4-flash` via OpenCode Go — title generation returns clean JSON titles (`{\"title\": \"...\"}`) without `finish_reason: length` or empty `content`. Pairs with #83725 / #84767 (which handle the `json_schema` 400 rejection); this PR handles the empty-content thinking leak.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85316",
        "createdAt": "2026-08-13T13:25:23Z",
        "updatedAt": "2026-08-13T13:39:44Z",
        "timestamp": "2026-08-13T13:39:44Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/deepseek",
          "P3"
        ],
        "author": "Jeffgithub0029",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85317",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(mcp): skip startup discovery for disabled servers",
        "text": "## Summary - Treat disabled native MCP server entries as inactive configuration. - Prevent startup discovery from opening when all configured servers are disabled. - Preserve agent-plugin MCP detection. ## Test plan - `uv run --with pytest python -m pytest -q tests/hermes_cli/test_mcp_startup.py` - Result: 4 passed, 1 warning. The change is based directly on the current upstream `main`.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85317",
        "createdAt": "2026-08-13T13:28:19Z",
        "updatedAt": "2026-08-13T17:23:37Z",
        "timestamp": "2026-08-13T17:23:37Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "tool/mcp",
          "area/config",
          "P3",
          "sweeper:risk-compatibility"
        ],
        "author": "Wahrheitssuchender",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85318",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(webhook): bind signatures to explicit provider schemes (Webhook Revolution)",
        "text": "Part of the [Webhook Revolution campaign](https://github.com/NousResearch/hermes-agent/issues/84834). Task 9. Closes #47451, #80327. ## Explicit, mode-bound signature verification Replaces header-driven scheme inference in `gateway/platforms/webhook_auth.py` with a verifier registry keyed by `signature_mode`: `github`, `gitlab`, `svix`, `generic_v2`, `generic_v1`. A route's configured mode decides the scheme; a route never infers a weaker scheme from attacker-controlled headers. `_validate_signature` gains a `signature_mode` param (default `generic_v2`), wired from the route config in `webhook.py`. ## Replay-safe - generic_v2 / svix reject stale, malformed, missing, and downgrade-attempt timestamps (bounded tolerance, default 300s). - A V2 request missing its timestamp is rejected — never downgraded to V1. - generic_v1 stays explicit compatibility mode with a deprecation warning; never an automatic fallback from V2. - Constant-time comparison throughout. ## Verification (67 passed) - `tests/gateway/test_webhook_signatures.py` — 14 new attack tests (wrong provider header, mixed V1/V2 downgrade, stripped/expired/future timestamp, malformed/non-ASCII sig, rotated signature, changed body, missing header, unknown mode). - `test_webhook_auth_seam.py` + `test_webhook_adapter.py` + `test_webhook_signature_rate_limit.py` + `test_webhook_integration.py` + `test_webhook_dynamic_routes.py` + `test_webhook_deliver_only.py` — no behavior regression (tests updated to set explicit signature_mode). - `git diff --check` clean; `webhook_auth.py` 213 lines, `webhook.py` 1173 lines (both < 2000).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85318",
        "createdAt": "2026-08-13T13:30:27Z",
        "updatedAt": "2026-08-13T13:39:16Z",
        "timestamp": "2026-08-13T13:39:16Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/security",
          "comp/cli",
          "comp/gateway",
          "platform/webhook",
          "area/auth",
          "P2",
          "needs-repro",
          "sweeper:risk-message-delivery",
          "sweeper:risk-security-boundary"
        ],
        "author": "andrexibiza",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85319",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(compress): bound offline dataset pipeline RAM",
        "text": "## What does this PR do? The offline dataset pipeline loaded every trajectory into RAM, then created one coroutine per row and `asyncio.gather(*tasks)`. The semaphore only capped in-flight API calls — not task/closure count or retained results — so large JSONL dumps OOM'd. Sampling now streams HuggingFace rows (streaming load when the hub supports it) and reservoir-samples qualifying trajectories instead of `random.sample(all_filtered)`. Directory compression processes each file in batches of `max_concurrent_requests`, writes incrementally to a temp file, and atomically replaces the output. Timeouts are still omitted; unexpected errors still keep the original row. ## Related Issue Fixes #84703 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `scripts/sample_and_compress.py`: `iter_dataset_entries` + Algorithm-R `reservoir_add`; merge writes through a temp file instead of a full in-memory list. - `trajectory_compressor.py`: `_process_directory_async` streams JSONL and `gather`s at most `max_concurrent_requests` awaitables. - Tests: gather-size cap, timeout/error output contract, reservoir cap, no `random.sample` on the full qualifying pool. ## How to Test 1. On `origin/main`, `test_process_directory_does_not_gather_one_task_per_entry` fails (`gather` of 12 vs cap 3) and `test_sample_from_datasets_does_not_sample_from_full_materialized_pool` fails (`random.sample` on 40). 2. `scripts/run_tests.sh tests/test_trajectory_compressor_async.py tests/test_sample_and_compress_ram.py` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — or N/A - [x] I've updated `cli-config.yaml.example` — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — or N/A - [x] I've considered cross-platform impact — or N/A - [x] I've updated tool descriptions/schemas — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85319",
        "createdAt": "2026-08-13T13:30:47Z",
        "updatedAt": "2026-08-13T13:39:12Z",
        "timestamp": "2026-08-13T13:39:12Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/perf",
          "comp/tools",
          "P3"
        ],
        "author": "Christopher-Schulze",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85320",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(kanban): reconcile detached running runs",
        "text": "## Summary - close `task_runs` left running after their task is blocked, terminal, or moved to a successor run - preserve task workflow state and write a correlated `run_reconciled` audit event transactionally - defer reconciliation while the detached run still records a live PID - run the repair in the existing opt-out orphan-reconciliation dispatch phase ## Root cause Card-level reconciliation only scanned `tasks.status='running'`. A run row could therefore remain `running` forever after its task had already left the running phase, creating stale operational debt that no existing recovery path could see. ## Verification - strict RED observed: missing `reconcile_stale_task_runs` raised AttributeError - `uv run --frozen --extra dev pytest tests/hermes_cli/test_kanban_stale_run_reconciliation.py -q` -> 4 passed - lifecycle regression set (stale-run/orphan/review/reclaim/dispatch-lock) -> 58 passed - `uv run --frozen --extra dev ruff check hermes_cli/kanban_db.py tests/hermes_cli/test_kanban_stale_run_reconciliation.py` -> passed - `git diff --check` -> passed - independent review -> PASS, no security or logic findings ## Safety / non-goals - does not move blocked/terminal task rows - does not reconcile a run that still records a live PID - does not enable broad sync, dispatch, LiveOp Done writeback, main, or production - 24-hour canary and live crash/restart/offline/resource drills remain post-review operational gates",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85320",
        "createdAt": "2026-08-13T13:32:03Z",
        "updatedAt": "2026-08-13T13:33:02Z",
        "timestamp": "2026-08-13T13:33:02Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "mtccressall",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85323",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): stop tool loops across result-hash and arg jitter",
        "text": "## Summary The no-progress detector keyed on `(signature, result_hash)`, so any repeat whose *result* differed reset the streak. Two mechanisms exploited that in live sessions, and a third survived even after signature counting: 1. The context compressor rewrites an older duplicate tool body to `[Duplicate tool output ...]`, which changes the hash. 2. Tools that vary their payload for a repeat call by design (`skill_view` returns `status: \"unchanged\"`) never look like a repeated result. 3. Even after counting identical signatures, a loop could mint a *fresh* signature every iteration by jittering one irrelevant field (`todo` alternating `merge: true`/`false` while re-asserting a byte-identical list). Repeating an identical call is the loop; the shape of the response is not what makes it one. This PR stacks three commits: - `bcb9e0374` — count identical **signatures**, not identical result hashes. Applies to every tool, including mutating bookkeeping tools like `todo`. Membership in `MUTATING_TOOL_NAMES` is not evidence of progress. - `ba0330f1e` — carry no-progress streaks across turn boundaries. `reset_for_turn()` no longer wipes `_no_progress`; streaks clear only when the world actually moves, gated on `file_mutation_result_landed()`. - `c1d9de8f5` — close the remaining two holes: normalize declarative `todo` payloads before hashing (item order + `merge` do not change the asserted end state), and collapse the compressor duplicate stub to a stable sentinel in `_result_hash()`. A landed file mutation still resets its own signature count, so write → re-run → write cycles and post-edit re-verification stay allowed. ## Test plan - [x] `tests/agent/test_tool_guardrails.py` + related runtime/sidecar tests: **82 passed** on `c1d9de8f5` with the repo venv (`~/.hermes/hermes-agent/venv/bin/python`, pytest 9.1.1, Python 3.11.15). - [x] Mutation-kill of `c1d9de8f5`: disabling arg normalization fails 2 tests; disabling the duplicate sentinel fails 1; byte-exact restore returns 12/12 green. - [ ] CI on this PR. Do not merge from review. Draft until the author decides.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85323",
        "createdAt": "2026-08-13T13:42:33Z",
        "updatedAt": "2026-08-13T13:42:33Z",
        "timestamp": "2026-08-13T13:42:33Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "patrykkopycinski",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85324",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): wire /learn slash command to the backend",
        "text": "## Summary Desktop's `isDesktopSlashCommand` gate rejected `/learn` because it had no row in `DESKTOP_COMMAND_SPECS`, so it never reached the TUI backend handler that already builds the prompt (`agent/learn_prompt.py::build_learn_prompt`). Add it as an `exec()` spec (`argumentMode: text`), matching the existing backend-executed commands. The desktop dispatcher now routes it through `slash.exec` -> `command.dispatch`, which the backend already handles. No client-side logic is duplicated, so the behavior inherits all upstream changes to the backend prompt builder. ## Test plan - Added a routing case asserting `/learn` resolves to `exec()` and is suggestible/runnable. - `vitest run src/lib/desktop-slash-commands.test.ts` -> 28 passed. - `tsc --noEmit -p tsconfig.json` clean. ## Notes This is intentionally scoped to `/learn` only. The same fall-through gap affects `/init` (and a few other backend commands), but those are left for a separate change to keep this PR minimal.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85324",
        "createdAt": "2026-08-13T13:43:04Z",
        "updatedAt": "2026-08-13T15:32:35Z",
        "timestamp": "2026-08-13T15:32:35Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "P3",
          "comp/desktop"
        ],
        "author": "rarf",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85326",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(providers): add SayGM model provider",
        "text": "## What does this PR do? Adds SayGM through Hermes' `ProviderProfile` architecture so users get safe live model discovery, curated fallbacks, output limits, and the provider-specific Sol request adjustment. Static prices are intentionally omitted because SayGM routing prices can change at runtime. ## Related Issue Fixes #85325 ## Type of Change - [x] ✨ New feature (non-breaking change that adds functionality) ## Changes Made - Adds the SayGM profile and curated Chat Completions fallback models - Uses `https://api.saygm.com/v1` and `SAYGM_API_KEY` - Filters discovery to `available: true` and `api_shapes: chat.completions` - Adds per-model output limits and the SayGM Sol tool-call request adjustment - Adds focused provider/profile tests ## How to Test 1. Set `SAYGM_API_KEY` and select the SayGM provider. 2. Confirm live discovery includes only available Chat Completions models. 3. Run `.venv/bin/python -m pytest tests/hermes_cli/test_saygm_provider.py -q`. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched open and merged PRs for duplicates - [x] My PR contains only SayGM provider changes - [ ] I've run the full suite and all tests pass - [x] I've added tests for my changes - [x] I've tested on macOS ### Documentation & Housekeeping - [x] Provider setup metadata is included; standalone docs are N/A - [x] `cli-config.yaml.example` is N/A (no new config key) - [x] `CONTRIBUTING.md` / `AGENTS.md` are N/A (no architecture change) - [x] Cross-platform impact considered; the profile is platform-independent - [x] Tool descriptions/schemas are N/A ## Test Results Final rebased commit: focused SayGM suite **23 passed**. The canonical full run completed with **29,553 passed, 306 skipped, and 21 failed across 13 files**. None of the failures involve SayGM; they are current environment/platform failures including macOS `/tmp` aliasing, unavailable optional audio runtimes, stale global test configuration, and Linux/WSL process assumptions. This PR remains draft until upstream CI establishes the clean baseline. No screenshot is applicable because this is a CLI provider/profile integration.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85326",
        "createdAt": "2026-08-13T13:45:44Z",
        "updatedAt": "2026-08-13T13:45:44Z",
        "timestamp": "2026-08-13T13:45:44Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "markdavison",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85328",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(irc): stop recommending Libera.Chat for agentic clients",
        "text": "## What does this PR do? Libera.Chat [forbids LLM-powered agents](https://libera.chat/news/bot-policy-update). Hermes still used `irc.libera.chat` as the example host in IRC setup, plugin env docs, and the user guide, so the recommended configuration walked users into a policy violation. Examples now point at a local IRCd (`127.0.0.1`). Setup text says so explicitly. If someone still configures a Libera hostname, `connect()` and interactive setup warn — they are not hard-blocked, so existing deployments keep working. ## Related Issue Fixes #61181 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `plugins/platforms/irc/adapter.py`: `is_libera_chat_host()`, setup/docs examples, connect warning. - `plugin.yaml`, `hermes_cli/config_defaults.py`, IRC user-guide + env-var docs. - Regression tests for host detection, example strings, and the connect warning. ## How to Test 1. On `origin/main`, plugin.yaml / setup prompt still say `e.g. irc.libera.chat`. 2. `scripts/run_tests.sh tests/plugins/platforms/test_irc_libera_policy.py` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — or N/A - [x] I've updated `cli-config.yaml.example` — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — or N/A - [x] I've considered cross-platform impact — or N/A - [x] I've updated tool descriptions/schemas — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85328",
        "createdAt": "2026-08-13T13:46:12Z",
        "updatedAt": "2026-08-13T13:46:12Z",
        "timestamp": "2026-08-13T13:46:12Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "Christopher-Schulze",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85329",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(desktop): add in-app problem reporting",
        "text": "## What does this PR do? Adds a clear **Report a problem on GitHub** action to Desktop Settings → About for users who do not know where or how to file a bug. The action opens an editable GitHub issue draft in the system browser with a small allowlist of diagnostics Desktop already knows: app version, platform, Electron version, and a validated short source revision when available. The user reviews and submits the draft. Hermes does not upload or submit anything automatically. This deliberately uses GitHub's blank issue composer because URL parameters cannot prefill arbitrary fields in the repository's structured issue form. The draft mirrors the essential reporting prompts while keeping the Desktop pathway privacy-minimal. ## Related Issue Fixes #84941 Related: #68735 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - Add a localized Support row to Settings → About. - Open the canonical upstream `/issues/new` composer through the existing `openExternal` bridge. - Build the report URL in a pure helper with a strict diagnostic allowlist. - Omit missing, blank, or malformed diagnostic values. - Add helper and component interaction tests. - Update all Desktop locales. ## How to Test 1. Open Desktop Settings → About. 2. Select **Report on GitHub**. 3. Confirm the system browser opens an editable `[Desktop Bug]:` issue draft. 4. Confirm the draft includes basic Desktop diagnostics but no logs, paths, profiles, connection details, configuration, or transcript content. Automated verification: ```text npm run test:ui # 423 files, 3,786 tests passed npm run typecheck # passed npm run lint -- --quiet # passed npm run build # passed ``` ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains only changes related to this feature - [ ] I've run `pytest tests/ -q` and all tests pass (N/A: Desktop-only TypeScript change; full Desktop gates above passed) - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 26.3.1, arm64 ### Documentation & Housekeeping - [x] Documentation update N/A - [x] `cli-config.yaml.example` update N/A - [x] `CONTRIBUTING.md` / `AGENTS.md` update N/A - [x] Cross-platform impact considered: uses the existing cross-platform `openExternal` bridge and platform value - [x] Tool descriptions/schemas update N/A ## Screenshots / Logs The new About-page row is intentionally small and uses existing `SectionHeading`, `ListRow`, `Button`, and icon primitives. No new native capability or backend endpoint is added.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85329",
        "createdAt": "2026-08-13T13:46:17Z",
        "updatedAt": "2026-08-13T13:46:17Z",
        "timestamp": "2026-08-13T13:46:17Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "ctaylor86",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85330",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cua): make governed capture failures explicit",
        "text": "## What changed - Add an explicit failed capture result with `ok:false`, `success:false`, `status:\"failed\"`, a bounded error code/phase/repair hint, and target-presence metadata. - Preserve that contract through addon pre-tool governance blocks instead of returning a generic error string. - Reject half-pairs and invalid native capture targets explicitly; keep raw app names, IDs, titles, and native detail out of model-facing results. - Document the explicit-app and exact-pair capture contract and add focused backend, hook, and serialization tests. ## Why The previous failure path returned a normal `0x0` capture or a generic blocked-tool error, allowing downstream callers to treat a rejected capture as completed. The producer now has one bounded failure shape for backend and governance rejection paths. ## Validation - Hermes computer-use focused contract tests — 6 passed - Hermes model-tools focused contract tests — 2 passed - `git diff --check` — passed Related: AGC-390, AGC-388, AGC-391, AGC-281. No release, carrier install, or deployment is included.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85330",
        "createdAt": "2026-08-13T13:47:09Z",
        "updatedAt": "2026-08-13T16:06:40Z",
        "timestamp": "2026-08-13T16:06:40Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/tools",
          "P2"
        ],
        "author": "leonagente",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85334",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(transports): normalize xhigh and minimal reasoning effort levels to high and low",
        "text": "Fixes HTTP 400 error (invalid reasoning value 'xhigh') when reasoning effort is set to xhigh on OpenAI-compatible and Nous Portal endpoints. Clamps xhigh -> high and minimal -> low before assembling reasoning extra_body.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85334",
        "createdAt": "2026-08-13T13:54:13Z",
        "updatedAt": "2026-08-13T15:32:46Z",
        "timestamp": "2026-08-13T15:32:46Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/nous",
          "provider/openai",
          "P2"
        ],
        "author": "finansovazashtita-arch",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85340",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cron): relay-fronted Slack delivery — synthetic creation-thread capture + preflight fronted-platform blindness",
        "text": "## Summary Two interlocking cron-delivery bugs on relay-fronted Slack deployments (the gateway runs only the relay platform; a connector owns the Slack app). Reported from a live deployment: `/sethome` was set correctly, yet cron jobs created from Slack delivered into the ephemeral thread that Slack spawns around the creation message — and the explicit-target escape hatch `deliver: \"slack:<chat_id>\"` was rejected at preflight with \"delivery platform 'slack' has no gateway credentials configured (not connected)\". ### Bug 1 — synthetic session-keying thread persisted as durable routing Relay-fronted Slack in thread-per-message mode stamps each top-level message's own id as `source.thread_id` (`gateway/relay/adapter.py::_stamp_slack_session_thread`, mirroring native `thread_ts = event.thread_ts or ts`). Its docstring is explicit that the stamp exists for **session keying** — but three consumers persisted it as a delivery address: 1. **Cron origin capture** (`tools/cronjob_tools.py::_origin_from_env`) stored it as `origin.thread_id`, and fire-time resolution replayed it unconditionally (`cron/scheduler.py::_resolve_single_delivery_target`), with a Slack origin-affinity block re-attaching it even onto explicit `slack:<chat_id>` targets. 2. **`/sethome` itself** (`gateway/slash_commands.py`) captured it into the persisted `HomeChannel.thread_id` and its legacy env mirror — pinning the *home target* to the thread around the `/sethome` message, so bare `deliver: \"slack\"` also routed into a stale thread. Found during live validation of fix 1; same root cause, third consumer. **Fix — recognize the stamp at every capture boundary, plus a fire-time repair for jobs persisted before the fix:** - Capture (cron): `_origin_from_env` drops a Slack thread id equal to the creation message's own id (`HERMES_SESSION_MESSAGE_ID`). A genuine in-thread creation (thread id = parent's id, not this message's own) keeps its thread. - Capture (`/sethome`): new `_home_thread_from_source` applies the same recognition before persisting the home target. Running `/sethome` inside a real thread still pins that thread deliberately. Users repair an already-poisoned home target by rerunning `/sethome`. - Fire time: `_origin_thread_is_stale` treats a Slack origin thread as stale when the origin chat is the configured Slack home chat — delivery goes top-level (or to the home target's own configured thread). Non-home chats keep their threads; the explicit-target re-attach is gated on the same predicate. - Slack-scoped throughout: Telegram forum topics legitimately reuse ids and are untouched (covered by negative-control tests). ### Bug 2 — preflight blind to relay-fronted platforms `_preflight_check_delivery` and `cron_delivery_targets` validated deliver prefixes against `get_connected_platforms()`, which only sees **natively configured** platforms — `{relay}` on a relay-only deployment, so `slack:<chat>` was refused before any spend. Fire-time routing already handles this topology correctly (`gateway/delivery.py::resolve_delivery_transport` → `RelayAdapter.fronts_platform`; the delivery loop even bypasses the native enabled-gate for relay transports). Validation and routing were asymmetric; the asymmetry is the bug. **Fix:** new `gateway.relay.relay_fronted_platforms()` — a thin wrapper over the existing `relay_platform_identities()` (env-derived from `GATEWAY_RELAY_PLATFORMS`, the same source that seeds the live adapter's identity set, so the two paths cannot disagree; usable from a standalone scheduler process with no live adapter handle). Unioned into the connected set at both validation sites, only when the relay itself is connected. Native topologies keep the strict credential check unchanged. ## Live validation (relay-fronted staging fleet, 2026-08-13) | Lane | Pre-fix behavior | Post-fix result | |---|---|---| | `deliver: \"origin\"` (job created from Slack DM) | delivered into creation-message thread | **verified: top-level delivery** | | `deliver: \"slack:<chat_id>\"` | killed at preflight (\"no gateway credentials\") | **verified: schedules and delivers top-level** | | `deliver: \"slack\"` (bare → home) | delivered into the old `/sethome` message's thread | **verified: top-level after rerunning `/sethome`** | ## Test evidence RED before fix (6 failed = the new contracts; 8 passed = negative controls): ``` FAILED tests/cron/test_cron_origin_synthetic_thread.py::TestSlackSyntheticThreadCapture::test_synthetic_slack_thread_not_captured FAILED tests/cron/test_cron_relay_delivery_guards.py::TestOriginThreadStaleGuard::test_origin_thread_dropped_when_chat_is_home FAILED tests/cron/test_cron_relay_delivery_guards.py::TestOriginThreadStaleGuard::test_home_thread_config_still_wins FAILED tests/cron/test_cron_relay_delivery_guards.py::TestOriginThreadStaleGuard::test_explicit_target_no_reattach_when_chat_is_home FAILED tests/cron/test_cron_relay_delivery_guards.py::TestPreflightRelayFronted::test_relay_fronted_slack_accepted FAILED tests/cron/test_cron_relay_delivery_guards.py::TestPreflightRelayFronted::test_delivery_targets_include_relay_fronted 6 failed, 8 passed ``` Mutation checks (fix committed first, one guard stubbed at a time, tests must go red, tree restored): - cron capture guard stubbed → `test_synthetic_slack_thread_not_captured` FAILED (1 failed, 3 passed) - `_origin_thread_is_stale` stubbed to `return False` → 3 staleness-guard tests FAILED (3 failed, 7 passed) - fronted-union stubbed to `return set()` → 2 preflight tests FAILED (2 failed, 2 passed) - `/sethome` guard stubbed → `test_synthetic_slack_thread_dropped` FAILED (1 failed, 4 passed) Canonical runner on the final tree: ``` === Summary: 74 files, 715 tests passed, 0 failed, 1 skipped (100% complete) in 13.9s (8 workers) === (scripts/run_tests.sh tests/cron tests/gateway/relay) ``` Full cron + gateway sweep after the `/sethome` commit: 543 passed, 1 skipped. ## Surfaces - No new config keys, no new model-tool surface, no wire/frame changes (`relay_fronted_platforms()` reads the existing `GATEWAY_RELAY_PLATFORMS` deploy stamp). - No connector-side changes required: both bugs are gateway-local (capture/validation); the connector's delivery path was already correct. - Behavior change scope: Slack origins/home targets only for bug 1; relay-connected deployments only for bug 2. ## What this does NOT do - No change to the per-message-session UX — the adapter's session-keying stamp is untouched; only its capture as delivery routing changes. - No `gateway/delivery.py` changes — fire-time routing was already correct. - No standalone-scheduler fire-path hardening (in-gateway fire works via the existing relay transport bypass). - Thread-continuation UX for cron deliveries in thread-per-message mode (a reply under a delivered brief opens an unseeded session) is a separate concern with a fix in progress on `fix-slack-delivery-root-seed`; it will arrive as its own PR once live-validated.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85340",
        "createdAt": "2026-08-13T14:02:55Z",
        "updatedAt": "2026-08-13T17:46:14Z",
        "timestamp": "2026-08-13T17:46:14Z",
        "metrics": {
          "reactions": 0,
          "comments": 4
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "comp/cron",
          "platform/slack",
          "P2",
          "sweeper:risk-message-delivery"
        ],
        "author": "victor-kyriazakos",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85349",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(compress): do not publish micro-compaction when DB sync fails",
        "text": "## What does this PR do? Micro-compaction spliced the rolling summary into the in-memory transcript, then called `_sync_micro_compact_to_db` and swallowed any exception. Resume then loaded the still-active originals on top of the already-compacted list. Rehydrated marker text and generated micro summaries also skipped the batch path's strict redaction, so a legacy secret in an old marker could re-enter the aux prompt and the session DB. `_sync_micro_compact_to_db` now returns success/failure. A failed persist restores the previous cursor and summary and returns the pre-splice messages. Rehydrated marker text and generated micro summaries go through `_redact_compaction_text` before they are stored or sent. ## Related Issue Fixes #84723 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `_sync_micro_compact_to_db` returns `True` on success / no DB, `False` on write exception. - Absorb path rolls back cursor/summary and keeps the original list when persist fails. - Rehydrate and generated micro summaries are redacted before prompt/DB. - Tests: persist-fail keeps the pre-splice transcript; leaked summarizer output and a legacy marker secret do not survive publish or the aux prompt. ## How to Test 1. On `origin/main`, `test_failed_db_sync_keeps_pre_splice_transcript`, `test_generated_micro_summary_is_redacted_before_publish`, and `test_rehydrated_secret_is_redacted_before_summarizer_prompt` fail. 2. `scripts/run_tests.sh tests/agent/test_micro_compaction.py tests/agent/test_compaction_redaction_boundaries.py` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — or N/A - [x] I've updated `cli-config.yaml.example` — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — or N/A - [x] I've considered cross-platform impact — or N/A - [x] I've updated tool descriptions/schemas — or N/A",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85349",
        "createdAt": "2026-08-13T14:20:09Z",
        "updatedAt": "2026-08-13T17:45:24Z",
        "timestamp": "2026-08-13T17:45:24Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P2",
          "sweeper:risk-session-state",
          "area/sessions",
          "area/compression"
        ],
        "author": "Christopher-Schulze",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85351",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(browser): add authenticated extension controller",
        "text": "## What does this PR do? Adds an opt-in browser-extension controller lane so Hermes can route existing `browser_*` tools to the exact authenticated browser session that opened the conversation. The implementation has two layers: 1. A transport-neutral broker with principal/profile/session/controller/browser-profile/transport scoping, one-shot WebSocket tickets, capability allowlisting, command lifecycle, cancellation, timeout, reconnect, and owner-scoped detach. 2. Request-bound tool routing for the seven existing Browser Use registry schemas. Generic requests preserve the existing backend; once the gateway binds controller identity, the extension lane is authoritative and fails closed if that exact controller disappears or cannot execute the action. Local API and authenticated dashboard/cloud transports use the same protocol and real-action allowlist: - `browser_navigate` - `browser_snapshot` - `browser_screenshot` - `browser_click` - `browser_type` - `browser_press` - `browser_scroll` - `browser_back` - `browser_list_tabs` - `browser_activate_tab` Raw CDP, arbitrary evaluation, console, file upload, vision, and image extraction are not admitted. ### Reconnect and detach semantics Unexpected transport loss is recoverable: the controller is hidden from new dispatch while already-started commands remain pending until their original deadline. A reconnect with the same stable identity refreshes the transport and negotiated capabilities, flushes deferred cancels before new work, and can complete the original command. An authenticated `browser.controller.detach` frame/RPC remains immediately terminal. A different controller id or browser profile in the same authenticated session lane is also a hard replacement: the old controller's pending work is cancelled before the successor becomes routable. Inbound heartbeat, result, cancel, and detach frames are admitted only from the current owner. Slow but live cross-thread WebSocket writes remain in flight under the broker command deadline instead of being misclassified as failed sends. Real send failures still surface immediately. ### Existing user compatibility - `browser.extension_control.enabled` defaults to `false`. - Feature off: the broker is never queried and the current Browser Use path is unchanged. - Feature on with no server-bound controller identity: the current backend still handles the tool. - Feature on with bound controller identity: missing, ambiguous, disconnected, or incapable controllers fail closed instead of switching browsers. - Controller tools are exposed only inside the matching request/session context; availability is never cached process-wide. - Once a request is bound to the controller lane, failures do not silently jump to another browser. - Server-bound browser-control identity variables are excluded from shared shell snapshots, preventing cross-session routing metadata persistence. ### Tests A real `browser_snapshot` journey covers the aiohttp route table, Bearer-authenticated registration, one-shot subprotocol ticket, controller WebSocket command/result exchange, router serialization, and zero calls to the legacy backend. Reconnect coverage includes socket close and dashboard transport loss, same-identity capability renegotiation, deferred-cancel ordering and bounds, stale-owner rejection, explicit detach, different-identity hard replacement, timeout, late completion, and slow WebSocket send waits. The diff is 23 files with 4,873 additions; 2,553 additions are tests and 101 are docs/config. Resolves the Chrome-extension backend portion of #84000. This uses the existing authenticated API/dashboard transports instead of adding a second native-messaging server inside Hermes. ## Related issue(s) #84000 ## Checklist - [x] I have performed a self-review of my changes - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have added or updated documentation as needed - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have run code quality checks locally and they pass - [x] I have not committed sensitive data or credentials - [x] My commit messages are clear and descriptive - [x] I have credited co-authors where applicable - [x] Any generated or vendored files are reproducible and justified in the PR description ## Testing evidence - Controller/broker/API/cloud/router plus snapshot-identity and bound-route authority regressions: **95 passed, 1 POSIX-only integration skipped on Windows** - Registry/cache/model/API compatibility: **93 passed** - RED → GREEN: 6 strict admission failures → all green - RED → GREEN: real `browser_snapshot` WebSocket journey failed when the action was removed, then passed with **0 legacy-backend calls** - RED → GREEN: 4 reconnect lifecycle contracts failed on the old broker, then passed - RED → GREEN: stale-scope cancel, completed-send `TimeoutError`, and zombie-controller replacement regressions - RED → GREEN: two bound-controller authority cases failed by invoking legacy fallback, then passed fail-closed; a real schema-build → disconnect → dispatch regression proves zero fallback calls - First public CI head exposed `HERMES_BROWSER_CONTROL_*` snapshot exclusion drift; reproduced locally and fixed in the fourth commit - `ruff check` on every touched Python module/test: clean - `compileall` on every touched Python module/test: clean - Docusaurus English production build: successful (only the repo's pre-existing `/docs/llms.txt` and `/docs/llms-full.txt` root-page warnings) - Config example parses with the feature disabled - GitNexus change analysis: low risk, no affected existing process flow - Added-line secret scan and `git diff --check`: clean ## Additional context The feature is deliberately opt-in and fail-closed on identity/capability mismatch. Registration validates protocol version `1` strictly (booleans are rejected), requires at least one permitted capability, and uses a 30-second one-shot ticket carried only in `Sec-WebSocket-Protocol`. Query-string tickets are rejected. This branch is based on current `main`. The first CI head's only failing Python slice was the new browser-control ContextVars missing from shared terminal snapshot exclusions; the corrected head includes that regression fix.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85351",
        "createdAt": "2026-08-13T14:21:02Z",
        "updatedAt": "2026-08-13T15:45:08Z",
        "timestamp": "2026-08-13T15:45:08Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "comp/gateway",
          "comp/tools",
          "comp/tui",
          "tool/browser",
          "area/auth",
          "area/config",
          "P3",
          "needs-decision",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery",
          "sweeper:risk-security-boundary"
        ],
        "author": "abundantbeing",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85352",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(guardrails): carry no-progress loops across duplicate results and turns",
        "text": "## Summary Fix two gaps in the tool-loop no-progress guardrail that let repeated successful bookkeeping calls survive indefinitely: 1. Count repeated identical **tool signatures**, not repeated result hashes. - Context compression can replace a repeated tool body with `[Duplicate tool output — same content as a more recent call]`. - Some tools intentionally return a dedup payload such as `{\"status\":\"unchanged\",\"content_returned\":false}`. - Both are the same no-progress call with different result hashes, so hash equality is the wrong counter. 2. Preserve `_no_progress` across `reset_for_turn()`. - `reset_for_turn()` runs at the start of each `run_conversation`, including after context compaction / a new user message. - Clearing no-progress state there lets a repeated bookkeeping call restart its streak at `1` after compaction and never reach `no_progress_block_after`. - Stale streaks are now cleared when the world actually moves: a file mutation whose result proves the write landed via `file_mutation_result_landed()`. 3. Normalize conservative housekeeping args before signature hashing. - `todo`: ignore `merge` and sort todos by `id`. - `skill_view`: drop `file_path: null`. - `read_file`: drop default `offset: 1` / `limit: 2000`. - Non-housekeeping tools keep raw args; `terminal` is intentionally not normalized. ## Relationship to existing guardrail PRs This is intentionally narrow and complements the existing open guardrail work: - #56929 correctly identifies landed file mutations as the progress signal. This PR keeps that signal but separates it from `reset_for_turn()`, because `_no_progress` must survive turn rollover. - #57816 explicitly tests that `reset_for_turn()` clears bounded guardrail state, which is the opposite invariant needed for compaction-crossing no-progress loops. - #58221 tracks successful mutating repetitions, but still keys the streak on result-hash equality, so duplicate-output stubs / dedup payloads still reset the count. - #60661 covers an adjacent alternating-success bypass, but this PR covers cosmetic argument jitter for declarative housekeeping calls. `MUTATING_TOOL_NAMES` is not used as proof of progress here: bookkeeping tools such as `todo` live in that set but do not move the world. ## Verification Targeted tests: ```text python3 -m pytest tests/agent/test_tool_guardrails.py -q Pytest: 10 passed ``` Adjacent tests: ```text python3 -m pytest tests/agent/test_tool_guardrails.py tests/agent/test_tool_result_classification.py tests/agent/test_turn_context.py tests/cli/test_cli_interrupt_ack_race.py -q -p no:cacheprovider Pytest: 32 passed ``` Lint: ```text python -m ruff check . All checks passed! ``` Mutation checks performed locally: - restoring result-hash equality counting made the duplicate-stub regression fail - restoring the per-turn `_no_progress` wipe made the turn-boundary regression fail - neutering the landed-write clear made the turn-boundary regression fail after a real write - hashing raw canonical args instead of normalized housekeeping args made both arg-jitter regressions fail Environment note: the earlier local `pydantic_core._pydantic_core` import failure is fixed. The installed venv now imports `pydantic_core 2.46.4`, `python -m pip check` reports no broken requirements, and the guardrail/classification tests pass under that venv.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85352",
        "createdAt": "2026-08-13T14:22:07Z",
        "updatedAt": "2026-08-13T15:47:39Z",
        "timestamp": "2026-08-13T15:47:39Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P3"
        ],
        "author": "patrykkopycinski",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85353",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(read_file): dedup key uses realpath to avoid cross-path false positives (#85333)",
        "text": "## Fix for #85333 ### Problem The dedup cache keyed on without canonicalizing through . When the same file content exists at two different paths (git worktrees, clones, symlinked directories), both paths could resolve to the same normalized string, causing a false-positive dedup stub on the second path — the tool returns with instead of the actual file content. ### Fix Use for the dedup key identity so physically different files at different paths never dedup against each other, even when their content is identical. Falls back to the normalized path when is unavailable (remote backends, missing files). Also updated to use the same identity so / correctly evict dedup entries created by reads through symlinked/worktree paths. ### Changes - : - now uses instead of - resolves to before matching dedup keys - : - Added regression test ### Verification - All 4 tests pass (including the new regression test) - Pre-existing test failures on Windows (device path tests, mtime granularity test) are unrelated to this change - See issue #85333 for full reproduction details",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85353",
        "createdAt": "2026-08-13T14:22:12Z",
        "updatedAt": "2026-08-13T15:45:15Z",
        "timestamp": "2026-08-13T15:45:15Z",
        "metrics": {
          "reactions": 0,
          "comments": 2
        },
        "labels": [
          "type/bug",
          "tool/file",
          "P2"
        ],
        "author": "andrexibiza",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85365",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): propagate HERMES_BUNDLED_* into launchd/systemd units",
        "text": "## Problem On Homebrew/Nix installs the launchd-supervised gateway logs `No adapter available for <platform>` for every bundled messaging platform (telegram, discord, slack, …), even when the plugin is enabled, the SDK is installed, and the token is valid. The identical setup works when started from a shell. ## Root cause The `hermes` wrapper exports resource pointers — `HERMES_BUNDLED_PLUGINS`, `HERMES_BUNDLED_SKILLS`, `HERMES_BUNDLED_LOCALES`, `HERMES_OPTIONAL_SKILLS`, `HERMES_TUI_DIR` — so `get_bundled_plugins_dir()` resolves to the real manifest tree, which on packaged installs lives outside site-packages (e.g. `<prefix>/share/hermes-agent/plugins`). But `generate_launchd_plist()` writes only `PATH`, `VIRTUAL_ENV`, and `HERMES_HOME` into the plist's `EnvironmentVariables`. launchd starts the venv python directly, bypassing the wrapper, so inside the supervised gateway `get_bundled_plugins_dir()` falls back to the in-repo `plugins/` path, discovers **zero** bundled platform manifests, and `_create_adapter()` returns `None`. `generate_systemd_unit()` has the identical gap on the Linux side (Nix uses systemd user units with the same wrapper vars). ## Fix Bake the wrapper's `HERMES_BUNDLED_*` / `HERMES_OPTIONAL_SKILLS` / `HERMES_TUI_DIR` pointers into the generated launchd plist and systemd unit(s) when they are present in the environment, via a small shared `_bundled_resource_env_pairs()` helper. - **Empty on a standard pip/uv install** (the wrapper isn't involved), so those units are **byte-for-byte unchanged** — no churn to `launchd_plist_is_current()` / `systemd_unit_is_current()` for existing deployments. - Deterministic ordering; only non-blank values are emitted. ## Tests New `TestBundledResourceEnvPropagation` covers, for both generators: - vars set → emitted in the correct plist/`Environment=` form; - vars unset → not emitted (unit unchanged); - unset vars aren't leaked when only some are set; - blank/whitespace values are skipped. Full `tests/hermes_cli/test_gateway_service.py` (83 passed, 1 skipped) plus the sibling systemd/launchd suites (`test_gateway_linger`, `test_gateway`, `test_systemd_watchdog_unit`, `test_systemd_optional_directives`, `gateway/test_status`) pass. ruff + windows-footgun gates clean. (The test file also picks up `encoding=\"utf-8\"` on a handful of pre-existing bare `read_text`/`write_text` calls that the footgun gate flags once the file is touched — part of the #71014 encoding campaign.) Fixes #85357",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85365",
        "createdAt": "2026-08-13T14:46:08Z",
        "updatedAt": "2026-08-13T17:56:40Z",
        "timestamp": "2026-08-13T17:56:40Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/gateway",
          "P2",
          "sweeper:risk-message-delivery",
          "sweeper:risk-compatibility"
        ],
        "author": "PRATHAMESH75",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85370",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(hooks): add emit_waterfall — Cordis-style around-middleware dispatch",
        "text": "## Summary Adds `HookRegistry.emit_waterfall()` — a Cordis-style **around-middleware** dispatch mode for gateway event hooks. This is the Hermes port of the waterfall primitive DeepSeek Harness uses for its tool execution pipeline (`tools/pre-execute` → `tools/execute` → `tools/post-execute`), where cooperative listeners mutate a shared request and delegate via `next()`, while a policy listener that owns a decision returns without delegating. ## Background Hermes gateway hooks previously had exactly two dispatch modes: | Mode | API | Handler signature | What a handler can do | |------|-----|-------------------|----------------------| | Observe | `emit` | `handle(event_type, context)` | Side effects only; return ignored | | Collect | `emit_collect` | `handle(event_type, context)` | Return a value; caller interprets all results | Both are flat fan-out: every handler sees the same input, and there is no way for one handler to **rewrite the value before the next handler sees it**, nor to **stop the chain** (a deny decision that should prevent later handlers from running). The gateway command-dispatch path works around this by returning `{\"decision\": ...}` dicts and re-interpreting them in `gateway/run.py` — a pattern that cannot express \"the previous handler rewrote the command, now re-check the rewritten command\". DeepSeek Harness solves this with Cordis waterfall dispatch: a handler receives `(...args, next)`; calling `next()` delegates (optionally with a replaced value), returning without `next()` short-circuits. This is the core of their tool policy pipeline. ## What this PR adds `gateway/hooks.py` — `emit_waterfall(event_type, value, context)`: - **Waterfall participants** (4-arg handlers: `handle(event_type, value, context, next_fn)`): - `await next_fn(new_value=...)` → delegate downstream, optionally replacing the value; - return without calling `next_fn` → **short-circuit**; the handler's return value becomes the waterfall result. - **Legacy observers** (existing 2-arg handlers) keep working on the same event: run in order, return ignored, cannot rewrite or short-circuit — no breakage for current hook users. - **Fail-closed containment**: a throwing waterfall participant stops the chain; a throwing observer is contained (chain continues). - Sync and async handlers both supported; wildcard (`command:*`) matching applies. `tests/gateway/test_hooks_waterfall.py` — 11 regression tests: delegate, rewrite propagation, short-circuit, observer compat (both directions), fail-closed, contained observer, async observer, empty chain, wildcard matching, and filesystem-discovered waterfall handlers. `website/docs/user-guide/features/hooks.md` — new \"Waterfall Handlers (around-middleware)\" section with a policy example, semantics table, and lineage note. ## Why this approach - **Smallest footprint**: a single new method on the existing `HookRegistry`; zero changes to `emit`/`emit_collect` or any call site. Existing hooks are untouched. - **Backward compatible by construction**: handler arity inspection distinguishes waterfall participants from legacy observers, so a mixed event (observer + waterfall handlers) behaves correctly without a config flag. - **Real consumer ready**: the gateway `command:*` dispatch path (and future tool-policy hooks) can migrate from `emit_collect` + caller-side dict interpretation to a true middleware chain — rewrite decisions propagate downstream instead of being re-parsed by the caller. - **Inspired by prior art, not reinvented**: the waterfall contract (delegate vs short-circuit, value propagation through `next()`) matches Cordis semantics as documented by deepseek-ai/deepseek-harness (`docs/cordis-primer.md`). ## Who should enable this - Hook authors building **policy / guardrail / rewrite** chains (allow-deny, command rewriting, rate limiting, request mutation) who need one handler's output to feed the next, or a deny to stop later handlers. - Gateway integrators who previously had to hand-roll decision interpretation over `emit_collect` return lists. ## Platform compatibility | Platform | Support | |----------|---------| | Linux | ✅ pure-Python, no native deps | | macOS | ✅ | | Windows | ✅ | | CLI / Gateway / TUI / Desktop | ✅ (gateway event-hook surface) | ## Quick-start guide ```python # ~/.hermes/hooks/command-policy/handler.py async def handle(event_type: str, value, context: dict, next_fn): command = context.get(\"command\", \"\") if command in {\"dangerous\", \"wipe\"}: return {\"decision\": \"deny\", \"message\": f\"/{command} is blocked by policy.\"} return await next_fn(new_value=value) # delegate; rewrite if needed ``` The host calls `emit_waterfall` (instead of `emit`) for events that support policy chains. Short-circuit returns are the final result; delegated values propagate to later handlers. ## Troubleshooting | Symptom | Cause | Fix | |---------|-------|-----| | My existing hook still runs but my new 4-arg handler never fires | Handler arity: a 2-arg handler is treated as an observer | Declare all four params: `def handle(event_type, value, context, next_fn)` | | Chain stopped unexpectedly | A waterfall participant returned without calling `next_fn` (intentional short-circuit) or threw (fail-closed) | Check the `[hooks] Error in waterfall handler` log line; ensure policy handlers delegate when they should | | Value not rewritten downstream | `next_fn()` called without `new_value=` | Pass `await next_fn(new_value=...)` to replace the value | ## Verification ```bash venv/bin/python3 -m pytest tests/gateway/test_hooks_waterfall.py tests/gateway/test_hooks.py -q # 18 passed venv/bin/python3 -m ruff check gateway/hooks.py tests/gateway/test_hooks_waterfall.py # All checks passed! ```",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85370",
        "createdAt": "2026-08-13T14:53:12Z",
        "updatedAt": "2026-08-13T17:31:38Z",
        "timestamp": "2026-08-13T17:31:38Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "P3"
        ],
        "author": "x7peeps",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85372",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(doctor): report Hermes source checkout state",
        "text": "## Summary Add a non-failing `Source Checkout` section to `hermes doctor` so users can tell whether the running Hermes install is clean upstream, behind/ahead of its upstream, or dirty with local live patches. The section reports: - current branch / detached state and short HEAD - upstream ahead/behind counts when an upstream is configured - tracked local modifications count - untracked file count with a first-path sample This is meant to prevent confusion when a live install has local patches applied, especially when debugging behavior that differs from upstream or an open PR. ## Verification Unit tests: ```text python -m pytest tests/hermes_cli/test_doctor_source_state.py -q 2 passed ``` Lint: ```text python -m ruff check hermes_cli/doctor.py tests/hermes_cli/test_doctor_source_state.py All checks passed! ``` Direct helper smoke in this checkout: ```text ('info', 'Source checkout', 'fix/doctor-source-state @ fa83af3f9a42') ('ok', 'Upstream origin/main', 'behind 0, ahead 0') ('warn', 'Source checkout has local modifications', '1 tracked file(s) changed') ('info', 'Untracked source files', '1 file(s); first: tests/hermes_cli/test_doctor_source_state.py') ``` Note: a full `python cli.py doctor` invocation exceeded 300s in my local environment before reaching this section, due to unrelated slow doctor checks. The new logic is covered as a pure helper and wired into `run_doctor`.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85372",
        "createdAt": "2026-08-13T14:54:29Z",
        "updatedAt": "2026-08-13T17:31:30Z",
        "timestamp": "2026-08-13T17:31:30Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "P3"
        ],
        "author": "patrykkopycinski",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85373",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): surface actionable error when Nous Cloud agent returns 503 (#85335)",
        "text": "## Fix for #85335 ### Problem When a Hermes Desktop is configured to connect to a Nous-managed cloud agent (e.g. `https://ares-3009.agents.nousresearch.com`) and that backend returns **HTTP 503 Service Unavailable**, the error surfaces as a generic 'Hermes backend did not become ready: 503: ...' message with no guidance that the cloud server itself is down. The user cannot restart a Nous-managed cloud instance themselves and has no actionable recovery path. ### Fix Added two helper functions to `apps/desktop/electron/backend-health.ts`: - `isServerSideHttpError(error)` — detects HTTP 502/503/504 from backend probe errors - `isNousCloudAgentUrl(baseUrl)` — detects URLs matching `\\*.agents.nousresearch.com` In `waitForHermesReady`, when the backend URL is a Nous cloud agent and the final error is a server-side HTTP error, the function now throws a rich error message that: 1. Names the failing cloud agent hostname 2. Reports the HTTP status code 3. Suggests checking https://portal.nousresearch.com 4. Suggests switching to Local mode in Settings → Gateway 5. Provides Discord support link (discord.gg/NousResearch) 6. Includes `isCloudBackendDown` flag and `statusCode` property for renderer overlay customization ### Changes - `apps/desktop/electron/backend-health.ts`: Added `isServerSideHttpError` and `isNousCloudAgentUrl` helpers; enhanced error in `waitForHermesReady` - `apps/desktop/electron/backend-health.test.ts`: Added tests for both helpers and the enhanced error path ### Verification - Helper functions tested with tsx: 503/502/504 detected correctly; cloud URL detection works - Full `waitForHermesReady` tested: cloud 503 returns actionable message, non-cloud 503 returns generic message - Existing tests unchanged (vitest runner hangs on this environment, verified via direct tsx execution) Fixes #85335 See also: #85333 (read_file dedup bug — related PR #85353)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85373",
        "createdAt": "2026-08-13T14:54:39Z",
        "updatedAt": "2026-08-13T15:37:12Z",
        "timestamp": "2026-08-13T15:37:12Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "P2",
          "comp/desktop"
        ],
        "author": "andrexibiza",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85374",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(gateway): expose Kanban wake metadata",
        "text": "## Summary - identify each direct, user-visible Kanban terminal notification with a versioned `hermes_kanban_notification` envelope - preserve existing subscription chat/thread routing metadata while keeping the stored subscription mapping unchanged - retain the distinct `hermes_kanban_wake` envelope on the later synthetic internal wake, including deterministic aggregate event ordering - preserve localized text, retry/cursor behavior, artifact delivery, ordinary sends, and the stateless API self-post shape ## Delivery-plane contracts Direct push notifications receive `metadata[\"hermes_kanban_notification\"]` version 1 with only: - `board` - `task_id` - the direct notification's `event_kind` - the claimed batch `cursor` The later push-capable synthetic `MessageEvent` receives `event.metadata[\"hermes_kanban_wake\"]` version 1 with only: - `board` - `task_id` - deterministically ordered aggregate `event_kinds` - the same claimed batch `cursor` The names are intentionally distinct: one direct notification represents one event, while one internal wake can aggregate several events from the claimed batch. ## Compatibility and privacy Existing subscription routing metadata remains alongside the direct-notification envelope and is copied before augmentation. Neither the caller mapping nor the stored subscription metadata is mutated. Calling `deliver_wake` without metadata still produces the existing internal text `MessageEvent` with an empty metadata mapping; supplied wake metadata is deep-copied. Both Kanban envelopes are explicit privacy allowlists. They exclude task content, summaries/results/reasons, arbitrary event payloads, profile/assignee identity, local paths, credentials, secret-like fields, and subscription delivery metadata. Localized visible text and ordinary adapter sends are unchanged. Stateless API self-post wakes retain their existing request shape and receive neither adapter-only envelope. Non-push delivery still relies on the existing self-post path. ## Overlap reconciliation A current open-PR scan before repair found adjacent Kanban notifier, routing, and wake work, but no open PR providing both direct per-event notification identity and the separate aggregate synthetic-wake contract. This PR remains the single candidate lineage. ## Verification - `scripts/run_tests.sh tests/gateway/test_kanban_notifier.py tests/gateway/test_kanban_notifier_apiserver_wake.py tests/gateway/test_kanban_notifier_watcher_dispatch_gate.py tests/gateway/test_kanban_notifier_zero_sub_gate.py tests/gateway/test_kanban_watchers_mixin.py tests/gateway/test_wake_delivery.py tests/hermes_cli/test_kanban_notify.py tests/tui_gateway/test_kanban_notify_poller.py -q` — 42 passed - `ruff check gateway/kanban_watchers.py gateway/wake.py tests/gateway/test_kanban_notifier.py tests/gateway/test_wake_delivery.py` — passed - `python -m py_compile gateway/kanban_watchers.py gateway/wake.py tests/gateway/test_kanban_notifier.py tests/gateway/test_wake_delivery.py` — passed - `git diff --check` — passed ## Residual risk Push notification text remains the delivery boundary before the best-effort synthetic wake, matching existing behavior. The shared claimed cursor identifies retries and aggregate batches; it is not a per-event database ID.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85374",
        "createdAt": "2026-08-13T14:56:56Z",
        "updatedAt": "2026-08-13T15:59:04Z",
        "timestamp": "2026-08-13T15:59:04Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "comp/cron",
          "P3",
          "sweeper:risk-message-delivery"
        ],
        "author": "rmacbot",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85375",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(hindsight): show embedded config in desktop",
        "text": "## Summary Fixes #84572 by making Desktop represent existing Hindsight `local_embedded` configuration honestly without bypassing the setup flow that installs its runtime dependencies. - preserves an existing `local_embedded` mode as a disabled, read-only select option instead of displaying the Cloud default - exposes the embedded LLM provider, base URL, model, and secret fields in Full config - maps the embedded LLM secret to `HINDSIGHT_LLM_API_KEY` - keeps selecting `local_embedded` setup-owned, while allowing edits to an already configured embedded installation - adds backend and Desktop behavior coverage ## Root cause The generic declared-config serializer replaced stored select values outside a surface's write allow-list with the field default. Hindsight intentionally omits `local_embedded` from Desktop's writable Mode options because `hermes memory setup` owns dependency installation, but that caused a valid setup-created value to render as Cloud. The declared schema also omitted the embedded LLM fields and secret mapping. ## User impact Users with Hindsight Local Embedded configured through setup now see their actual mode and LLM settings in Desktop and can edit those settings safely. Desktop still cannot create a partially installed embedded configuration. ## Validation - `152 passed, 1 skipped` across the current Hindsight schema and full web-server test files - `10 passed` in the Desktop provider-config panel test - Ruff passed - Prettier 3.9.5 passed - `git diff --check` passed",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85375",
        "createdAt": "2026-08-13T14:57:35Z",
        "updatedAt": "2026-08-13T15:08:10Z",
        "timestamp": "2026-08-13T15:08:10Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/plugins",
          "tool/memory",
          "area/config",
          "P3",
          "sweeper:risk-compatibility",
          "comp/desktop",
          "area/memory"
        ],
        "author": "aikeepsitreal",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85376",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "test(state): trace the pooled read connection in FTS projection coverage",
        "text": "## Summary - prime the pooled read path before attaching SQLite trace callbacks - trace the same connection that `_read_ctx()` will borrow during FTS searches - restore deterministic query-count coverage for context projection ## Root cause The test opened a fresh read connection with `_get_read_conn()` and attached a trace callback to it. Since the read path is now pooled, that connection was not returned to the pool and the searches borrowed a different connection. The context enrichment queries executed correctly, but the test observed zero statements. Using `_read_ctx()` once primes and returns the connection to the pool before tracing it, so the subsequent searches reuse the observed connection. ## Verification Baseline on current `main`: ```text 1 failed: TestFTS5Search::test_search_projection_skips_context_enrichment_queries ``` After this change: ```text 1 passed 222 passed in tests/test_hermes_state.py ruff check tests/test_hermes_state.py: All checks passed ``` Production code is unchanged.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85376",
        "createdAt": "2026-08-13T14:58:17Z",
        "updatedAt": "2026-08-13T15:08:12Z",
        "timestamp": "2026-08-13T15:08:12Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/test",
          "comp/agent",
          "P3"
        ],
        "author": "yuzilongleif-collab",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85378",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix: attribute Codex subscription usage correctly",
        "text": "## Reproduction On Hermes 0.20.0, an explicit openai-codex prompt succeeds against the ChatGPT backend, but the Codex quota helper returns no data because the pool-only OAuth path omits ChatGPT-Account-Id and receives HTTP 401. Desktop and gateway /usage also show Nous credits solely because a Nous login exists, even while openai-codex is active. ## Fix - derive the non-secret ChatGPT account identifier from the OAuth JWT for pool-only quota requests - show active-provider account limits in Desktop/TUI usage - show Nous credits only for Nous inference sessions - never persist or log token claims ## Verification - focused Hermes harness: 10 tests passed - controlled live prompt with --provider openai-codex --ignore-user-config --safe-mode returned CODEX_SUBSCRIPTION_OK and recorded non-zero tokens No API-key or Nous fallback is introduced.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85378",
        "createdAt": "2026-08-13T15:06:17Z",
        "updatedAt": "2026-08-13T15:16:43Z",
        "timestamp": "2026-08-13T15:16:43Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/gateway",
          "comp/tui",
          "provider/openai",
          "area/auth",
          "P2",
          "area/billing"
        ],
        "author": "PJHammond",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85379",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix: run python-script launchers through the current interpreter in hermes.desktop",
        "text": "## Problem On Linux, the auto-installed `hermes.desktop` menu entry can be silently broken. `resolve_exec_command()` writes `Exec=<argv[0]> desktop`, and when Hermes is launched through a venv wrapper script (e.g. `~/.local/bin/hermes` doing `exec venv/bin/python .../hermes-agent/hermes \"$@\"`), `argv[0]` is the in-checkout `./hermes` Python script. That script's `#!/usr/bin/env python3` shebang resolves to the **system** interpreter when the desktop launcher runs it — outside the venv — so it dies instantly: ``` File \".../hermes_cli/env_loader.py\", line 12, in <module> from dotenv import load_dotenv ModuleNotFoundError: No module named 'dotenv' ``` From the user's perspective, clicking Hermes in the app launcher just does nothing, while `hermes desktop` in a terminal works fine. And because the entry is regenerated on every launch, hand-fixing the `.desktop` file doesn't stick. ## Fix When the resolved launcher is itself a Python-shebang script, prefix the `Exec` line with `sys.executable` — the interpreter that is provably working, since it's running Hermes at that moment: ``` Exec=/home/user/.hermes/hermes-agent/venv/bin/python /home/user/.hermes/hermes-agent/hermes desktop ``` Non-script binaries and shell wrappers are left untouched. One subtlety: `sys.executable` is used **unresolved**. A venv's `bin/python` is a symlink, and its *path* is what selects the venv (via the adjacent `pyvenv.cfg`); `Path(...).resolve()` yields the base interpreter without the venv's site-packages, reintroducing the same crash. The pre-existing `-m hermes_cli.main` fallback had this latent issue and is fixed by the same change. ## Testing - Two new tests in `tests/hermes_cli/test_linux_desktop_entry.py` (written first, watched fail): - a `#!/usr/bin/env python3` script gets the current interpreter prefixed, asserting the venv symlink path is preserved un-resolved - a bash wrapper stays unprefixed - Full `test_linux_desktop_entry.py` + `test_gui_uninstall.py` suites pass (20 passed, 2 platform-skipped) - Verified end-to-end on Arch/Hyprland (walker launcher): regenerated entry launches the Electron app from the menu, where it previously crashed on import",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85379",
        "createdAt": "2026-08-13T15:07:22Z",
        "updatedAt": "2026-08-13T15:27:36Z",
        "timestamp": "2026-08-13T15:27:36Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "P2",
          "needs-decision",
          "sweeper:risk-compatibility",
          "comp/desktop",
          "area/install-update"
        ],
        "author": "hsearcy",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85382",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat: support Discord decision reactions",
        "text": "## Summary - add configurable Discord decision channels where 👍 approves and 👎 rejects bot-authored decision messages - isolate each reacted decision into its own session so rapid approvals cannot interrupt or overwrite one another - enforce existing user/channel authorization, ignore bot reactions, and preserve first-reaction semantics while allowing retry after failed handoff - document `discord.decision_reaction_channels` ## Test plan - [x] `scripts/run_tests.sh tests/gateway/test_discord_*.py tests/plugins/platforms/test_discord_gate_isolation.py -q` - 279 passed, 0 failed, 1 skipped - [x] Ruff checks pass - [x] `git diff --check` passes - [x] independent security/logic re-review passes ## Activation This PR does not merge or activate the feature. A configured decisions-channel ID and gateway restart are required after merge.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85382",
        "createdAt": "2026-08-13T15:15:03Z",
        "updatedAt": "2026-08-13T15:24:49Z",
        "timestamp": "2026-08-13T15:24:49Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/plugins",
          "platform/discord",
          "area/config",
          "P3"
        ],
        "author": "kundanuppal7-art",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85386",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(redact): mask WebSocket transport credentials",
        "text": "## What does this PR do? Redacts opaque `access_key` and `ticket` values from `ws://` and `wss://` endpoint query strings before they reach logs or tool output. The redaction is deliberately limited to WebSocket transport URLs. Ordinary `http://` and `https://` links keep the existing passthrough behavior so OAuth callbacks, magic links, and pre-signed URLs remain actionable. ## Related Issue N/A - no matching issue or pull request was found. ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [x] 🔒 Security fix - [ ] 📝 Documentation update - [ ] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `agent/redact.py`: add focused WebSocket transport query redaction for `access_key` and `ticket`, preserving other parameters and URL components. - `tests/agent/test_redact.py`: cover log formatting, case-insensitive parameter names, public WebSocket parameters, and HTTP(S) passthrough. ## How to Test 1. Run `.venv/bin/ruff check agent/redact.py tests/agent/test_redact.py`. 2. Run `scripts/run_tests.sh tests/agent/test_redact.py -q`. 3. Confirm Ruff passes and all 102 redaction tests pass. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26.5.2, Python 3.14.3 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys - N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows - N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) - uses only existing Python regex/string handling - [x] I've updated tool descriptions/schemas if I changed tool behavior - N/A ## Screenshots / Logs N/A - behavior is covered by automated tests.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85386",
        "createdAt": "2026-08-13T15:22:38Z",
        "updatedAt": "2026-08-13T15:22:38Z",
        "timestamp": "2026-08-13T15:22:38Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "Zero94Smile",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85388",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(pricing): apply DeepSeek peak/off-peak rate card (#72662)",
        "text": "## Summary Implements #72662: DeepSeek's official rate card switches to peak/off-peak billing at **2026-08-16T16:00 UTC** (announced on https://api-docs.deepseek.com/quick_start/pricing): - Peak hours: **01:00–04:00 and 06:00–10:00 UTC** (all other hours off-peak) - Peak = 2× off-peak on every billing item (cache-hit input, cache-miss input, output) ## Changes ### `agent/usage_pricing.py` - **Snapshot updated to the 2026-08-16 OFF-PEAK rates**: deepseek-v4-flash $0.007 / $0.22 / $0.66 per 1M (cache-hit / cache-miss / output), deepseek-v4-pro $0.022 / $0.66 / $1.98. `pricing_version` bumped to `deepseek-pricing-2026-08-16`. The deepseek-chat / deepseek-reasoner aliases keep sharing flash's rates. - **`estimate_usage_cost` peak/off-peak selection**: before the switchover instant the legacy flat card (`deepseek-pricing-2026-07`) applies; after it, calls during peak hours bill at 2× the off-peak rates and the `CostResult` carries a \"peak-hour rate applied\" note. Peak windows are read as half-open intervals → hours 1, 2, 3 and 6, 7, 8, 9. - The legacy flat card lives in a small temporary module dict (`_DEEPSEEK_LEGACY_FLAT_RATES`) so estimates stay accurate during the transition window; it is marked for removal after the switchover. ### `tests/agent/test_usage_pricing.py` 7 new tests: - peak-hour boundary matrix (hours 0–11, 23) incl. exact window edges (00:59:59 → 01:00:00, 03:59:59 → 04:00:00, 09:59:59 → 10:00:00) - official-table amounts for both models (1M in + 1M out: flash $0.88 off-peak / $1.76 peak; pro $2.64 / $5.28) - cache-read tokens scale at peak ($0.007 → $0.014 flash) - pre-switchover legacy flat rates (incl. a would-be peak hour) - switchover instant boundary (15:59:59Z legacy, 16:00:00Z new card) - non-DeepSeek providers unaffected during peak hours - updated `test_deepseek_v4_pro_pricing_entry_exists` to the new card ## Testing - `tests/agent/test_usage_pricing.py`: 20 passed - All usage_pricing consumers (insights, model cost guard, usage command, MoA loop, context engine): 100 passed total - A large combined batch run shows ~120 failures in unrelated suites (title generator, secret-scope migration, codex transport, etc.) that pass in isolation on both main and this branch — known order-dependent batch interference in this repo, unrelated to this change. ## Notes - **Boundary semantics**: peak windows are treated as half-open [01:00, 04:00) and [06:00, 10:00) UTC, matching the 09:00–12:00 / 14:00–18:00 Beijing framing (clean 12:00–14:00 lunch gap). Hour 4 (04:00–05:00) is off-peak. - **Transition window**: rates are selected at call time (post-request), matching DeepSeek's per-request timestamp billing. Before 2026-08-16T16:00Z the legacy flat card is used, so estimates don't overstate during the transition. - Supersedes the earlier attempt #72756, whose 2×-on-current-rates approach no longer matches DeepSeek's published pricing (the official off-peak rates themselves differ from the current flat card). - **Credit**: feature request and the peak-window UTC mapping are from @dominicelayda's issue #72662; @webtecnica's earlier attempt (#72756) established the multiplier approach and surfaced the need for the official rate card. This PR is a fresh implementation against the official card. Closes #72662",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85388",
        "createdAt": "2026-08-13T15:24:17Z",
        "updatedAt": "2026-08-13T17:52:19Z",
        "timestamp": "2026-08-13T17:52:19Z",
        "metrics": {
          "reactions": 0,
          "comments": 6
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "provider/deepseek",
          "P3",
          "area/usage-cost"
        ],
        "author": "DavidMetcalfe",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85389",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(gateway): native MCP Streamable-HTTP endpoint + OpenAI tool-progress chunks",
        "text": "## Summary Adds a native **MCP Streamable-HTTP endpoint** to the Hermes API server (gateway), so MCP clients (e.g. Open WebUI) can consume agent tools directly from the gateway process — replacing the need for a separate MCP bridge service. Also makes tool-progress visible to strict OpenAI-stream clients by additionally emitting standard `chat.completion.chunk` with `delta.tool_calls`. ## Changes 1. **`gateway/mcp_endpoint.py`** (new) — mounts FastMCP (SSE mode) into the aiohttp app via an ASGI bridge (~90 lines, `_hold_mcp_lifespan` keeps the session task-group alive for the app lifetime). Routes: `/mcp` and automatic `/p/<profile>/mcp` mirrors. Auth: Bearer `API_SERVER_KEY` (scope-aware per profile), 401 on missing/invalid key. `transport_security.allowed_hosts` set to `172.17.0.1:*` / `host.docker.internal:*` because the gateway binds the docker bridge IP, not loopback. 2. **`gateway/mcp_tools_exec.py`** (new) — 9 execution tools (`execute_terminal`, `read_file`, `write_file`, `list_files`, `search_files`, `http_request`, `docker_ps`, `docker_logs`, `docker_exec`) running **inside the gateway process**, with 3 safety layers re-established in Python: command whitelist (`validateCommand`), path confinement to configured allowed roots (`validatePath`), per-identity sliding-window rate limit + timeout. Every executed tool call writes an audit event to MongoDB (parity with the old bridge behaviour). 3. **`gateway/mcp_tools_admin.py`** (new) — 8 admin tools (`skill_list/read/create/update/delete`, `audit_recent`, `mcp_list`, `mcp_remove`) backed by MongoDB (users/events collections), same semantics as the previous facade implementation. 4. **`gateway/mcp_acl.py`** (new) — per-user role resolution from MongoDB `users` via `X-Hermes-User-Id` (fallback `X-Hermes-User`); most-restrictive role wins; admin tools return `ERROR: admin permission required` for non-admin. 5. **`gateway/platforms/api_server.py`** — (a) mounts the MCP endpoint + lifespan on the existing HTTP server; (b) additive OpenAI-stream compatibility: each internal tool-progress event additionally emits a standard `chat.completion.chunk` carrying `delta.tool_calls` (stable per-call `index`, `function.name` on `running`, empty `arguments` on `completed`), so strict OpenAI clients like Open WebUI can render tool steps. The custom `hermes.tool.progress` event is preserved; `finish_reason` stays `None` because tools run server-side. ## Configuration New non-secret config block `gateway.mcp` (config.yaml): `allowed_paths` (host workspace roots), `whitelist_commands`, `rate_limit_per_hour` (default 100), `tool_timeout_ms` (default 30000). MongoDB URI is read from the Hermes env file as a secret (`MCP_MONGO_URI`). ## Testing (all real, on WSL + Open WebUI stack) - MCP handshake over SSE: `initialize` 200 + `Mcp-Session-Id`; `tools/list` returns 18 tools (17 legacy parity + `echo` smoke); `DELETE` terminates 200. - ACL matrix: admin → all tools + `audit_recent` OK; user role → `ERROR: admin permission required`; missing key → 401; cross-profile key (`/p/<team>/mcp`) → 401. - Safety layers: `rm` (not whitelisted) → `command not allowed`; `/etc/passwd` → `outside allowed roots`; burst > limit → 429. - Audit continuity: `list_files` + `execute_terminal` calls produce fresh `type=tool` events in MongoDB with the correct userId (a regression bug where 7/9 exec tools skipped auditing was found and fixed in the final commit). - Open WebUI end-to-end: chat + MCP tools work via `http://host.docker.internal:8642/mcp`; the separate MCP bridge container was removed from the compose stack. ## Notes - The delta.tool_calls commit keeps the original `hermes.tool.progress` event intact — additive only. - The local-patch comment in the second commit is descriptive of its origin; happy to reword on review.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85389",
        "createdAt": "2026-08-13T15:25:19Z",
        "updatedAt": "2026-08-13T15:25:19Z",
        "timestamp": "2026-08-13T15:25:19Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "thucnobita97",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85390",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(memory): add interactive setup support for self-hosted supermemory",
        "text": "## Description Updates the supermemory plugin to explicitly support self-hosting by prompting for the \base_url during hermes memory setup and saving it to supermemory.json. This removes the need for users to manually edit supermemory.json prior to running the setup wizard, smoothing out the user experience for local installations while adhering to the configuration footprint rules (i.e., avoiding new \\.env\\ variables for non-secret configs).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85390",
        "createdAt": "2026-08-13T15:25:57Z",
        "updatedAt": "2026-08-13T15:25:57Z",
        "timestamp": "2026-08-13T15:25:57Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "akashgohilwork-art",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85392",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "test(web): preserve MCP profile scoping",
        "text": "Related to #85301 The current web API layer already scopes `/api/mcp/*` requests through the global management-profile mechanism. This adds a focused regression test for `api.getMcpServers()` so switching profiles cannot silently regress to reading the dashboard process profile. Checks: - `npx vitest run --config vitest.config.ts src/lib/api.test.ts` (8 passed) - `npm run typecheck` - `npx eslint src/lib/api.test.ts`",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85392",
        "createdAt": "2026-08-13T15:29:01Z",
        "updatedAt": "2026-08-13T15:29:01Z",
        "timestamp": "2026-08-13T15:29:01Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "seanxuu",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85393",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cron): retry pinned dead model via fallback_providers on 402",
        "text": "## Summary Cron jobs pinned to a dead model failed with HTTP 402 for days, ignoring `fallback_providers`. The cron layer only had fallback at the resolve stage (credential-dead) but not at runtime 402 — a pinned dead model (quota exhausted / plan removed) made the job fail forever with `Fallbacks=None`. ## Change (`cron/scheduler.py`) - **Dead-model 402 detection** (+100): `_DEAD_MODEL_BILLING_PATTERNS` / `_is_dead_model_billing_error()` — cause-chain aware (checks `status_code` attr + message text), mirrors the billing buckets in `agent/error_classifier.py` so cron recognizes the same failure class the agent loop does. Disambiguates transient usage-limit (resets) from dead-model (never recovers). - **Bounded fallback re-run in `run_job`**: when the pinned model's run fails with a dead-model 402, the job re-runs once against `fallback_providers` (finite, 1 extra run per tick) — never dies for days, never overspends. - The diff refactors the `run_job` error path (335+/217-) to fold the fallback retry into the existing delivery/error handling. ## Verification - `tests/cron/test_scheduler.py`: **68 passed**. Stylistically mirrors the fallback helpers from #84008 (not merged — no conflict). Closes #85215",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85393",
        "createdAt": "2026-08-13T15:32:39Z",
        "updatedAt": "2026-08-13T15:32:39Z",
        "timestamp": "2026-08-13T15:32:39Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "webtecnica",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85394",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): guard merged assistant compaction handoffs",
        "text": "## What does this PR do? Extends the #80622 active-turn guard to one merged-carrier shape that still escaped it. When compaction merges its summary into an assistant tail message, the carrier keeps `role=\"assistant\"`, the assistant's prior prose, and any inherited `tool_calls`. If that merged carrier immediately follows a completed assistant response with `finish_reason=\"stop\"`, those preserved fields describe the completed assistant turn; they are not evidence of a fresh user request or a live tool loop. Without this guard, `reference_handoff_would_drive_next_model_call()` treats the carrier's merged prose as live content and its own stale `tool_calls` as in-flight work, allowing the reference-only handoff to drive another model call and repeat completed work. The fix is deliberately narrow: - only `classify_summary_content(...) == \"merged\"` - only an assistant-role carrier - only when the immediately preceding assistant message completed with `finish_reason=\"stop\"` - the carrier's own inherited `tool_calls` do not count as later live work - a real user turn after the carrier still proceeds - a distinct later assistant tool-call row still proceeds - a merged carrier without the completed-stop boundary remains an in-flight loop - standalone handoffs continue to use the existing guard ## Issue linkage - Follow-up to #80622 - Addresses the remaining merged-assistant-carrier leak in #42768 ## Tests RED first: - added the production-shaped merged assistant carrier - `1 failed, 18 passed` on current `main` - only `test_merged_assistant_carrier_after_completed_stop_drives` failed GREEN: - `195 passed` across the active-turn guard, compressed-summary metadata, context compressor, summary continuity, zero-user provenance, temporal anchoring, session-end state, and cross-session guard suites - `ruff` clean - `compileall` clean - `git diff --check` clean - GitNexus: 2 files, 9 symbols, 0 affected processes, low risk ## Checklist - [x] I have read the contributing guidelines - [x] This PR targets `main` - [ ] `pytest tests/ -q` passes locally - [x] Relevant docs/comments updated where needed - [x] No dependencies added - [x] The diff is limited to the guard and its regression tests I ran the complete adjacent compaction surface rather than claiming an unrelated full-suite run. The patch changes 2 files with 104 additions and 2 deletions; 85 additions are regression tests.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85394",
        "createdAt": "2026-08-13T15:34:03Z",
        "updatedAt": "2026-08-13T15:45:11Z",
        "timestamp": "2026-08-13T15:45:11Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [],
        "author": "abundantbeing",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85395",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(kanban): color-code cards by project",
        "text": "## Summary - attribute dashboard tasks to first-class projects using explicit id, unique path, then conservative metadata matching - add project badges, a blocked/clear project status strip, project filtering, and project-aware search - add focused backend and bundle regression coverage ## Verification - `python -m pytest tests/plugins/test_kanban_dashboard_plugin.py -q -k \"not test_home_channels_lists_only_platforms_with_home\"` (22 passed; excluded known environment-sensitive Photon home failure) - `python -m pytest tests/hermes_cli/test_kanban_board_project.py -q` (4 passed) - `python -m py_compile plugins/kanban/dashboard/plugin_api.py` - `node --check plugins/kanban/dashboard/dist/index.js` - independent blocker-only review: PASS",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85395",
        "createdAt": "2026-08-13T15:35:20Z",
        "updatedAt": "2026-08-13T15:41:42Z",
        "timestamp": "2026-08-13T15:41:42Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "kyan12",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85396",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(wake-word): support Chinese wake phrases via sherpa wenetspeech pinyin model",
        "text": "## Summary The sherpa wake-word engine hardcoded BPE tokenization (`text2token` + `bpe.model`), which only covers Latin-script phrases on the default English GigaSpeech model. The wenetspeech KWS model — sherpa-onnx's Chinese counterpart — is **pinyin-modeled** (声母/韵母 phonemes) and ships no `bpe.model`, so pointing `wake_word.sherpa.model_dir` at it crashed at listener init. ## Changes - **Model-family detection**: `_SherpaKwsEngine` now checks for `bpe.model` — BPE models keep the existing `text2token` path; pinyin models (no `bpe.model`) romanize phrases to phonemes. - **Pinyin tokenizer** (`_phrase_to_pinyin_phonemes`): splits pypinyin's toned syllables (`Style.TONE`) after the initial — `nǐ → n ǐ`, `wén → w én`, `huì → h uì` — which also fixes pypinyin's mis-timed bare finals (`úen`, `ueì`), with a `ü→u` fallback for finals the vocab spells without umlaut (`üé → ué`). - **Graceful degradation**: phrases a model cannot represent (e.g. Latin on the pinyin model) are skipped with a warning instead of failing the whole listener — so mixed-language profile routing degrades safely rather than crashing. ## Verification - Unit tests: 31 pass (5 new pinyin-tokenization tests, offline-safe via `tmp_path` + `pytest.importorskip`). - E2E against the real wenetspeech model + its bundled test wavs: all 8 official keywords recognized (`文森特卡索/周望军/朱丽楠/蒋友伯/女儿/法国/见面会/落实`) with correct phoneme output matching the official `keywords.txt` byte-for-byte. - TTS-rendered `你好妮妮` fires the listener with correct profile routing (`last_match == ('你好妮妮', 'nina')`). ## Docs New \"Chinese wake phrases (pinyin model)\" section under the sherpa engine docs with model download link and config example. ## Notes - `pypinyin` was already a `wake.sherpa` lazy-dep (sherpa_onnx's `text2token` imports it unconditionally, so no new dependency. - The wenetspeech model (~13 MB) is auto-downloadable from sherpa-onnx releases; users point at the unpacked directory. EOF )",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85396",
        "createdAt": "2026-08-13T15:35:56Z",
        "updatedAt": "2026-08-13T15:35:56Z",
        "timestamp": "2026-08-13T15:35:56Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "ruhipiano",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85397",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(install): fail when Node dependencies cannot install",
        "text": "## Summary - Treat the checkout-root and TUI `npm install` operations as required steps in `scripts/install.sh`. - Propagate dependency-install failures through both the monolithic installer and the `node-deps` stage, restore tracked lockfile churn, and suppress success output when installation fails. - Preserve successful installs and the existing best-effort behavior for optional browser, Browser Use, and Computer Use setup. ## Validation - Focused regression tests: 3 passed. - Nearby installer tests: 29 passed. - Installer family: 90 passed, 1 unrelated pre-existing failure, and 2 skips. - `bash -n`, Python compilation, and `git diff --check` passed. - Full `scripts/run_tests.sh` was attempted; the available Python 3.14.6 environment stopped the bounded run after environment-caused failures from missing `acp`, `python-dotenv`, and `openai` dependencies and the async pytest plugin. - CodeRabbit committed-diff review: no findings. Fixes #85297 ## Agent Disclosure - Created by: GPT-5.6 in Codex - Human looked at and manually signed the commit",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85397",
        "createdAt": "2026-08-13T15:37:51Z",
        "updatedAt": "2026-08-13T15:37:51Z",
        "timestamp": "2026-08-13T15:37:51Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "egilewski",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85398",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(telegram): anchor fenced-code regex so inline triple backticks aren't mangled",
        "text": "## What Anchor the Telegram MarkdownV2 fenced-code protection regex to line starts so it only matches **standalone** fenced code blocks, and stop it from swallowing **inline** triple-backtick spans. Fixes #85381. ## Why In `plugins/platforms/telegram/adapter.py`, `format_message()` protects fenced code blocks before MarkdownV2 escaping with: ```python r'(```(?:[^\\n]*\\n)?[\\s\\S]*?```)' ``` The `(?:[^\\n]*\\n)?` makes the newline after the opening fence **optional**, so the pattern also matches inline triple backticks on a single line (e.g. a reply containing *\"the syntax is ```like this``` inline\"*). `_protect_fenced` then treats that inline span as a `pre` block, splits on a `\\n` that isn't there, and escapes the backticks into an **unbalanced** entity. Telegram rejects the whole message: ``` [Telegram] MarkdownV2 parse failed, falling back to plain text: Can't parse entities: can't find end of pre entity at byte offset 922 ``` Hermes then falls back to plain text, dropping **all** rich formatting (bold, tables, links) for the entire message. ## Fix - Anchor the pattern to line starts (`(?m)`, 0–3 spaces of indent), requiring the opening fence to end with a newline and the closing fence to sit on its own line: ```python r'(?m)^([ ]{0,3}`{3}[^\\n]*\\n)([\\s\\S]*?)(^[ ]{0,3}`{3})[ \\t]*$' ``` Inline triple-backtick spans no longer match; their backticks fall through to normal escaping and render as valid literal backticks instead of a broken `pre`. - Rewrite `_protect_fenced` to use the three explicit capture groups (opening line / body / closing fence) instead of the naive `raw[:-3]` slice. As a side benefit this also fixes a **doubled closing fence** when the closing line carried trailing whitespace (the old `[:-3]` stripped the trailing spaces, not the backticks). ## Tests Added to `tests/gateway/test_telegram_format.py::TestFormatMessageCodeBlocks`: - `test_inline_triple_backticks_not_treated_as_fence` — inline `` ```…``` `` no longer emits a raw fence. - `test_fence_and_inline_backticks_mixed` — a real block is still protected while a trailing inline span in the same message is not turned into a second, unbalanced fence. - `test_fence_with_trailing_whitespace_on_close` — closing fence with trailing spaces isn't doubled. Existing fenced/inline-code tests continue to pass (45 passed). Preflight (`windows-footguns`, `ruff`, affected tests) green vs `upstream/main`. The known arm64-fork-Docker CI job failure is unrelated to this change.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85398",
        "createdAt": "2026-08-13T15:40:13Z",
        "updatedAt": "2026-08-13T15:40:13Z",
        "timestamp": "2026-08-13T15:40:13Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "PRATHAMESH75",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85400",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(startup): coordinate one-shot session context",
        "text": "Adds a single bounded StartupCoordinator path before the first prompt build. Startup context is attached once to the first user sidecar, receipts are immutable and redacted, duplicate injectors are rejected, and system-prompt bytes remain stable. Cold diagnostic callers avoid loading optional plugin and MCP runtimes.\\n\\nValidation: canonical scripts/run_tests.sh; 107 focused tests passed.\\n\\nLocal activation remains separate from upstream merge.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85400",
        "createdAt": "2026-08-13T15:46:08Z",
        "updatedAt": "2026-08-13T15:46:08Z",
        "timestamp": "2026-08-13T15:46:08Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "lightcloud00",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85401",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(title): support deterministic-only auto titles",
        "text": "Allows automatic titles to use the deterministic path without enabling later model-based title upgrades. The default behavior remains unchanged.\\n\\nValidation: canonical scripts/run_tests.sh; 35 title tests passed.\\n\\nRelated: #85194",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85401",
        "createdAt": "2026-08-13T15:46:10Z",
        "updatedAt": "2026-08-13T17:30:45Z",
        "timestamp": "2026-08-13T17:30:45Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "P3",
          "area/sessions"
        ],
        "author": "lightcloud00",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85402",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(repo-governance): add D1 repository identity validation",
        "text": "## Summary - Add D1 canonical repository identity validation. - Add Darwin exact-child repository observation. - Add policy vectors and bounded regression tests. ## Validation - 4/4 final-byte tests passed. - 10/10 targeted tests passed. - 79/79 bounded suite passed twice. ## Scope - 12 files added with 2,610 insertions. - Head commit: 52c641b9dbf2be60bfa377c225b536c74e0df7c5. ## Governance - Open as a draft; do not merge automatically. - External mutation exclusivity and same-SHA ABA absence remain unproven.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85402",
        "createdAt": "2026-08-13T15:47:10Z",
        "updatedAt": "2026-08-13T15:47:10Z",
        "timestamp": "2026-08-13T15:47:10Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "chineseclouds",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85403",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(whatsapp): pair into the session dir the gateway reads, and reject empty creds",
        "text": "## What Fixes two WhatsApp pairing bugs from #85391 that together strand users in a restart loop against an \"enabled but not paired\" precondition no restart can satisfy. Fixes #85391 (Bugs 1 & 2). Bug 3 in the issue is a minor packaging/UX note (dependency-less bridge copy under the install root) and is left for a follow-up. ## Bug 1 — wizard paired into a directory nothing reads The gateway adapter and the dashboard both resolve the session dir via: ```python get_hermes_dir(\"platforms/whatsapp/session\", \"whatsapp/session\") ``` but the `hermes whatsapp` wizard hard-coded the **legacy** path: ```python session_dir = get_hermes_home() / \"whatsapp\" / \"session\" ``` `get_hermes_dir(new, old)` returns the legacy path *only while it exists with content* (an empty stub does not count — see the `#27602` note in `hermes_constants.py`). So the two agree only while `whatsapp/session` is populated. The moment it's emptied — a natural \"start clean\" move — the wizard keeps writing to `whatsapp/session` while the gateway reads `platforms/whatsapp/session`. Re-pairing then has no effect: the gateway replays the old (revoked) creds and loops on `Logged out`. **Fix:** route the wizard through a shared `_whatsapp_session_path()` helper that mirrors the reader's resolver, so writer and reader can never diverge. ## Bug 2 — a 0-byte `creds.json` counted as \"paired\" The start preflight was existence-only: ```python creds_path = self._session_path / \"creds.json\" if not creds_path.exists(): ``` In `--pair-only` mode the bridge writes `creds.json` **after** emitting the `connected` event and then exits on its own, so a supervisor that terminates the bridge on `connected` truncates the file. The reporter saw exactly this: `connected` fired, **826** key files were written, yet `creds.json` was **0 bytes** — and existence-only passed, so the gateway proceeded on unusable credentials. **Fix:** add `_has_valid_creds()` — non-empty, parseable JSON, carrying the Baileys `noiseKey`/`signedIdentityKey` — and gate `start()` on it instead of bare existence. The \"not paired\" log line now says *missing, empty, or truncated*. ## Tests - `tests/gateway/test_whatsapp_creds_validation.py` — new: missing / 0-byte / truncated / non-object / keyless creds all rejected; a valid creds dict accepted. - `tests/hermes_cli/test_whatsapp_session_path.py` — new: fresh install and an **empty legacy stub** both resolve to the consolidated path; a populated legacy dir is still honored; and the wizard agrees with the adapter's default. - Existing `test_whatsapp_connect.py` / `test_whatsapp_stale_bridge.py` fixtures updated to write valid creds (and bypass the preflight where they exercise `connect()`'s downstream paths), since the preflight now validates content, not just existence. Preflight (`windows-footguns`, `ruff`, affected tests) green vs `upstream/main`. Pre-existing bare `write_text` calls in the two touched test files were given `encoding=\"utf-8\"` to satisfy the windows-footgun gate. The known arm64-fork-Docker CI job failure is unrelated to this change.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85403",
        "createdAt": "2026-08-13T15:50:08Z",
        "updatedAt": "2026-08-13T17:30:24Z",
        "timestamp": "2026-08-13T17:30:24Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/plugins",
          "platform/whatsapp",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-compatibility"
        ],
        "author": "PRATHAMESH75",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85404",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "perf(desktop): bound long-running app resource use",
        "text": "- persist exact backend ownership and coordinate shutdown so failed starts and app relaunches do not leave resource-heavy servers behind - park inactive panes and use a weighted protected-session cache so hidden transcripts no longer grow renderer memory without bound - preserve active, stateful, and terminal surfaces while validating lifecycle behavior across renderer, Electron, and backend tests Tests: - Desktop renderer typecheck - ESLint on changed Desktop files - 75 focused renderer tests - 24 focused Electron lifecycle tests - 2 backend parent-watchdog tests 💘 Generated with Crush",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85404",
        "createdAt": "2026-08-13T15:52:47Z",
        "updatedAt": "2026-08-13T15:52:47Z",
        "timestamp": "2026-08-13T15:52:47Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "aleksclark",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85405",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): passive update check must compare against the official repo, not the local origin",
        "text": "## Problem The desktop app's passive update check (\"Settings → About → Check now\", and the background auto-check) can report **\"You're on the latest version\" while the checkout is thousands of commits behind the official repo.** `checkUpdates()` in `apps/desktop/electron/main.ts` only probed the official repo when the local git origin was the **official SSH** URL (`git@github.com:NousResearch/hermes-agent.git`). For every other origin — including the official **HTTPS** remote and any **fork** — it ran `git fetch origin <branch>` and compared `HEAD` against *that* origin's branch. A fork origin is common on contributor machines. If the fork's `main` trails the local `HEAD`, the comparison reads as \"0 behind\" and the app concludes it's current — even when official `main` is far ahead. Updates are then silently never offered, and the auto-updater never recovers on its own. ### Reproduction (real install) - Local checkout: official `main` @ `01a1037d1` (2026-08-05), origin pointed at a fork (`33hodl/hermes-agent`, fork `main` @ 2026-07-04) - Local `HEAD` was **6,431 commits ahead** of the fork's `main` → `behind = 0` - App reported \"You're on the latest version\" while official `main` was **~1,500 commits ahead** of `HEAD` - The About panel's git metadata confirmed the checkout was a source install (branch/commit shown), i.e. exactly the path that uses this check ## Fix The passive check now **always probes the official repo first** via anonymous HTTPS `ls-remote` — the same read-only, no-auth, no-SSH/FIDO2-prompt mechanism the code already used for official SSH remotes (`update-remote.ts`). Only when that probe fails (offline / GitHub unreachable) does it fall back to the old behavior: - official SSH origins → fail-fast `fetch-failed` (unchanged; avoids the hardware-touch prompt) - all other origins → the previous `git fetch origin` comparison path (unchanged) So a fork or stale-mirror origin can no longer masquerade as \"latest\". The verdict is always about the official repo's branch, which is the question a user is actually asking. New testable helper `isOfficialRemote()` in `update-remote.ts` classifies SSH/HTTPS official remotes for the fallback decision. ## Why not an alternative - **Fix the origin instead** — doesn't scale: forks and mirrors are a legitimate, documented setup for contributors and organizations; the check itself should be honest regardless of origin. - **Make apply pull from the official repo too** — out of scope: active apply flows are deliberately left unchanged (installs that intentionally track a fork keep pulling from their own origin). This PR only fixes the *check's* source of truth. - **Require a release-tag comparison** — `main`-tracking installs (the default) check against the branch they track, not the last release; comparing against the official branch keeps the existing semantics, just with the correct reference. ## Validation - `npx vitest run electron/update-remote.test.ts` → **7/7 passing** (6 existing + new `isOfficialRemote` coverage) - `esbuild` parse check on the modified `main.ts` → clean - Production observation: the reproduction above — after this change, the same install compares against official `main` and reports the true behind state instead of \"latest\" No speculative surface: single-purpose diff (3 files, +62/−22), behavior preserved for every previously-correct path.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85405",
        "createdAt": "2026-08-13T15:53:13Z",
        "updatedAt": "2026-08-13T17:30:14Z",
        "timestamp": "2026-08-13T17:30:14Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "P2",
          "comp/desktop",
          "area/install-update"
        ],
        "author": "33hodl",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85407",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cli): keep Windows spinner updates on one row",
        "text": "## What does this PR do? The classic CLI spinner now remains on one physical terminal row instead of pushing every timed repaint into Windows Terminal scrollback. The widget clips long status text before the terminal autowrap cell, preserving live progress while keeping the prompt stable. ### Symptom On Windows 10 with Windows Terminal and PowerShell, a long classic CLI spinner adds a new line on every update rather than refreshing in place. The input tail is repeatedly copied into scrollback with the spinner frames. ### Impact Classic CLI users on the reported Windows terminal path accumulate repeated spinner and prompt rows throughout a tool call, obscuring conversation output and making the active input area unstable. ### Bug Cause **Trigger:** `cli.py` / `_spinner_widget_height()` and the classic CLI `spinner_widget` **Causal chain:** 1. Live token-flow status makes the spinner text long enough to exceed the available terminal width. 2. The spinner widget allows wrapping and dynamically advertises multiple rows. 3. In prompt_toolkit's non-full-screen Windows output path, timed layout repaints push those changing rows into scrollback instead of updating one row in place. **Why it is wrong:** A transient status widget must have stable geometry. Allowing its height to change during timed repaints turns terminal autowrap into persistent output. **Working sibling / contrast:** Short spinner text stays within one row and therefore does not trigger the dynamic-height path. **Ruled out:** The raw `KawaiiSpinner` writer is not responsible because the classic CLI supplies a thinking callback and renders this prompt_toolkit widget; the reported screenshot also repeats the adjacent input tail, showing that the whole bottom layout moves. ### Fix Keep the spinner widget at one row, disable line wrapping, and trim rendered status text to `terminal width - 1` so prompt_toolkit never writes the terminal's final autowrap cell. The regression test uses wide characters to assert both stable height and display-width-safe clipping. ## Related Issue Closes #85380 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `cli.py` - keep the classic CLI spinner on one row and clip text before the autowrap column. - `tests/cli/test_cli_status_bar.py` - cover stable height and display-width-safe clipping with wide text. ## How to Test 1. In Windows Terminal with PowerShell, run the classic CLI and start a turn whose live spinner/status exceeds the terminal width. 2. Leave the status repainting for several seconds and verify that it stays on one clipped row with the input stable below it. 3. Run the targeted automated suite: ```bash scripts/run_tests.sh tests/cli/test_cli_status_bar.py ``` Verified locally: 20 passed. The Windows Terminal/PowerShell comparison reproduced four wrapped rows on the base revision and one clipped row across the same 60-frame, 12-second sequence on this branch. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Windows 11 with Windows Terminal and Windows PowerShell ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - N/A; behavior is internal and covered by a method docstring and regression test - [x] I've updated `cli-config.yaml.example` if I added/changed config keys - N/A; no config keys changed - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows - N/A; no architecture or workflow changed - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) - [x] I've updated tool descriptions/schemas if I changed tool behavior - N/A; no model tool changed ## Screenshots / Logs The real Windows terminal verification used the same timed repaint sequence on the detached base and this branch: the base occupied four physical rows, while this branch remained on one clipped row with the input stable below it.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85407",
        "createdAt": "2026-08-13T16:00:07Z",
        "updatedAt": "2026-08-13T16:00:07Z",
        "timestamp": "2026-08-13T16:00:07Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "fangliquanflq",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85408",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(kanban): stop workers after lifecycle handoff",
        "text": "## What does this PR do? Stops a Kanban worker immediately after it successfully hands custody off through `complete`, `block`, `request-review`, or `request-changes`, and binds the actual runtime provider/model/API mode/session to the corresponding durable run receipt. This closes two concrete failure modes observed in a bounded native-Kanban campaign: - a stale worker continued writing after its lifecycle handoff; and - task receipts could describe requested profile configuration without proving the runtime route that actually executed the worker. The lifecycle latch is deliberately narrow: it requires both a real `HERMES_KANBAN_TASK` and a dispatcher-owned worker context. Ordinary orchestrators, delegated children, inherited cron contexts, and failed lifecycle calls do not stop. ## Related Issue Related to #82591. This is a bounded lifecycle/receipt hardening slice, not closure of that epic. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - Propagate trusted runtime identity outside model-controlled tool arguments. - Stamp provider, model, API mode, session, and source on all four custody-ending Kanban lifecycle paths. - Persist metadata for blocked and changes-requested runs. - Latch only successful dispatcher-owned worker handoffs. - Cancel later same-batch calls, including calls in later execution segments. - End the worker conversation loop without another provider iteration. - Cover spoof resistance, all four lifecycle outcomes, context boundaries, failed calls, same-segment cancellation, and later-segment cancellation. ## How to Test 1. `scripts/run_tests.sh $(python3 -c 'import glob; print(\" \".join(sorted(glob.glob(\"tests/**/*kanban*.py\", recursive=True))))')` 2. `scripts/run_tests.sh tests/run_agent/test_run_agent.py::TestConcurrentToolExecution::test_invoke_tool_dispatches_to_handle_function_call tests/tools/test_kanban_runtime_receipts.py tests/run_agent/test_tool_batch_segmentation.py` 3. `python3 -m compileall -q agent hermes_cli model_tools.py tools/kanban_tools.py` 4. `uv run --with ruff ruff check agent/agent_init.py agent/agent_runtime_helpers.py agent/conversation_loop.py agent/tool_executor.py hermes_cli/kanban_db.py model_tools.py tools/kanban_tools.py tests/run_agent/test_run_agent.py tests/run_agent/test_tool_batch_segmentation.py tests/tools/test_kanban_runtime_receipts.py` 5. `git diff --check origin/main...HEAD` Local results on macOS 15.7.8 / Python 3.11.15: - Kanban suite: **396 passed, 2 skipped** - Targeted lifecycle/receipt suite: **40 passed, 1 skipped** - Adjacent dispatch contract: **passed** - Ruff, compileall, and diff checks: **passed** ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains only changes related to this fix - [x] I've run the repository's canonical focused suites - [x] I've added tests for my changes - [x] I've tested on macOS 15.7.8 ### Documentation & Housekeeping - [x] Documentation updates are N/A; behavior and trust boundary are covered by code comments and tests - [x] `cli-config.yaml.example` update is N/A; no config keys changed - [x] `CONTRIBUTING.md` / `AGENTS.md` update is N/A - [x] Cross-platform impact considered; Windows-specific segmented-dispatch coverage remains in CI - [x] Tool schema changes are N/A; trusted runtime identity is internal middleware data, not a model-visible argument ## Screenshots / Logs No UI change. The PR is covered by canonical tests and an independently reviewed live-campaign evidence packet. No credentials, external source-system writes, or production mutations are part of this patch.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85408",
        "createdAt": "2026-08-13T16:01:28Z",
        "updatedAt": "2026-08-13T16:05:31Z",
        "timestamp": "2026-08-13T16:05:31Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [],
        "author": "ataraxiaone",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85409",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(memory): extend embedded Hindsight install timeout",
        "text": "## What Give embedded Hindsight package installs a 600-second budget while keeping the existing 120-second timeout for lightweight memory-provider SDKs. This applies consistently to: - `hermes memory setup` for `local_embedded` - active-provider restoration during `hermes update` - the Intel macOS slim embedded stack proposed in #81530 ## Why Hermes already treats Hindsight as a supported shipped memory provider. For `local_embedded`, setup installs the full runtime and `hermes update` restores it after rebuilding the managed venv. That supported repair path currently passes the full Hindsight stack to `install_specs(..., timeout=120)`. A real managed update on Apple Silicon exceeded that generic budget, logged a warning, continued successfully, and left the configured embedded provider absent. The same forced dependency restoration completes when allowed a realistic multi-minute budget. This follows up the provider-healing work merged in #72363. It also addresses the 120-second embedded install timeout reported during #7718 without moving Hindsight to an external service or adding private dependency pins. ## Scope - Detect package names from versioned/extra-qualified specs. - Use 600 seconds only when the dependency set includes `hindsight-all`, `hindsight-all-slim`, or `hindsight-api-slim`. - Preserve 120 seconds for `hindsight-client` cloud/external mode and other providers. - Reuse the same timeout selector at both setup and update restoration call sites. This PR deliberately does not address the stale `hindsight-client==0.6.1` runtime pin. That known issue is already covered by #80390 and the broader existing fix in #80517. ## Verification - Focused timeout/update tests: 5 passed. - Ruff: clean on all four changed files. - `git diff --check`: clean. - `uv.lock`: unchanged. - Sabotage test: restoring the historical fixed 120-second selector makes all three embedded-runtime regression assertions fail; restoring this fix makes them pass. - Live operational acceptance on the affected installation after dependency restoration: embedded daemon healthy, PostgreSQL connected, retain accepted, exact nonce recalled on first structured recall, disposable bank deleted, client closed. ## Related - Follow-up to #72363 - Related to #7718 - Compatible with #81530",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85409",
        "createdAt": "2026-08-13T16:06:29Z",
        "updatedAt": "2026-08-13T17:30:01Z",
        "timestamp": "2026-08-13T17:30:01Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "tool/memory",
          "P3"
        ],
        "author": "neilinger",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85410",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(mailmap): jonny@nousresearch.com is yoniebans, not jquesnelle",
        "text": "## Problem `.mailmap` line 89 maps `jonny@nousresearch.com` to `jquesnelle`, so all 62 commits authored with that email (54 as `yoniebans`, 8 as `jonny`) display under the wrong name in every mailmapped view: `git shortlog`, `git log --use-mailmap`, changelog generators, contributor stats. The raw commit metadata is correct; only the display mapping is wrong. The line came from the auto-generated 75-mapping pass in #9358, which guessed the wrong GitHub handle for the email. jquesnelle's real commits use `emozilla@nousresearch.com` (already mapped on line 76) and `jquesnelle@gmail.com`. ## What this PR does One line: `.mailmap:89` `jquesnelle` → `yoniebans`. Verified with `git shortlog -sne origin/main`: all 62 commits now fold under `yoniebans <jonny@nousresearch.com>`; jquesnelle's 97 emozilla commits and 1 gmail commit are unchanged. `AUTHOR_MAP` in `scripts/release.py` already had the correct mapping, so no change needed there.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85410",
        "createdAt": "2026-08-13T16:06:58Z",
        "updatedAt": "2026-08-13T16:11:17Z",
        "timestamp": "2026-08-13T16:11:17Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [],
        "author": "yoniebans",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85411",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(vision): normalize sandbox-side paths to POSIX for container exec-read",
        "text": "## Summary On a Windows host with `terminal.backend: docker`, `vision_analyze` failed for every local image path routing to the in-container exec-read: `Path()` on Windows converts POSIX separators into backslashes, so `shlex.quote(str(p))` injected `\\workspace\\...\\vision_test.png` into the Linux container command → \"Only base64 data is allowed\". ## Change (same bug class, 3 sites + 1) - `tools/image_source.py`: `shlex.quote(str(p))` → `shlex.quote(p.as_posix())` in `_resolve_container_fallback` (no-op on Linux); `_permitted_host_read_target` + error messages echo `as_posix()`. - `tools/credential_files.py`: `Path(container_path)` → `PurePosixPath(container_path)` in `from_agent_visible_cache_path` (container paths are POSIX by definition; `Path()` would mangle mount-relative comparison on Windows). Host-side paths unchanged. - Tests: 3 new (POSIX passthrough, Windows-style backslash input, cache-path comparison). ## Verification - `tests/tools/test_image_source.py` + `tests/tools/test_credential_files.py`: **64 passed** (2.0s). Root cause confirmed with a real experiment (WindowsPath `str()` mangling). Docker integration test not run (requires daemon; bug not reproducible on Linux CI). Closes #85406",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85411",
        "createdAt": "2026-08-13T16:07:47Z",
        "updatedAt": "2026-08-13T16:07:47Z",
        "timestamp": "2026-08-13T16:07:47Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "webtecnica",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85412",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(tools): use POSIX path in vision container exec-read on Windows hosts",
        "text": "## What does this PR do? Fixes `vision_analyze` failing on Windows hosts under the Docker terminal backend for any local path that routes to the in-container exec-read (sandbox paths like `/workspace/...`, container cache paths). The resolver built the read command from `str(Path(...))`, which on Windows yields backslash separators; the Linux container cannot resolve `\\workspace\\x.png`, so the read returns no bytes and the image fails magic-byte sniffing (`\"not a recognized image\"` / `\"Only base64 data is allowed\"`). The fix quotes `p.as_posix()` — a no-op on POSIX hosts (where `Path` is already `PosixPath`) and the correct form inside the container everywhere. ## Related Issue Fixes #85406 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `tools/image_source.py` — `_resolve_container_fallback`: quote the POSIX path form (`shlex.quote(p.as_posix())` instead of `shlex.quote(str(p))`), with a comment explaining why. - `tests/tools/test_image_source_posix_paths.py` — new regression test. `PureWindowsPath` is instantiable on every platform and produces the same backslash `str()` shape as a Windows-host `Path`, so the Windows-host condition is covered on Linux CI. ## How to Test Reproduction (Windows host + `terminal.backend: docker`): call `vision_analyze` on any sandbox-side image path, e.g. `/workspace/shot.png` → fails with `sandbox returned non-image data for '\\workspace\\...': Only base64 data is allowed`. Test: 1. `pytest tests/tools/test_image_source_posix_paths.py` — fails on current `main` (command contains `'\\workspace\\shot.png'`), passes with the fix (`/workspace/shot.png`). 2. `pytest tests/tools/test_image_source.py` — existing resolver tests still pass (183 vision/image tests passed locally). Host-side confirmation (Windows): `python -c \"from pathlib import Path; print(Path('/workspace/x.png').as_posix())\"` → `/workspace/x.png`. ## Checklist - [x] Ran tests: regression test + existing `test_image_source.py` suite pass - [x] Cross-platform: `as_posix()` is a no-op on POSIX hosts, so Linux/macOS behavior is unchanged - [x] Two unrelated failures in `tests/tools/test_image_generation.py` are pre-existing (reproduced via `git stash`, identical without this change)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85412",
        "createdAt": "2026-08-13T16:07:57Z",
        "updatedAt": "2026-08-13T16:07:57Z",
        "timestamp": "2026-08-13T16:07:57Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "maqifrnswa",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85414",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): make get-windows optional dep so Linux build doesn't break",
        "text": "## Summary `get-windows@9.3.0` was in `dependencies` of the desktop app — on Linux its install script (`node-pre-gyp install --fallback-to-build`) fails (404 prebuilt + gyp ModuleNotFoundError in the venv), breaking `npm ci` and the whole desktop build during `hermes update`. ## Change (platform-gated) - `apps/desktop/package.json`: `get-windows` moved from `dependencies` → **`optionalDependencies`** (install failure on Linux no longer breaks `npm ci`). - `apps/desktop/scripts/stage-native-deps.mjs`: `resolveGetWindowsRoot()` try/catch → `null`; `stageGetWindows()` with injectable platform gate — Linux + package missing = skip with warning; darwin/win32 + missing = hard error (native payload required). - `apps/desktop/electron/window-below.ts`: lazy tolerant import (`import('get-windows').catch(() => null)`) — missing module is a normal state for an optional dep; enumeration degrades to the failure note. - `apps/desktop/electron/get-windows.d.ts` (new): type shim. Root `allowScripts` for get-windows kept (needed for Windows binding download; Linux install failure now absorbed by the optional dep). ## Verification - `stage-native-deps.test.mjs` (26) + `window-below.test.ts` (13): **39 passed**, `tsc` exit 0. Real npm experiment confirmed install-script failure is absorbed with the optional-dep layout. Closes #85377",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85414",
        "createdAt": "2026-08-13T16:09:42Z",
        "updatedAt": "2026-08-13T16:09:43Z",
        "timestamp": "2026-08-13T16:09:43Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "webtecnica",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85415",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(security): block writes to sensitive paths in managed-files API",
        "text": "## Summary The dashboard's managed-files API protected sensitive files (`.env`, `config.yaml`, credential stores) on the **read** side only — the **write** endpoints (`/api/files/upload`, `/api/files/upload-stream`, `/api/files/mkdir`, delete) would happily **overwrite** those same files. Security gap. ## Change (fail-closed at the chokepoint) - `hermes_cli/web_server.py::_resolve_managed_path()`: added `if for_write and _is_sensitive_path(resolved): raise HTTPException(403, \"Cannot write to a sensitive path\")`. The check uses the **post-symlink-resolution** path, so aliases/symlinks pointing at `.env`/`config.yaml` are also blocked. Covers upload, upload-stream, mkdir (already passed `for_write=True`). - `delete_managed_file()`: now passes `for_write=True` → destructive deletes also refuse sensitive paths (404 semantics preserved). - `_is_sensitive_path()` docstring updated (no longer claims \"read-side only\"). ## Verification - `tests/hermes_cli/test_web_server_files.py`: **10 passed** (4 new: upload .env → 403, upload-stream config.yaml → 403, mkdir sensitive → 403, symlink-to-.env → 403). Normal file ops unchanged. Closes #85387",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85415",
        "createdAt": "2026-08-13T16:10:09Z",
        "updatedAt": "2026-08-13T17:29:24Z",
        "timestamp": "2026-08-13T17:29:24Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/security",
          "area/auth",
          "P3",
          "sweeper:risk-security-boundary",
          "comp/dashboard"
        ],
        "author": "webtecnica",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85416",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(macos): stable TCC anchor for uv-managed python interpreter",
        "text": "## Summary On macOS, TCC permission grants (microphone, screen recording, etc.) are keyed to the resolved client binary path. The uv-managed Python interpreter lives in `~/.local/share/uv/python/cpython-<patch>-macos-*/bin/python3.11` — every patch bump creates a new directory → orphaned grants → permission prompts return after **every** update. Symlinks don't help (TCC resolves through them). ## Change (stable anchor — \"copy to fixed path before invoking\") - `hermes_cli/macos_tcc_anchor.py` (new, +277): replaces the `venv/bin/python` symlink (which resolves into the versioned uv store) with a **real copy** of the interpreter binary. The venv path is stable across updates and, being a regular file, there's no symlink for TCC to resolve → constant client path. `pyvenv.cfg` (home → uv store) untouched, so stdlib still loads as today. **Self-healing**: detects patch bump via `.tcc-anchor-source` marker (re-copies) and re-points `python3`/`python3.11` aliases to the anchor. No-op on non-macOS and non-uv interpreters (Homebrew already has a stable path). Best-effort, never raises. - `hermes_cli/doctor.py` (+52): reports anchor state (healthy / stale / would-fix). - `hermes_cli/update_cmd.py` (+15): runs the anchor heal after update. - `tests/hermes_cli/test_macos_tcc_anchor.py` (new, +296): uv-store detection, patch-bump re-copy, symlink replacement, no-op cases (monkeypatched — real TCC behavior not testable on Linux CI). ## Verification - `tests/hermes_cli/test_macos_tcc_anchor.py`: **20 passed**. Real macOS validation left to review (CI is Linux). Closes #85345",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85416",
        "createdAt": "2026-08-13T16:10:31Z",
        "updatedAt": "2026-08-13T16:10:31Z",
        "timestamp": "2026-08-13T16:10:31Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "webtecnica",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85419",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(auth): harden shared Codex OAuth rotation",
        "text": "## Summary - Canonical root lock-held OAuth refresh/writeback with a random rotation-invariant grant identity. - Stale aliases converge; terminal failures quarantine matching aliases without touching independent grants/providers. - Native-Codex implementation/review preflight is strictly read-only, fail-closed, and runs before claim/spawn. - Compatibility is preserved for ordinary credential pool loading. ## Verification - Credential pool: 59 passed. - OAuth/Kanban: 57 passed + 1 Windows-only skip. - py_compile/diff-check: passed. No credential values or local/private paths are included.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85419",
        "createdAt": "2026-08-13T16:17:55Z",
        "updatedAt": "2026-08-13T16:17:55Z",
        "timestamp": "2026-08-13T16:17:55Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "fardoche6",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85421",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(telegram): rebind TypeHandler in the deferred SDK import (fixes #85272)",
        "text": "Root cause for #85272, plus a three-line fix. ## What happens `check_telegram_requirements()` re-imports python-telegram-bot after a lazy install and rebinds the module-level aliases that the top-level `except ImportError` block set to `typing.Any`. **`TypeHandler` is missing from all three places**: the `global` declaration, the `from telegram.ext import (...)` list, and the assignment block. It is set to `Any` on line 265 along with everything else, so it is the one alias never restored. Whenever the top-level import fails and the deferred path runs, every other alias is restored and `TELEGRAM_AVAILABLE` flips to `True` — while `TypeHandler` stays `Any`. The adapter then instantiates it while registering handlers and raises `TypeError: Any cannot be instantiated`: ``` WARNING [Telegram] Discovering Telegram API fallback IPs via DNS-over-HTTPS… ERROR [Telegram] Failed to connect to Telegram: Any cannot be instantiated WARNING Gateway started with no connected platforms — 1 platform(s) queued for retry ``` The 22.6 → 22.8 pin bump named in #85272 is the trigger, not the defect: it makes the top-level import fail, which is what sends the module down the deferred path where the missing rebinding has always been. ## Repro macOS 15.5 (Apple Silicon), Hermes v0.20.0, python-telegram-bot 22.8, CPython 3.11. 1. Configure a Telegram gateway with a valid bot token 2. `hermes gateway run` Network and credentials are not involved — `getMe` against the same token returns `ok: true`, and `api.telegram.org` resolves and connects from the same host. With the patch applied the gateway connects and long-polls normally; Telegram then reports `Conflict: terminated by other getUpdates request` to any second poller, confirming the adapter holds the connection. ## Note The three sibling aliases in the same block (`Update`, `Bot`, `Message`, `Application`, `HTTPXRequest`, …) are all handled correctly — this is an omission for one name rather than a design problem, so the patch just completes the existing pattern.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85421",
        "createdAt": "2026-08-13T16:19:30Z",
        "updatedAt": "2026-08-13T17:29:03Z",
        "timestamp": "2026-08-13T17:29:03Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "platform/telegram",
          "P3"
        ],
        "author": "lesyuk",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85428",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix: reset SSL ciphers to DEFAULT for WeChat CDN handshake (#84394)",
        "text": "## What does this PR do? WeChat's CDN (novac2c.cdn.weixin.qq.com) rejects Python's default restrictive cipher list with SSLV3_ALERT_HANDSHAKE_FAILURE, causing all media file downloads (documents, images) to fail. Text messages work fine because they don't hit the CDN. The root cause is in gateway/platforms/weixin.py::_make_ssl_connector(). It creates an ssl.create_default_context() but never resets the cipher list. Python's default context negotiates a restrictive set of ciphers that WeChat's CDN doesn't support. curl and other tools use OpenSSL's DEFAULT cipher list and connect fine. The fix adds ssl_ctx.set_ciphers(\"DEFAULT\") after creating the context. This resets to OpenSSL's default cipher list (the same one curl uses) without weakening certificate verification — the CA bundle from certifi is still used for verification. The try/except guards against environments where the DEFAULT alias isn't available. The issue reporter verified this exact fix works: after adding set_ciphers(\"DEFAULT\"), aiohttp connects to novac2c.cdn.weixin.qq.com successfully. ## Related Issue Fixes #84394 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Testing - Added TestWeixinSSLCipherFix::test_ssl_connector_sets_default_ciphers regression test that verifies the SSL context has ciphers available after set_ciphers(\"DEFAULT\") is called. - All 31 existing weixin tests still pass.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85428",
        "createdAt": "2026-08-13T16:40:51Z",
        "updatedAt": "2026-08-13T17:28:38Z",
        "timestamp": "2026-08-13T17:28:38Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "platform/wecom",
          "P2",
          "sweeper:risk-message-delivery"
        ],
        "author": "rkfshakti",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85429",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): re-emit session.info when approvals config changes out of band",
        "text": "Turning YOLO on in the settings page flips the real switch: approvals stop immediately. But the status-bar pill never updates, and switching sessions repaints it from a stale cached \"off.\" The broken indicator then baits you into clicking the toggle again, which turns the bypass you already had **off**, and approvals mysteriously come back. From the user's chair the setting just doesn't stick. The pill only repaints on a `session.info` event. The `config.set` RPC re-emits one after a mode flip, but the settings page saves through REST `PUT /api/config`, which wrote `config.yaml` and emitted nothing. Enforcement follows the file per-command, so switch and indicator parted ways. The raw YAML editor and the gateway's `/approvals` slash command had the same gap. All three now get the RPC path's contract: `broadcast_session_info()` in `tui_gateway/server.py` (snapshots `_sessions` under `_sessions_lock`), called from the REST handlers via a `sys.modules` guard (a process that never imported the gateway has no sessions to notify), and only when the save actually changed the `approvals` block on the process's own profile. Bare read-only `/approvals` and unrelated autosave churn emit nothing. Scope: reaches the in-process gateway (`hermes serve` / `hermes dashboard`, the topologies the desktop app talks to). A spawned `tui_gateway.entry` child gateway is its own process; its TUI statusbar already reconciles every turn. ## Test plan - [x] `scripts/run_tests.sh tests/hermes_cli/test_web_server_approvals_broadcast.py` with 10 new contracts: broadcast on change (both REST paths, plus deleting the approvals block in the raw editor), silence on non-approvals / no-op / other-profile saves, gateway-absent path asserts no import, `/approvals` broadcasts on write but not bare read - [x] Neighbors green: 195 (web_server, config offloop, protocol) + 555 (tui_gateway server, compress lock) - [x] Root cause confirmed live pre-fix: CDP probe showed the REST save changing enforcement while `$yoloActive` stayed stale Note: the slice 9 CI failure (`test_primary_runtime_restore.py::test_allowed_for_nous_anthropic_messages`) is red on main's own push CI with the identical signature and is unrelated to this diff.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85429",
        "createdAt": "2026-08-13T16:42:21Z",
        "updatedAt": "2026-08-13T17:16:29Z",
        "timestamp": "2026-08-13T17:16:29Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/tui",
          "area/config",
          "P2",
          "sweeper:risk-compatibility"
        ],
        "author": "yoniebans",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85434",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(xai): drop stale 256K grok-4.6 context cache + generalize pre-catalog guard",
        "text": "## Summary Users who ran `grok-4.6` before its 500K catalog entry landed keep a stale persisted 256K context length forever — this drops that leftover so it re-resolves, and generalizes the guard so the whole class (MiniMax-M3, Grok-4.3/-4.6/-4-fast/-4.20, qwen3.6-plus) is covered by one rule instead of per-model patches. Salvages #84341 by @Julientalbot (cherry-picked, authorship preserved), then widens per the never-patch-predicates rule; also absorbs the guards requested in #37684 by @AhmetArif0. ## Changes - `agent/model_metadata.py`: cherry-picked grok-4.6 guard (@Julientalbot), then replaced the three per-model `_model_name_suggests_*` stale-cache predicates with one generic `_stale_pre_catalog_cache_entry()` driven by `_PRE_CATALOG_STALE_KEYS`. A cached value is dropped only when the model resolves (longest-key-first, same as step 8) to a listed catalog key AND the cached value is at or below what the old resolution path could have produced (largest shorter matching catch-all, or the 256K fallback). Probe-derived values above that threshold are never dropped. `_model_name_suggests_minimax_m3` kept for its two non-cache callers. - `tests/agent/test_model_metadata.py`: contributor's `TestGrok46StaleCacheGuard` E2E test preserved; predicate unit tests migrated to the generic guard; new `TestGenericPreCatalogStaleGuard` covering qwen3.6-plus / grok-4-fast / grok-4.20 incl. an E2E stale-drop-and-re-resolve. ## Validation | | Result | |---|---| | `tests/agent/test_model_metadata.py` + `test_minimax_provider.py` | 93/93 pass | | Sabotage run (guard disabled) | 3 E2E tests fail as expected, restore → green | | Stale-base gate | 0 behind origin/main, 2-file diff | ## Infographic ![Stale context cache guard — one generic rule replaces per-model patches](https://files.catbox.moe/wmwc6u.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85434",
        "createdAt": "2026-08-13T16:43:50Z",
        "updatedAt": "2026-08-13T17:21:53Z",
        "timestamp": "2026-08-13T17:21:53Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/xai",
          "P3"
        ],
        "author": "teknium1",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85439",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(langfuse): widen tracing to errors, sessions, subagents, and MoA fan-out",
        "text": "## Summary Widens the bundled Langfuse observability plugin from 6 hooks to 11 and fixes two attribution bugs, salvaged from PR #83437 by @erosika with adopted fixes from 8 prior community PRs. ## Changes - **Model attribution:** `on_pre_llm_request`/`on_post_llm_call` now prefer the wire value (`request[\"body\"][\"model\"]`, `response_model`) over the stale agent attribute after `/model` switch or provider fallback. - **Cost total:** Both cost paths now send a summed `total` alongside the per-type breakdown. Subscription-included routes send no cost keys at all (Langfuse treats explicit `$0` as authoritative). - **New hooks:** `api_request_error` (closes failed generations with ERROR level), `on_session_finalize`/`on_session_end` (closes dangling traces), `subagent_start`/`subagent_stop` (traces delegated children as spans), MoA advisor fan-out (one generation per advisor priced at the advisor's own model). - **Capture modes:** `HERMES_LANGFUSE_CAPTURE=metadata|sanitized|full` (default `sanitized`). Sanitized mode redacts secrets using the project-wide `agent.redact.redact_sensitive_text(force=True)`. - **Adopted lifecycle fixes:** Shutdown client at session finalize when `reason=shutdown` (not on session rotation); atexit finalizer ends open root spans for short-lived processes; root context manager exited to prevent interpreter-teardown TypeError; TOCTOU on `_get_langfuse()` fixed with lock; reasoning_content surfaced; system prompt included in generation input for Anthropic/Codex/Bedrock; SDK v3 `update_trace` replaces `set_trace_io`. ## Follow-up fixes applied during salvage (from /hermes-pr-review + /simplify-code) 1. Replaced `_redact_secrets` with `redact_sensitive_text(force=True)` from `agent/redact.py` — the plugin's 11-pattern list was a strict subset of the 50+ patterns in the existing redactor. Added `pk-lf-` to `_PREFIX_PATTERNS`. 2. Removed dead `not isinstance(client, object)` check in `on_session_finalize`. 3. Fixed `MoAClient.last_reference_metrics()` to call the public method instead of reaching into private `_last_reference_metrics` via `getattr`. 4. Deduplicated `_coerce_request_messages` call in `on_pre_llm_request` — pass `pre_coerced` to avoid double-coercion + double `_capture_content` serialization. 5. Added `HERMES_LANGFUSE_CAPTURE` to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py`. 6. Fixed `test_sanitized_mode_redacts_secrets` test data — old samples were too short to match regex thresholds and never actually tested redaction. ## Attribution Salvaged from PR #83437 by @erosika, with adopted fixes from: - @bgodlin (#81054) — shutdown client on session finalize - @aldoeliacim (#82332) — finalize open root spans at exit - @nftpoetrist (#42326) — guard _get_langfuse() against TOCTOU - @rodboev (#39653) — surface reasoning_content in traces - @FnExpress (#64292, supersedes #32175 by @db-aeon) — include system prompt in generation input - @Per0-1 (#61166) — use update_trace for I/O columns - @NaMinhyeok (#64797) — export canonical generation total - @liuhao1024 (#43130) — omit cost_details for subscription-included providers ## Validation | | Before | After | |---|---|---| | Langfuse + MoA tests | — | 99 passed | | Capture mode tests | — | 9 passed | | E2E smoke | — | All hooks registered, capture modes work, system prompt extraction for 4 provider shapes | ## Closes / supersedes Closes #29482, #43129, #72661. Supersedes #81054, #82332, #42326, #39653, #64292, #32175, #61166, #64797, #43130. Partially addresses #67544 (capture modes + secret redaction; user_id remains open).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85439",
        "createdAt": "2026-08-13T16:51:44Z",
        "updatedAt": "2026-08-13T17:40:17Z",
        "timestamp": "2026-08-13T17:40:17Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/plugins",
          "P3"
        ],
        "author": "kshitijk4poor",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85444",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): expire orphaned drain markers past a max-age so a leaked marker can't wedge the gateway",
        "text": "## Summary A `.drain_request.json` marker orphaned **without a machine restart** wedges the gateway in `draining` indefinitely — every inbound message on every platform is bounced with *\"⏳ This agent is draining for a maintenance action…\"* until someone manually deletes the marker. Observed in the field: a Hermes Cloud instance refused all Telegram turns for ~3 days (marker written for a maintenance action that completed without recreating the container; the writer never cancelled the drain). The NS-570 epoch stamp (#53050) only clears markers that survive a **restart** — it bakes in the assumption that every drain-gated action ends in one. A same-epoch orphan passes the epoch check forever, and the marker's `requested_at` was write-only: no reader ever looked at it. Compare the neighbouring `.restart_notify.json` marker, which `gateway/run.py` guards with a 5-minute `requested_at` staleness check for exactly this reason. Fixes #85433. ## Changes - `gateway/drain_control.py`: - New `_marker_is_expired()`: a marker whose `requested_at` parses and is older than `DRAIN_REQUEST_MAX_AGE_SECONDS` (1h) reads as stale. Same leniency contract as the epoch check — a missing/unparseable timestamp still reads as drain-active (fail-safe toward quiescing), a future-dated timestamp (clock skew) is honoured, a tz-naive timestamp is treated as UTC. - New `_marker_is_stale()` composes epoch-mismatch OR expiry; `drain_requested()` and `drain_notification_suppressed()` both route through it (all external callers — the 1s gateway watcher, the shutdown broadcast gate, and the dashboard status endpoint — go through these two readers, so every path is covered). - Expiry logs a warning **once per marker** (keyed by `requested_at`), not once per 1s poll tick (~86k/day otherwise). A refreshed marker that expires again warns again. - Long drains keep a sanctioned keep-alive: `write_drain_request()` is idempotent and re-writing refreshes `requested_at` (docstring updated to say so). ## Before / after Before: marker written at 20:04 for a no-restart maintenance action, never cleared → gateway bounces every message with the draining text **indefinitely** (same epoch → honoured on every 1s tick). After: the same orphan is honoured for at most 1h, then reads as absent; the watcher flips the gateway back to `running` on the next tick and logs one warning naming the marker's timestamp, age, and principal. ## Validation - `tests/gateway/test_external_drain_control.py`: 7 new tests — expired marker reads as absent (drain state AND suppression flag), missing timestamp honoured, unparseable timestamp honoured, tz-naive timestamp expires, keep-alive re-write revives an expired drain, expiry warning fires once across repeated polls and re-fires for a refreshed marker. - Full file: 18 passed. Wider `-k \"drain or restart\"` slice over `tests/gateway/`: 168 passed, 3 skipped. - Mutation check: reverting `gateway/drain_control.py` to the pre-fix version makes the 4 new-behavior guard tests fail (the leniency tests correctly still pass — they assert behavior that predates the fix); restoring goes green. - `ruff check` clean on both files.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85444",
        "createdAt": "2026-08-13T16:56:21Z",
        "updatedAt": "2026-08-13T17:53:49Z",
        "timestamp": "2026-08-13T17:53:49Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/gateway",
          "P1",
          "sweeper:risk-message-delivery"
        ],
        "author": "kshitijk4poor",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85449",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway): pin active-session lease to its acquire-time registry (#85431)",
        "text": "## What does this PR do? Fixes #85431: in a native multiplex gateway, an active-session lease routed to a named profile leaks its slot on release, so after `max_concurrent_sessions` routed turns every new session is rejected — *\"Hermes is at the active session limit (N/N)\"* — even though no agent turn is running. ## Root cause `try_acquire_active_session()` writes the lease entry to the registry resolved from the **root** `HERMES_HOME`. But `release_active_session()` and `transfer_active_session()` re-resolved `_state_path()` / `_lock_path()` from the *current* `HERMES_HOME`. A turn routed to a named profile runs under `_profile_runtime_scope(profile_home)`, and the agent cleanup path calls `release()` inside that scope — so the release looked under `…/profiles/<name>/runtime/active_sessions.json` instead of the root registry where the lease was created. The root entry survived (its `pid` is the still-running gateway, so pruning keeps it), the in-memory lease was marked `released=True`, and the slot was never reclaimed. The issue's deterministic repro (temp dir, no credentials) reproduces it with Hermes' own profile-override and active-session primitives. ## The fix Pin the registry to acquire time. `ActiveSessionLease` gains `state_path` / `lock_path`, set when the lease is created; `release_active_session()` and `transfer_active_session()` use those pinned paths (falling back to the live resolver only for a disabled/no-op lease). Release/transfer therefore always act on the registry the lease was written to, regardless of the `HERMES_HOME` scope active at cleanup. ## Tests `tests/hermes_cli/test_active_sessions.py`: - `test_release_under_named_profile_scope_frees_the_root_slot` — the issue's exact scenario: acquire against root, release inside a `set_hermes_home_override(profile)` scope, assert the root slot is freed and no stray profile registry was written. Verified failing before the fix, passing after. - `test_transfer_under_named_profile_scope_updates_the_root_registry` — the same pinning for `transfer_active_session()`. Affected-test, ruff, and windows-footgun gates pass locally. Fixes #85431",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85449",
        "createdAt": "2026-08-13T17:01:36Z",
        "updatedAt": "2026-08-13T17:15:29Z",
        "timestamp": "2026-08-13T17:15:29Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/gateway",
          "P2",
          "sweeper:risk-session-state",
          "area/profiles"
        ],
        "author": "PRATHAMESH75",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85451",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "docs(telegram): document allowed_topics whitelist and free_response gates",
        "text": "Fixes #78770. The forum-topic whitelist (`telegram.allowed_topics` / `TELEGRAM_ALLOWED_TOPICS`) and the two free-response gates (`free_response_chats` / `free_response_topics`) are fully implemented in `plugins/platforms/telegram/adapter.py` but were not discoverable from any documentation. This adds the three env vars to the reference table in `environment-variables.md` and a bullet each in the Telegram user guide, including the whitelist-before-ignore ordering and the General-topic normalization to ID `1` (verified at adapter.py `_telegram_allowed_topics` and `_should_process_message`). Docs-only, no behavior change.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85451",
        "createdAt": "2026-08-13T17:04:44Z",
        "updatedAt": "2026-08-13T17:39:45Z",
        "timestamp": "2026-08-13T17:39:45Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/docs",
          "comp/gateway",
          "platform/telegram",
          "P3"
        ],
        "author": "Luna161",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85452",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(honcho): isolate clients per profile, resolve baseUrl/apiKey, enforce saveMessages containment",
        "text": "## Summary Consolidates and salvages three Honcho plugin PRs by @erosika (#83500, #83508, #83525) onto latest main, with review follow-up fixes applied on top. All three address distinct bug classes in the Honcho memory plugin that cause silent data corruption in multi-profile/self-hosted setups. ## What this PR makes true The Honcho plugin correctly isolates per-profile clients, resolves self-hosted baseUrl/apiKey configs, and respects saveMessages=false — fixing cross-tenant memory bleed, silent cloud routing, and ignored write containment. ## Changes ### From PR #83525 — client identity isolation (closes #69123, #74065) - Replaces process-wide client singleton with per-identity caching (host, workspace, base_url, environment, provenance, timeout, credential fingerprint) - Configs capture provenance (config_path, hermes_home) at resolution time - `spawn_context_thread()` carries contextvars into all 9 plugin daemon threads - Credential fingerprint hashes OAuth refresh token (stable across access-token rotation, changes on re-auth) - Stale same-identity slots evicted on replacement - Path-keyed timeout memo (was single-slot, thrashed between profiles) - MemoryManager background dispatch wraps with caller's contextvars (general fix for ALL memory providers) - **Follow-up fix:** replaced unreadable double-lambda with `functools.partial(ctx.run, fn)` - **Follow-up fix:** `from_env()` now sets `config_path` so `bound_config_path()` doesn't re-resolve from ContextVar on daemon threads ### From PR #83508 — baseUrl/apiKey resolution (closes #43800, #37436, #76414; addresses #36098, #66125) - Reads `endpoint.baseUrl` (SDK-native, what Claude Desktop writes) - Accepts `HONCHO_URL` env var as fallback - Host-block baseUrl takes precedence over flat root - Uses `_host_block()` for dot-form legacy host key fallback in local-auth check - Honors top-level `apiKey` for local URLs - Warns loudly when keyless profile doesn't inherit default host's key - `raise_errors=True` in `dialectic_query` for explicit tool calls - `_sanitize_url()` drops non-printable chars before client init - Peers command uses `profile_host_key()` (underscore form) ### From PR #83500 — saveMessages containment (closes #35209) - Gates all four automatic write paths on `saveMessages` (sync_turn, on_memory_write, on_session_end, shutdown) - Rejects machine-generated gateway notifications from being persisted as user turns - Routes `sync_turn` through `manager.save()` so `writeFrequency` batching applies - Provider shutdown calls `manager.shutdown()` (flush + join writer thread) or `manager.stop_async_writer()` (join only, no flush) - Owner-gates memory-file migration so non-owner users in shared channels don't get the owner's MEMORY.md/USER.md - **Follow-up fix:** tracks and joins the `honcho-memwrite` thread in `shutdown()` (was fire-and-forget, the exact problem the PR fixes for the async writer) ## Attribution Adopted with original authorship preserved. Contributors credited: - @dtownsel — saveMessages gates on sync_turn/on_memory_write + gateway-internal-turn rejection (#82130) - @strzhao, salvaging @Matroskin86 — saveMessages gates on session-end + shutdown flush (#81214, #67559) - @menhguin — non-owner migration skip (#82038) - @cfdude — endpoint.baseUrl + HONCHO_URL + fallback logging (#43803) - @LeonSGP43 — host-block baseUrl precedence (#14489) - @Morad37 — _host_block dot-form fallback (#37671) - @Bartok9, salvaging @teyrebaz33 — non-printable base_url sanitization (#62757, #2757) - @spfcraze — peers command underscore host keys (#76455) - @NaMinhyeok, @angel12 — per-identity cache approach (#69142, #81401) ## Validation | | Before | After | |---|---|---| | Tests | 0 honcho isolation tests | 363 passed across honcho_plugin/, test_honcho_*, test_honcho_client_config, test_honcho_cli_peers | | E2E smoke | N/A | 9 combined smoke tests passed (saveMessages gate, gateway regex, memwrite tracking, _sanitize_url, endpoint.baseUrl, provenance, bound path, from_env config_path) | | ruff | — | clean | | Cross-tenant bleed | #69123 live | per-identity cache + contextvar propagation | Closes #83500, #83508, #83525, #69123, #74065, #43800, #37436, #76414, #35209",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85452",
        "createdAt": "2026-08-13T17:05:39Z",
        "updatedAt": "2026-08-13T17:21:35Z",
        "timestamp": "2026-08-13T17:21:35Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/plugins",
          "tool/memory",
          "P3",
          "needs-decision",
          "sweeper:risk-session-state",
          "area/memory"
        ],
        "author": "kshitijk4poor",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85453",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "docs(telegram): document TELEGRAM_WEBHOOK_HOST and other undocumented adapter env vars",
        "text": "Fixes #78697. `TELEGRAM_WEBHOOK_HOST` is read in `plugins/platforms/telegram/adapter.py` and controls the webhook bind host — critical for IPv6-only deployments (Fly.io 6PN) — but appeared nowhere in `website/docs/`. A scan of the adapter shows it reads ~22 `TELEGRAM_*` and `HERMES_TELEGRAM_*` env vars, of which several were undocumented. This PR adds: - `TELEGRAM_WEBHOOK_HOST` to the env-var reference table **and** the Webhook Mode section of `telegram.md` (with the IPv6 rationale) - `HERMES_TELEGRAM_HTTP_POOL_SIZE` / `POOL_TIMEOUT` / `CONNECT_TIMEOUT` / `READ_TIMEOUT` / `WRITE_TIMEOUT` (PTB request tuning) - `HERMES_TELEGRAM_INIT_TIMEOUT` (bootstrap timeout) - `HERMES_TELEGRAM_DISABLE_FALLBACK_IPS` - `TELEGRAM_ALLOW_BOTS`, `TELEGRAM_GUEST_MODE`, `TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES` (with their `config.yaml` equivalents) All defaults verified against the code on `main`. Docs-only, no behavior change.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85453",
        "createdAt": "2026-08-13T17:08:03Z",
        "updatedAt": "2026-08-13T17:15:33Z",
        "timestamp": "2026-08-13T17:15:33Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/docs",
          "comp/gateway",
          "platform/telegram",
          "P3"
        ],
        "author": "Luna161",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85454",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "docs(telegram): document media routing tables and batch-timing env vars",
        "text": "Fixes #78710. Telegram inbound media routing is table-driven (`_TELEGRAM_IMAGE_EXTENSIONS`, `_TELEGRAM_IMAGE_MIME_TO_EXT`, `_TELEGRAM_IMAGE_EXT_TO_MIME`, `_TELEGRAM_VOICE_EXTS`, `_TELEGRAM_AUDIO_ATTACHMENT_EXTS`) and the three batch-debounce knobs (`HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS`, `HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS`, `HERMES_TELEGRAM_TEXT_BATCH_SPLIT_DELAY_SECONDS`) are read by the adapter, but none of this was documented. This PR: - Adds a \"Media routing and batching\" subsection to `telegram.md` (right after the MEDIA extensions table) describing the routing tables and the three debounce env vars with their defaults and clamps - Mirrors the three vars in `environment-variables.md` Defaults (`0.8`, `0.3`, `1.0`) and clamps (`0.08`–`2.0`, ≤`4.0`) verified against `plugins/platforms/telegram/adapter.py` on main. Docs-only.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85454",
        "createdAt": "2026-08-13T17:10:04Z",
        "updatedAt": "2026-08-13T17:15:26Z",
        "timestamp": "2026-08-13T17:15:26Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/docs",
          "comp/gateway",
          "platform/telegram",
          "P3"
        ],
        "author": "Luna161",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85456",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(slack): add channel approval gate",
        "text": "## Summary - add an opt-in Slack shared-channel approval gate before normal message dispatch, including Slack slash-command dispatch - store local approval/pending/revoked channel state per Hermes profile with team-scoped keys where Slack team IDs are available - honor configured approval owners/agent aliases with exact `approve <agent> here` / `revoke <agent> here` phrases - document the new Slack config/env settings and cover approval, revoke, exact command, slash-command interception, team scoping, and config bridge behavior in tests ## Test Plan - `scripts/run_tests.sh tests/gateway/test_slack_mention.py tests/gateway/test_slack.py tests/gateway/test_config.py -q` - `git diff --check` - `python3 -m py_compile plugins/platforms/slack/adapter.py tests/gateway/test_slack_mention.py tests/gateway/test_config.py gateway/config.py hermes_cli/config_defaults.py`",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85456",
        "createdAt": "2026-08-13T17:11:50Z",
        "updatedAt": "2026-08-13T17:23:36Z",
        "timestamp": "2026-08-13T17:23:36Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "comp/plugins",
          "platform/slack",
          "area/auth",
          "P3",
          "sweeper:risk-compatibility"
        ],
        "author": "psc239",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85459",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(providers): add OpenCode Free provider with keyed auth and opencode User-Agent",
        "text": "## Summary Adds an **OpenCode Free** model-provider plugin. Free model discovery uses models.dev (`cost.input == 0 AND status != \"deprecated\"`), matching the opencode CLI's exact filter logic. The free tier requires a real account API key and throttles third-party clients by User-Agent (requests that don't identify as the opencode client get `429 FreeUsageLimitError`; missing/anonymous keys get `401 AuthError`): - **With `OPENCODE_FREE_API_KEY` configured** — the key is sent as a Bearer token and requests identify as `opencode/latest`. - **Without a key** — the keyless fallback strips the OpenAI SDK's always-injected empty `Authorization` header and still sends the opencode User-Agent. - The credential resolver no longer blanks `OPENCODE_FREE_API_KEY` unconditionally (stale keyless-tier assumption), and credential-pool exhaustion no longer surfaces the misleading `Set OPENCODE_FREE_API_KEY` message. ## Files - `plugins/model-providers/opencode-free/` — provider plugin + manifest - `hermes_cli/auth.py` — provider registry entry, alias, credential resolver - `hermes_cli/runtime_provider.py` — keyless fallback for opencode-free - `agent/agent_runtime_helpers.py` — auth keep/strip + opencode User-Agent - `hermes_cli/models.py`, `hermes_cli/model_setup_flows.py`, `hermes_cli/providers.py`, `hermes_cli/main.py`, `hermes_cli/setup.py` - tests: `tests/agent/test_opencode_free_provider.py`, `tests/run_agent/test_opencode_free_client_headers.py`, `tests/hermes_cli/test_runtime_provider_resolution.py` ## Verification - 191 tests pass (provider plugin, client-header regression, runtime resolution, API-key providers). - E2E: real `resolve_runtime_provider` → `AIAgent` → `chat.completions.create` against opencode.ai returns HTTP 200 with `Bearer sk-…` + `User-Agent: opencode/latest`; keyless path sends no Authorization header but keeps the opencode User-Agent. Co-authored-by: Jean-François <jfm@laposte.net>",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85459",
        "createdAt": "2026-08-13T17:14:35Z",
        "updatedAt": "2026-08-13T17:23:28Z",
        "timestamp": "2026-08-13T17:23:28Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/cli",
          "comp/plugins",
          "area/auth",
          "P3",
          "sweeper:risk-security-boundary"
        ],
        "author": "rudrakshchahal",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85460",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(search): guide empty timed-out searches",
        "text": "## Summary - keep existing partial-result behavior for timed-out file searches - add explicit path/file_glob scoping guidance when a timeout emitted no usable results - cover file-name and all content output modes ## Verification - `scripts/run_tests.sh tests/tools/test_search_budget_truncation.py tests/tools/test_search_error_guard.py tests/tools/test_search_zero_match_and_multipath.py` (34 passed) - `.venv/bin/ruff check tools/file_operations.py tests/tools/test_search_budget_truncation.py` - real home-wide ShellFileOperations search returned `limit_reason: search_timeout` plus scoping warning",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85460",
        "createdAt": "2026-08-13T17:14:46Z",
        "updatedAt": "2026-08-13T17:23:24Z",
        "timestamp": "2026-08-13T17:23:24Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/tools",
          "tool/file",
          "P3"
        ],
        "author": "ajoslin",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85461",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(enterprise): control-plane core — resource model, store, audit, driver contracts",
        "text": "## Summary Adds the foundation of **Hermes Enterprise**: a standalone control-plane core (resource model, fail-closed resource store, audit log, and driver contracts) for deploying and operating fleets of Hermes agents multi-tenant on Kubernetes — with the hermes-agent runtime as the Harness. Part of the one-wave Enterprise draft series; this PR defines the contracts every sibling PR builds against (`ent/iam`, `ent/oag`, `ent/controller`, `ent/compute-k8s`, `ent/sandbox`, `ent/secrets`, `ent/deploy`, `ent/docs`). ## Changes - `enterprise/resources.py`: v1 resource model — `Namespace`, `Configuration`, `Agent`, `AgentRevision` (immutable snapshot), `Harness`, `Channel`, `Secret`/`SecretBroker` (never carry values), `SandboxPolicy`, `Restriction` (narrows, never grants). DNS-1123 names so resources back K8s objects 1:1; per-kind spec validation; secret-shape detector rejects embedded credentials in configs. - `enterprise/store.py`: SQLite store enforcing unique identity, namespace containment, same-namespace reference resolution (cross-namespace refs denied), optimistic concurrency via generations, AgentRevision spec immutability, dependent-blocking deletes + namespace drain. - `enterprise/audit.py`: append-only attributable audit log; refuses secret-like payloads at write time. - `enterprise/contracts.py`: `ComputeDriver` / `SandboxDriver` / `SecretDriver` / `IAMAdapter` / `IdentityVerifier` ABCs + single-selection `DriverRegistry` (unselected capability fails closed; no self-selection). Standalone package: nothing in the core agent imports it, zero model tools, no runtime coupling, stdlib only. ## Validation | | Result | |---|---| | `tests/enterprise/test_core.py` | 31/31 pass | | ruff | clean | | Core-agent footprint | zero (no imports from agent code) | ## Infographic ![Hermes Enterprise control plane core](https://files.catbox.moe/hk10pj.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85461",
        "createdAt": "2026-08-13T17:15:13Z",
        "updatedAt": "2026-08-13T17:23:21Z",
        "timestamp": "2026-08-13T17:23:21Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "P3",
          "needs-decision"
        ],
        "author": "teknium1",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85462",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "docs(delegation): document delegate_task live orchestration actions",
        "text": "## Summary Docs for #85232: the delegation feature page now documents the model-facing `delegate_task` control actions (`action='list'/'steer'/'stop'`) alongside the existing TUI/gateway `subagent.steer` RPC section. ## Changes - `website/docs/user-guide/features/delegation.md`: \"Steering a Running Subagent\" split into model-facing (delegate_task action param, ownership scoping, spawn-cap exemption, partial-result-on-stop) and session-facing (existing RPC content, unchanged) subsections. ## Validation Docs-only; content matches the merged behavior in #85232 (live-tested there). ## Infographic Docs companion to #85232 — infographic lives on the feature PR: https://files.catbox.moe/j8qzff.png",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85462",
        "createdAt": "2026-08-13T17:16:28Z",
        "updatedAt": "2026-08-13T17:23:16Z",
        "timestamp": "2026-08-13T17:23:16Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/docs",
          "tool/delegate",
          "P3"
        ],
        "author": "teknium1",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85463",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(slack): read real SDK responses instead of gating on isinstance dict (salvage #74658)",
        "text": "## Summary Slack adapter now reads real slack_sdk response objects — user name resolution, bot detection, ephemeral send confirmation, and thread `ts` reads all work again. Salvage of #74658 by @nikitaBarkov onto current main, authorship preserved. Root cause: commits from #69483/#70196/#69479 added `isinstance(resp, dict)` gates on Web API responses, but `SlackResponse`/`AsyncSlackResponse` are not dict subclasses — every gate was always False at runtime while dict-injecting tests stayed green. ## Changes - `plugins/platforms/slack/adapter.py`: new `_slack_response_payload()` normalizer (dict passthrough → `.data` if dict → `{}`), routed through all 8 gated call sites — whole-class fix - `tests/gateway/test_slack_sdk_response.py`: 18 tests parametrized over a stand-in AND a real `AsyncSlackResponse`, including the handoff-thread seed-ts and standalone media-send paths flagged in review ## Validation | | Before | After | |---|---|---| | users.info result | discarded, raw ID cached forever | name resolved | | bot detection | always False | works | | ephemeral send | reported as failure | confirmed | | targeted tests | — | 18/18 pass | ## Infographic ![Slack adapter SDK response fix](https://files.catbox.moe/wnii9i.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85463",
        "createdAt": "2026-08-13T17:20:56Z",
        "updatedAt": "2026-08-13T17:32:11Z",
        "timestamp": "2026-08-13T17:32:11Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/plugins",
          "platform/slack",
          "P3"
        ],
        "author": "teknium1",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85465",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(desktop): expose session archive action to plugins",
        "text": "## Summary - expose `host.archiveSession(storedSessionId, { profile? })` to runtime-loaded Desktop plugins - route the action through Desktop's canonical archive workflow so optimistic visibility, pins, selected-session behavior, and native notifications stay consistent - normalize lineage-root IDs, preserve exact profile routing, propagate rollback failures to plugin callers, and destructively clean associated tiles across profiles - document the SDK action and its rejection contract ## Why Desktop plugins can render native session context menus and read session lists, but they cannot currently invoke Desktop's archive lifecycle. Plugin authors must either omit the action or bypass Desktop's state cleanup through a lower-level mutation. This adds a narrow, reusable host action rather than exposing internal stores or adding plugin-specific behavior to core. A concrete consumer is an external work-queue plugin that presents sessions in a docked queue and offers Archive from its native right-click menu. ## Test plan - [x] `npm run test:ui -- src/hermes.test.ts src/sdk/index.test.ts src/app/session/hooks/use-session-actions.test.tsx src/store/session-states.test.ts` - [x] `npm run typecheck` - [x] focused ESLint on all changed Desktop TypeScript files - [x] `git diff origin/main...HEAD --check` - [x] packaged and code-signed the macOS arm64 Desktop candidate locally Tested on macOS arm64. ## Review evidence An independent review inspected the exact pre-rebase staged diff through four iterations. Final verdict: **PASS**, with no concrete blockers. It specifically verified IPC profile-property semantics, selected-session success/failure behavior, lineage identity normalization, active/inactive profile tile cleanup, closed-tab purge, promise rejection handling, and SDK documentation alignment. Residual risk: no live multi-backend Electron smoke test was run for background-profile cleanup; that path is covered through persisted profile-switching tests.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85465",
        "createdAt": "2026-08-13T17:22:21Z",
        "updatedAt": "2026-08-13T17:34:46Z",
        "timestamp": "2026-08-13T17:34:46Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "P3",
          "comp/desktop"
        ],
        "author": "jerryhjones",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85466",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(auxiliary): keep /anthropic base_url for anthropic_messages custom endpoints (salvage #64891)",
        "text": "## Summary Auxiliary tasks (titles, compression, vision) work again on `custom` providers whose base_url ends in `/anthropic` (MiniMax, Zhipu, LiteLLM proxies). Salvage of #64891 by @AlexanderPrendota onto current main, authorship preserved. Fixes #16254 (dup #17086). Root cause: the explicit-base branch of `resolve_provider_client()` rewrote `/anthropic` → `/v1` unconditionally before the Anthropic-wrap decision, so the Messages wrapper was built against a `/v1` URL and every auxiliary call 404'd. ## Changes - `agent/auxiliary_client.py`: when `api_mode == \"anthropic_messages\"`, `_wrap_if_needed` gets the raw `/anthropic` base; the plain-OpenAI client and SDK-unavailable fallback keep the `/v1`-rewritten base — same guard the named-custom-provider branch already has - `tests/agent/test_auxiliary_explicit_base_anthropic.py`: 3 regression tests (wrapper keeps `/anthropic`, fallback stays `/v1`, non-anthropic rewrite preserved) ## Validation | | Before | After | |---|---|---| | aux calls vs Messages-only gateway | 404 on `/v1/chat/completions` | wrapped on real endpoint | | OpenAI-mode custom base | `/v1` rewrite | unchanged | | targeted tests | — | 3/3 pass | Sibling paths (#60753 vision, #61333 class-wide guard) tracked as a separate cluster decision. ## Infographic ![Auxiliary anthropic base_url fix](https://files.catbox.moe/2qf6pc.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85466",
        "createdAt": "2026-08-13T17:23:22Z",
        "updatedAt": "2026-08-13T17:33:31Z",
        "timestamp": "2026-08-13T17:33:31Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/anthropic",
          "area/config",
          "P3",
          "sweeper:risk-compatibility"
        ],
        "author": "teknium1",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85467",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(relay): session-span segmentation for continuous sessions (salvage #85009)",
        "text": "## Summary Salvage of #85009 by @victor-kyriazakos: opt-in Relay session-scope segmentation so continuous gateway sessions export session-level telemetry at turn boundaries instead of holding one unexported span open for weeks — plus a LIFO-safety fix and docs on top. Relay export is close-driven; a Telegram/Slack session that never ends means the session root span (and its marks) never exports, and a crash loses the whole open segment. Turn spans already export per-turn (#83517); this handles the session-scope remainder. ## Changes - `agent/relay_runtime.py`: `gateway.telemetry.session_segments.{on_compaction,max_turns}` (both default OFF → byte-identical lifecycle); rotation consumed only in `begin_turn`, rides the bounded scope-op executor (#83514); segments share `session_id` + `hermes.session.segment` / `segment_reason` metadata (contributor's work, authorship preserved) - `agent/conversation_compression.py`: observer-only compaction notification (contributor's work) - **Fix on top:** the legacy rotating-compaction path closed the old session scope immediately — a compaction completing while a turn was still live on the old session would pop the session scope under the live turn scope (LIFO violation). Now defers via `close_pending`, consumed by that turn's `end_turn` after its own scope pops. Sabotage-verified: the new regression test fails without the fix. - **Docs on top:** `observability/nemo_relay` section on the built-in plugins page (the plugin had no section) with the `session_segments` config table; summary mirrored in the plugin README. ## Validation | | Result | |---|---| | PR test suite | 12/12 (contributor's 11 + 1 new mid-turn deferred-close regression test) | | All relay tests | 50/50 | | Sabotage run | new test fails at the exact assertion with the fix reverted | | Defaults-off contract | pinned by `TestDefaultsNeverRotate` | | Attribution audit | clean | ## Infographic ![Relay session-span segmentation](https://files.catbox.moe/mpxfpr.png) Closes #85009.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85467",
        "createdAt": "2026-08-13T17:24:11Z",
        "updatedAt": "2026-08-13T17:45:19Z",
        "timestamp": "2026-08-13T17:45:19Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "P3",
          "telemetry",
          "sweeper:risk-session-state",
          "area/compression"
        ],
        "author": "teknium1",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85468",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(enterprise): native IAM — principals, roles, bindings, restrictions",
        "text": "## Summary Native IAM for the Hermes Enterprise control plane: Principals, ServicePrincipals, WorkloadIdentities, Groups, Roles, AccessBindings, and Restrictions, with a deny-by-default `OCCIAMAdapter` implementing the `IAMAdapter` contract from #85461. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `enterprise/iam.py`: `IAMStore` (7 SQLite tables, WAL) + `OCCIAMAdapter` (name `occ-native`). Scope containment installation ⊇ namespace ⊇ exact-resource; group membership resolution; `Restriction` deny patterns (`action:Kind[:name]`) raise `RestrictionError` even over an allow — restrictions narrow, never grant. - Binding confinement enforced at creation: namespace-scoped ServicePrincipals/Groups can't receive installation bindings; a WorkloadIdentity binds only within its own namespace. - `resolve_principal(issuer, subject)` — unknown identity is denied; admission cannot create identities. ## Validation | | Result | |---|---| | `tests/enterprise/test_iam.py` | 29/29 pass | | ruff | clean | ## Infographic ![Hermes Enterprise IAM](https://files.catbox.moe/568w74.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85468",
        "createdAt": "2026-08-13T17:24:15Z",
        "updatedAt": "2026-08-13T17:35:39Z",
        "timestamp": "2026-08-13T17:35:39Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "P3",
          "needs-decision"
        ],
        "author": "teknium1",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85469",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(enterprise): access gateway — fail-closed identity verification and tenant admission",
        "text": "## Summary The access-gateway boundary: verifies externally-issued identity tokens and admits requests to exactly one installation and (when required) one namespace. Fail-closed on every path; caller claims can never select scope. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `enterprise/oag.py`: `TrustConfig` + `OAGVerifier` (implements `IdentityVerifier`). Stdlib-only HS256 JWT verification (constant-time compare), exact-alg enforcement (rejects `none`/alg-confusion), optional RS256 via `cryptography` (unavailable ⇒ `AdmissionError`, never skipped), iss/aud/exp/nbf/sub/required-claims checks. - Installation always comes from server-side config; tenant claim resolves through an explicit `tenant_map` and must match the required namespace exactly. - Test-support `mint_test_token` (HS256, clearly marked) for dev setups. ## Validation | | Result | |---|---| | `tests/enterprise/test_oag.py` | 15/15 pass (incl. tampered payload, no token leakage in errors) | | ruff | clean | ## Infographic ![Access gateway](https://files.catbox.moe/5hf9vf.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85469",
        "createdAt": "2026-08-13T17:24:17Z",
        "updatedAt": "2026-08-13T17:37:36Z",
        "timestamp": "2026-08-13T17:37:36Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "area/auth",
          "P3",
          "needs-decision",
          "sweeper:risk-security-boundary"
        ],
        "author": "teknium1",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85470",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(enterprise): controller — deploy choreography, rollback, namespace lifecycle, CLI",
        "text": "## Summary The controller: owns namespace lifecycle and the single deployment path — authorize → resolve + per-reference authorize → gateway ready → sandbox supports entire policy → immutable `AgentRevision` snapshot → provision candidate → enforce + verify containment → prepare nonserving route → retire previous → activate → start harness → enable route. Failures before activation leave the previous revision untouched; failures after trigger verified rollback, and an unverifiable rollback leaves the agent inactive (`RollbackError`). Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `enterprise/controller.py`: `Controller` + `GatewayManager` ABC (+ in-memory impl); per-agent deploy serialization; monotonic revision numbering; stable `wi-<agent>` workload identity; every decision audited. - `enterprise/cli.py` + `__main__.py`: `python -m enterprise.cli` — init, ns create/list, harness register, config put, agent create/deploy/rollback, get/list, audit tail. Dev-mode memory drivers gated behind `--driver memory`; no wiring into `hermes_cli` yet (kept zero-footprint until the stack lands). ## Validation | | Result | |---|---| | `tests/enterprise/test_controller.py` | 15/15 pass (exact call-order, all failure stages, rollback, CLI smoke deploy) | | ruff | clean | ## Infographic ![The controller](https://files.catbox.moe/oatdc9.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85470",
        "createdAt": "2026-08-13T17:24:19Z",
        "updatedAt": "2026-08-13T17:37:00Z",
        "timestamp": "2026-08-13T17:37:00Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "P3",
          "needs-decision"
        ],
        "author": "teknium1",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85471",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(enterprise): KubernetesComputeDriver — candidate provisioning via kubectl, gated harness start",
        "text": "## Summary The v1 `ComputeDriver`: provisions candidate workloads on Kubernetes via `kubectl` (no python k8s dependency). Candidates are created at `replicas: 0` — the harness literally cannot start until the controller activates the revision and the driver scales to 1. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `enterprise/drivers/kubernetes.py`: ServiceAccount + Deployment (JSON on stdin) with `app.hermes/*` labels, workload-identity SA, sandbox resource limits; readiness via observedGeneration; `start_harness` scales 0→1 with bounded polling; `stop_harness` verifies zero; teardown `--ignore-not-found`; any nonzero kubectl exit ⇒ `DriverError` with stderr. - Single `_kubectl()` seam for testability; secrets never appear in manifests. ## Validation | | Result | |---|---| | `tests/enterprise/test_compute_k8s.py` | 13/13 pass (manifest render from a real AgentRevision, ordering, timeouts, error paths) | | ruff | clean | ## Infographic ![Kubernetes compute driver](https://files.catbox.moe/fzsuir.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85471",
        "createdAt": "2026-08-13T17:24:22Z",
        "updatedAt": "2026-08-13T17:36:01Z",
        "timestamp": "2026-08-13T17:36:01Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "P3",
          "needs-decision"
        ],
        "author": "teknium1",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85472",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(enterprise): K8s sandbox driver — verified containment before harness start",
        "text": "## Summary The v1 `SandboxDriver`: enforces the admitted `SandboxPolicy` via NetworkPolicy + pod securityContext, and independently verifies enforcement by reading the applied objects back before the controller may activate. Partial support is unsupported; unverifiable containment blocks activation. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `enterprise/drivers/sandbox_k8s.py`: `K8sSandboxDriver` (`k8s-baseline`). `supports()` whole-policy truth table (unknown keys ⇒ False, unexpressable egress ⇒ False); `enforce()` default-deny / exact egress-allowlist NetworkPolicies + securityContext patch (nonroot, seccomp RuntimeDefault, drop ALL caps); `verify()` compares readback against policy exactly — drift, missing objects, or kubectl failure ⇒ `DriverError`. ## Validation | | Result | |---|---| | `tests/enterprise/test_sandbox_k8s.py` | 34/34 pass | | ruff | clean | ## Infographic ![Sandbox containment schematic](https://files.catbox.moe/wjkd85.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85472",
        "createdAt": "2026-08-13T17:24:24Z",
        "updatedAt": "2026-08-13T17:38:35Z",
        "timestamp": "2026-08-13T17:38:35Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "P3",
          "needs-decision",
          "sweeper:risk-security-boundary"
        ],
        "author": "teknium1",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85473",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(enterprise): secret brokering — workload-verified, value-free secret operations",
        "text": "## Summary Brokered secrets: workloads never receive secret values or backend credentials. Every operation is verified fail-closed — active revision only, matching workload identity, secret in the revision snapshot, IAM allow, exact broker, selected driver (no fallback) — then executed backend-side, returning only derived results (possession proofs, scoped short-lived tokens). Every attempt audited, allow and deny. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `enterprise/secrets.py`: `SecretBrokerService` (8-step verification pipeline; results are scrubbed with the core secret-shape detector — a driver leaking a value raises `SecretAccessError`), `EnvFileSecretDriver` (dev/test), `VaultHttpSecretDriver` (Vault KV v2 via stdlib urllib, token from env at call time). ## Validation | | Result | |---|---| | `tests/enterprise/test_secrets.py` | 23/23 pass (candidate/retired denied, identity mismatch denied, leak-scrub, vault URL/header assertions) | | ruff | clean | ## Infographic ![Secret brokering](https://files.catbox.moe/wdo51x.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85473",
        "createdAt": "2026-08-13T17:24:26Z",
        "updatedAt": "2026-08-13T17:34:48Z",
        "timestamp": "2026-08-13T17:34:48Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "area/auth",
          "P3",
          "needs-decision",
          "sweeper:risk-security-boundary"
        ],
        "author": "teknium1",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85474",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(enterprise): deployment packaging — Dockerfile, Helm chart, RBAC, smoke script",
        "text": "## Summary Packaging to run Hermes Enterprise on a cluster: one multi-stage image serving both the controller (`python -m enterprise.cli`) and per-namespace gateways, a Helm chart with least-privilege RBAC scoped to `hermes-*` namespaces, and a guarded smoke script. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `deploy/enterprise/Dockerfile`: python:3.12-slim multi-stage, venv, nonroot, OCI labels. - `deploy/enterprise/helm/`: Chart, values, OCC Deployment + PVC (sqlite state), ServiceAccount + Role/RoleBinding (deployments/serviceaccounts/networkpolicies/namespaces, `hermes-*` only), ingress, NOTES. - `deploy/enterprise/smoke.sh` + README (topology, install, values table). ## Validation | | Result | |---|---| | YAML parse (all templates) | pass | | `bash -n` + shellcheck smoke.sh | pass | | helm lint | skipped — no helm binary on this box (noted, not faked) | ## Infographic ![Deployment packaging](https://files.catbox.moe/vkb02y.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85474",
        "createdAt": "2026-08-13T17:24:28Z",
        "updatedAt": "2026-08-13T17:38:44Z",
        "timestamp": "2026-08-13T17:38:44Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "area/docker",
          "P3",
          "needs-decision"
        ],
        "author": "teknium1",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85475",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "docs(enterprise): overview, concepts, deployment, installation, security",
        "text": "## Summary Docs-site section for Hermes Enterprise: five pages (overview, concepts, deployment, installation, security) registered as a new **Enterprise** sidebar category. All pages carry a draft-status admonition since they document the in-flight `ent/*` series. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `website/docs/enterprise/`: architecture + choreography mermaid diagrams, resource-model and IAM reference tables grounded in the `ent/core` contracts, trust-boundary table, explicit v1 non-goals. - `website/sidebars.ts`: Enterprise category registered (pages reachable). ## Validation | | Result | |---|---| | `npx docusaurus build` | SUCCESS (only pre-existing zh-Hans anchor warnings) | ## Infographic ![Enterprise docs](https://files.catbox.moe/8tprcw.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85475",
        "createdAt": "2026-08-13T17:24:31Z",
        "updatedAt": "2026-08-13T17:33:42Z",
        "timestamp": "2026-08-13T17:33:42Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/docs",
          "P3",
          "needs-decision",
          "comp/dashboard"
        ],
        "author": "teknium1",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85476",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(slack): native live cards — chat.startStream reply streaming + opt-in plan/task-card tool progress",
        "text": "## Summary Slack replies now stream as native live-typing messages, and (opt-in) tool progress renders as Slack-native plan/task cards — consolidating the open \"Slack live cards\" PR/issue cluster (#66542 + #29496; closes #29483, addresses #51363's thinking-steps surface). ## Changes - `plugins/platforms/slack/adapter.py`: native streaming via `chat.startStream`/`appendStream`/`stopStream` through the existing draft-streaming interface (`supports_draft_streaming`/`send_draft`); the stream IS the final message, sealed once via `stopStream` (no duplicate final post); feature-gate errors cached → clean fallback to edit-based streaming. Plus `send_native_task_card_progress`/`stop_native_task_card_progress`: workspace-scoped, lock-serialized plan/task-card streams (`task_display_mode: plan`, `task_update` chunks), sealed on disconnect. - `gateway/run.py` + `gateway/turn_context.py`: `platforms.slack.extra.native_task_cards` opt-in wiring on the TurnRunner seam — ID-bearing `tool_start_callback`/`tool_complete_callback` correlate concurrent same-name tool calls by real call ID; native consumer drains the progress queue; on native failure, one continuously edited text fallback keeps progress live; card stream stopped exactly once at turn end. - Docs: `native_task_cards` config key + \"Native Streaming\" and \"Native Task Cards\" sections in the Slack guide; `cli-config.yaml.example` entry. ## Attribution - @ashah360's commit from #66542 cherry-picked (native streaming). - @simonvanlaak's #29496 (earliest surviving task-cards PR, from his own issue #29483) reapplied onto the current TurnRunner/TurnContext seam with authorship preserved. ## Validation | Suite | Result | |---|---| | tests/gateway/test_slack_native_streaming.py | 18 passed | | tests/gateway/test_slack.py + test_run_progress_topics.py + test_stream_consumer_draft.py | 203 passed | | Base freshness | 0 commits behind origin/main at push | ## Infographic ![Slack Live Cards infographic](https://files.catbox.moe/1a9l2s.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85476",
        "createdAt": "2026-08-13T17:25:51Z",
        "updatedAt": "2026-08-13T17:40:09Z",
        "timestamp": "2026-08-13T17:40:09Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "comp/plugins",
          "platform/slack",
          "P3",
          "needs-decision",
          "sweeper:risk-message-delivery",
          "sweeper:risk-compatibility",
          "area/streaming"
        ],
        "author": "teknium1",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85477",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(providers): deepseek reasoning passback condicional + effort off/high/max",
        "text": "## Summary Makes DeepSeek wire replay token- and cache-efficient: `reasoning_content` is only serialized on assistant turns that carried tool calls (the official thinking-mode passback rule), assistant `content` is normalized to `\"\"` (never null), and `reasoning_effort` uses the official DeepSeek vocabulary `off|high|max`. Root cause: Hermes replayed `reasoning_content` on every assistant turn even though the DeepSeek API ignores it on plain turns — every replayed token is billed input. It also passed `low`/`medium` effort values outside the official vocabulary and could serialize an explicit `content: null`, which some gateways reject outright and bricks later turns. ## Changes - `plugins/model-providers/deepseek/__init__.py`: effort mapping — `off`/`none` disable thinking without a wire effort, `minimal`/`low`/`medium` normalize to `high`, `xhigh`/`ultra` to `max`; any other value raises `ValueError` before I/O with a clear message - `agent/message_sanitization.py`: conditional reasoning passback policy + `is_native_deepseek_endpoint` predicate (drop applies only to the native DeepSeek endpoint) - `run_agent.py`: cached `_is_native_deepseek_endpoint()` on `AIAgent` - `agent/agent_runtime_helpers.py`: `copy_reasoning_content_for_api` / `reapply_reasoning_echo_for_provider` plumbing; `sanitize_api_messages` normalizes explicit `None` content to `\"\"` - `agent/transports/chat_completions.py`: `convert_messages` normalizes `None` → `\"\"` on the per-call copy (stored history stays byte-stable) - `tests/plugins/model_providers/test_deepseek_profile.py`, `tests/agent/test_message_sanitization_policy.py`, `tests/agent/transports/test_chat_completions.py`, `tests/run_agent/test_deepseek_reasoning_content_echo.py`, `tests/run_agent/test_message_sequence_repair.py`: coverage for the drop rule (deepseek-only), passback retention on tool-call turns, effort mapping, and null-content normalization ## Validation | Teste | Resultado | |-------|-----------| | `pytest` deepseek profile + message sanitization + transports + run_agent reasoning/sequence suites | 195 passed | | Live API validation (`test_deepseek_v4_thinking_live.py`) | skipped — requires `DEEPSEEK_API_KEY` + `HERMES_LIVE_TESTS=1` | Kimi/Moonshot/MiMo and aggregator-hosted deepseek models (OpenRouter) keep the legacy all-assistant echo — the change is scoped to the native endpoint.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85477",
        "createdAt": "2026-08-13T17:26:37Z",
        "updatedAt": "2026-08-13T17:34:26Z",
        "timestamp": "2026-08-13T17:34:26Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/deepseek",
          "P2",
          "area/usage-cost"
        ],
        "author": "pablo-afterlife",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85478",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "test(e2e): regressão de prompt-cache em turns multi-step (DeepSeek)",
        "text": "## Summary Adds a key-gated integration test that proves prompt-cache hits on multi-step DeepSeek conversations: after the first request, every subsequent request must report `cache_read_tokens > 0` because it shares a byte-identical prefix with its predecessor. Root cause: Hermes has no regression guard for prompt-cache stability. A silent prefix-breaking change (tool ordering, injected messages, system-prompt mutation) would multiply every user's cost with no test catching it — the dsh harness guards this exact property with a live e2e test (`request-cache.e2e.ts`). ## Changes - `tests/integration/test_deepseek_prompt_cache_regression.py` (new): two `pytest.mark.integration` tests — (1) multi-step turn with a tool call in the middle, (2) plain follow-up request; both assert `cache_read_tokens > 0` on the second+ request via the OpenAI SDK usage payload; graceful `skipif` when `DEEPSEEK_API_KEY` is absent; 120s client timeouts ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/integration/test_deepseek_prompt_cache_regression.py -m integration -rs` | 2 skipped (`DEEPSEEK_API_KEY not configured`) | | `ruff check` | All checks passed | | Plumbing probe with dummy key | real 401 from `api.deepseek.com` — endpoint and request path correct |",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85478",
        "createdAt": "2026-08-13T17:26:40Z",
        "updatedAt": "2026-08-13T17:34:20Z",
        "timestamp": "2026-08-13T17:34:20Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/test",
          "comp/agent",
          "provider/deepseek",
          "P3"
        ],
        "author": "pablo-afterlife",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85479",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(compression): prune oversized tool results in place before summarization",
        "text": "## Summary Prunes oversized tool results in their own message node — same position, same role, same `tool_call_id` — down to `head + marker + tail`, as a no-LLM pre-pass that runs BEFORE the summarization region is selected. Shrinking oversized tool bodies often makes the transcript fit the tail budget, skipping the summarizer entirely. Root cause: giant tool outputs (web extraction, terminal dumps) inflate the persisted transcript and force LLM summarization, which invalidates the cached prefix. In-place pruning is the most cache-friendly compaction form: the message count, roles, and call IDs never change — only one node's text shrinks. ## Changes - `agent/context_compressor.py`: `PRUNE_MARKER` (pinned verbatim, also doubles as immunity marker for the demote pass), pure `prune_tool_result_content` (code-point safe; supports str and multimodal list bodies, pruning only text parts), `resolve_tool_result_prune_config` (unsatisfiable budget disables the feature), `_tool_prune_boundary` shared by both passes, integration before region selection - `agent/agent_init.py`: config wiring - `hermes_cli/config_defaults.py`: `compression.tool_result_prune` defaults — `enabled: false` (opt-in), `threshold_chars: 8192`, `head_chars: 4096`, `tail_chars: 1024` - `tests/agent/test_tool_result_prune.py` (new): unit coverage of the pure function (threshold, head/tail, marker, unicode, list content, idempotence), config resolution, node-identity/persistence/rollback, and integration before region selection with the feasibility-skip path avoiding the LLM call ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/agent/test_tool_result_prune.py tests/agent/test_context_compressor.py` | 164 passed (incl. 136 existing compressor tests) | | Default behavior | disabled (`enabled: false`) — conservative, opt-in |",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85479",
        "createdAt": "2026-08-13T17:26:42Z",
        "updatedAt": "2026-08-13T17:34:16Z",
        "timestamp": "2026-08-13T17:34:16Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/cli",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-compatibility",
          "area/compression"
        ],
        "author": "pablo-afterlife",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85480",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(tools): spill oversized tool outputs to session files with preview + locator",
        "text": "## Summary When `tools.spill.enabled` is true and a tool result's UTF-8 size exceeds `tools.spill.max_inline_bytes`, the full text is written ONCE to a session-scoped file and the inline message becomes a bounded head/tail preview plus a locator notice the model follows with `read_file`. Root cause: large unique tool outputs (web extractions, dumps) grow the conversation prefix on every request, pushing the transcript toward compaction and invalidating the provider cache. Spilling at write time keeps every node under a byte cap, so the transcript stays byte-identical between steps and compaction fires far less often. ## Changes - `tools/tool_spill.py` (new): `SpillConfig` (`enabled` default false, `max_inline_bytes` default 100_000), `maybe_spill_tool_result`, byte-safe UTF-8 truncation (no multibyte splits), `_preview_head_tail` (head=ceil/2, tail=floor/2), notice `(N bytes omitted. Full result stored at: <path>)` with its byte cost reserved INSIDE the cap (`budget = cap − notice − 2`, replacement re-checked), traversal-safe session-scoped paths, best-effort storage (write failure keeps the result inline), `read_file` skipped (anti read→spill→read loop) - `agent/tool_executor.py`: integration at both write points (`execute_tool_calls_concurrent` and `execute_tool_calls_sequential`), config resolved once per turn, applied before `make_tool_result_message`; multimodal results untouched - `hermes_cli/config_defaults.py`: `tools.spill` defaults - `tests/tools/test_tool_spill.py` (new): 32 tests — cap applied, notice with path, reserved-budget invariant, best-effort with failing storage, read_file skip, unicode ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/tools/test_tool_spill.py` | 32 passed | | Default behavior | disabled (`enabled: false`) — conservative, opt-in |",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85480",
        "createdAt": "2026-08-13T17:26:45Z",
        "updatedAt": "2026-08-13T17:34:12Z",
        "timestamp": "2026-08-13T17:34:12Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/tools",
          "area/config",
          "P3",
          "sweeper:risk-compatibility"
        ],
        "author": "pablo-afterlife",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85481",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(compression): cache-aware summarization + thinking-off aux titles",
        "text": "## Summary Summarization calls now replay a genuine prefix of the last routed request: the conversation's own system prompt, the real tool schemas, the protected head + compacted region as actual chat messages in order, with the summarization instruction as the final user message. Auxiliary session-title calls force thinking OFF so the titler never burns its budget on reasoning. Root cause: the summarizer built its own standalone prompt, so the auxiliary call started from a cold cache — the provider's KV cache for the conversation prefix was invalidated instead of reused. The dsh harness avoids this by making the auxiliary call a genuine prefix of the last routed request (\"the provider's KV cache is reused instead of invalidated\"). ## Changes - `agent/context_compressor.py`: `_summarizer_wire_message` (role/content/tool_calls/tool_call_id preserved; redaction, think-strip, media/image labels and per-message truncation unchanged), `_bound_summarizer_messages` (structured sibling of `_bound_summary_input`, 160K char cap), `_generate_summary(prefix_messages, tools)`, `compress(tools)` plumbing - `agent/conversation_compression.py`: forward `agent.tools` (the exact request schemas) through `_supported_compression_kwargs` - `agent/title_generator.py`: force thinking OFF via `reasoning_config={\"enabled\": False}` (DeepSeek profile maps it to `extra_body.thinking={\"type\": \"disabled\"}`); minimal input + `max_tokens=64` + caller-owned timeout were already the contract - Tests: `test_compression_cache_aware_input.py` (new) + updated `test_title_generator.py`, `test_compress_focus.py`, `test_compression_small_ctx_threshold_floor.py`, `test_compaction_redaction_boundaries.py`, `test_context_compressor_summary_continuity.py`, `test_pre_compress_memory_context.py` ## Validation | Teste | Resultado | |-------|-----------| | `pytest` (7 compression/title suites) | 84 passed | | Input assembly | system → head → region (chat messages) → instruction LAST — verified by test |",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85481",
        "createdAt": "2026-08-13T17:26:48Z",
        "updatedAt": "2026-08-13T17:34:08Z",
        "timestamp": "2026-08-13T17:34:08Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "provider/deepseek",
          "P3",
          "sweeper:risk-session-state",
          "area/compression"
        ],
        "author": "pablo-afterlife",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85482",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(agent): repeat-tool-reminder advisory (loop hygiene)",
        "text": "## Summary Adds advisory loop hygiene: consecutive identical tool calls (same tool name + canonically identical arguments) get a soft reminder appended to the tail of the current tool result when the run length hits a threshold — escalating tone at 3, 5, and 8 repeats. Root cause: models can loop on the same tool call with identical arguments, burning tokens on every repeated step (and each loop step is a new request that can invalidate the cached prefix). A non-blocking reminder lets the model self-correct without vetoing anything. ## Changes - `agent/repeat_tool_reminder.py` (new): `canonicalize_arguments` (reuses `canonical_tool_args` — deep key-sorted JSON), `wildcard_to_regex` (anchored, `*` → `.*`), per-agent chain with lock, thresholds `[3, 5, 8]` gentle→detailed escalation, `gentle_reminder`/`detailed_reminder` with stable `[reminder]` prefix and capped args preview, `reset`; advisory contract — any exception → `None`, never breaks the loop - `agent/tool_dispatch_helpers.py`: `make_tool_result_message(..., append_tail=None)` — appended at the END, AFTER the untrusted-content wrapping (the reminder never becomes untrusted data; cached prefix stays byte-identical) - `agent/tool_executor.py`: `maybe_remind` in both mount points (sequential + concurrent, using `name`/`args` from parsed calls, correct across success/timeout/cancel/block outcomes) - `agent/conversation_loop.py`: reset on real user message (new turn and mid-turn redirect) - `hermes_cli/config_defaults.py` + `cli-config.yaml.example`: `repeat_tool_reminder` section — `enabled` (default true), `thresholds`, `include`/`exclude` wildcards, `arguments_preview_chars` - `tests/agent/test_repeat_tool_reminder.py` (new, 25 tests) + `tests/agent/test_tool_dispatch_helpers.py` updated ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/agent/test_repeat_tool_reminder.py tests/agent/test_tool_dispatch_helpers.py` | 54 passed | | Non-blocking contract | reminder appends only; execution never vetoed (tested) |",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85482",
        "createdAt": "2026-08-13T17:26:51Z",
        "updatedAt": "2026-08-13T17:34:04Z",
        "timestamp": "2026-08-13T17:34:04Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "area/config",
          "P3",
          "sweeper:risk-compatibility"
        ],
        "author": "pablo-afterlife",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85483",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(delegation): subagent result never-rejects with stop_reason + partial text",
        "text": "## Summary A failing delegation child (model error, timeout, user cancel, token budget) now settles into a structured result entry instead of surfacing as a lost failure: each `delegate_task` result gains an additive `stop_reason` (`completed|aborted|error|max-tokens`; `refusal` is reserved — Hermes has no refusal signal today) and, when the failure would otherwise discard the child's output, a `partial_output` field preserving its last real assistant text. Root cause: child failures previously settled with `summary=None` and no trace of what the child produced before failing; the parent could not distinguish \"declined\" from \"ran out of tokens\" from \"cancelled\". This mirrors the dsh `SubagentResult` contract (`result` never rejects; the tool maps non-completed stop reasons to `isError` while preserving partial text). ## Changes - `tools/delegate_tool.py`: `stop_reason` + `partial_output` fields per entry; timeout/exception paths capture partial text best-effort from the abandoned worker's session messages; completed payloads only gain the additive `stop_reason` field - `tools/process_registry.py`: `_format_async_delegation` renders partial output and `stop_reason` in re-injected completion blocks - `tools/async_delegation.py`: async completions propagate the new fields - `tests/tools/test_delegate.py`, `tests/tools/test_process_registry.py`: error → structured entry with partial text; cancel → aborted; success → completed (existing behavior preserved) ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/tools/test_delegate.py tests/tools/test_process_registry.py` | 117 passed, 28 skipped | | Pre-existing Windows env failures | 6 failed on this PR AND on clean base `1fd21cbd0` (PTY/POSIX/thread tests) — not introduced by this PR | | Backward compatibility | `summary`/`status`/`error`/`exit_reason` semantics unchanged; `stop_reason` additive |",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85483",
        "createdAt": "2026-08-13T17:26:54Z",
        "updatedAt": "2026-08-13T17:34:01Z",
        "timestamp": "2026-08-13T17:34:01Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "tool/delegate",
          "P3"
        ],
        "author": "pablo-afterlife",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85484",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(jobs): ferramentas de jobs em background com coleta",
        "text": "## Summary Adds a single `jobs` tool (action enum `start|list|output|kill`) that gives the agent owned background jobs with async collection, reusing the existing process registry (terminal background infrastructure) — no new process-management layer. Root cause: Hermes could run background processes via the terminal tool, but the agent had no owned job lifecycle: no way to start, list, wait, or kill jobs scoped to the current session, and no completion notice channel. The dsh `jobs` family provides exactly this (owner isolation, `wait` with timeout returning `running`, byte-capped output, completion notices). ## Changes - `tools/jobs_tool.py` (new): action enum `start|list|output|kill`; strict owner isolation `(session_key, task_id)` — foreign ids are indistinguishable from unknown ids (no existence leak); `start` spawns via `process_registry.spawn_local` with `notify_on_complete=True` (completion connects to the existing notification channel) and keeps terminal security guards (tirith, dangerous-command detection, hardline); `output` with `wait`/`timeout_ms` — timeout returns status `running` (never an error), output truncated by bytes with head/tail marker; `kill` terminates the job - `toolsets.py`: `jobs` registered in the `terminal` toolset (next to `terminal`/`process`) + 6 static bundles (core, terminal, debugging, coding, hermes-acp, hermes-api-server) - `hermes_cli/config_defaults.py`: `jobs.max_concurrent_per_session` (default 10), `jobs.output_limit_bytes` (default 20000) - `tests/tools/test_jobs_tool.py` (new, 20 tests) + `tests/test_toolsets.py` updated ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/tools/test_jobs_tool.py tests/test_toolsets.py` | 42 passed | | Scenarios covered | start/list, cross-session isolation, wait+timeout → running, post-completion output, kill, byte truncation, concurrency cap |",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85484",
        "createdAt": "2026-08-13T17:26:57Z",
        "updatedAt": "2026-08-13T17:33:57Z",
        "timestamp": "2026-08-13T17:33:57Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/tools",
          "tool/terminal",
          "P3"
        ],
        "author": "pablo-afterlife",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85485",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(delegation): continuable children v1 — durable id + settlement notice + report",
        "text": "## Summary Adds an opt-in `continuable` flag to `delegate_task` that gives every child a durable identity without changing the default delegation model: result entries carry `child_session_id` (the child's persisted session id, linked to the parent via `parent_session_id`) plus the ephemeral `subagent_id`, the async completion event renders a settlement notice per child, and the child's transcript is re-readable by id via the existing `session_search` READ shape. Root cause: `delegate_task` is ephemeral — children die with the parent session and there is no durable identity, no settlement channel, and no way to re-read a finished child's work. This is the v1 (safe, additive) slice of the dsh continuable-subagent pattern (stable child id + settlement notice + report), with the heavy features explicitly deferred. ## Changes - `tools/delegate_tool.py`: `continuable` flag (default `False` → output byte-identical); entries (success/timeout/error/crash) gain `child_session_id` + `subagent_id` - `tools/async_delegation.py`: background dispatch payloads name all children up-front (`children` list) - `tools/process_registry.py`: completion block renders `Child <id> finished (status=...)` / `was stopped before it finished` / `failed before it finished` (single + batch shapes) - `run_agent.py`: plumbing - `docs/delegation-continuable-children.md` (new): design + explicitly deferred items - `tests/tools/test_delegate_continuable.py` (new, 25 tests, mock-based — no LLM calls) ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/tools/test_delegate_continuable.py` | 25 passed | | Default mode | byte-identical — no id fields emitted unless `continuable=true` (tested) | | Deferred (documented) | `send_message` to child, `interrupt`, cold resume by parent | ## Limitation `child_session_id` is only as durable as the session row (retention pruning or a crash before the first turn loses the transcript; the id is still returned).",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85485",
        "createdAt": "2026-08-13T17:26:59Z",
        "updatedAt": "2026-08-13T17:33:53Z",
        "timestamp": "2026-08-13T17:33:53Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "tool/delegate",
          "P3",
          "sweeper:risk-session-state"
        ],
        "author": "pablo-afterlife",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85486",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(delegation): workflow batch orchestration with semaphore and caps",
        "text": "## Summary Extends `delegate_task` with a `workflow` mode: the parent describes an ordered list of steps, each either a `parallel` fan-out (independent items under a semaphore) or a `pipeline` of sequential stages (output of stage N is appended to the context of stage N+1's prompts). Item failure never kills the batch — a failed item becomes a structured error entry and the rest continues. Root cause: batch fan-out is capped at `max_concurrent_children` with no composition layer — the parent must orchestrate multi-stage work turn-by-turn, multiplying turns and context. The dsh workflow worker-thread runtime (agent/parallel/pipeline, FIFO semaphore, maxTotalAgents backstop, fatal-vs-item semantics) is the pattern reference. ## Changes - `tools/delegation_workflow.py` (new): step engine — `parallel`/`pipeline` step types, semaphore (max_concurrent = `min(8, delegation.max_concurrent_children)`), `max_items = 32` total across all steps, pipeline context chaining (stage N output appended to stage N+1 prompts), flat per-item result array with `status: completed|error`, ordered by `task_index` - `tools/delegate_tool.py`: `workflow` schema on the existing batch tool (no new core tool — narrow waist) - `run_agent.py`: workflow plumbing - `tests/tools/test_delegate_workflow.py` (new, 21 tests, stubbed runner — no real LLM): concurrency cap, per-item error isolation, pipeline context chaining, failed-stage continuation, caps, schema ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/tools/test_delegate_workflow.py` | 21 passed | | Reuses | exact child build/run/finalize machinery of the flat batch — summary budgets, memory manager, subagent_stop hooks, cost rollup apply unchanged | ## Limitation v1 runs synchronously (pipeline depends on prior stage output); no live transcripts; semaphore is the authoritative cap but steps never interleave in v1.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85486",
        "createdAt": "2026-08-13T17:27:02Z",
        "updatedAt": "2026-08-13T17:33:50Z",
        "timestamp": "2026-08-13T17:33:50Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "tool/delegate",
          "P3",
          "needs-decision"
        ],
        "author": "pablo-afterlife",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85487",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(kanban): explicit notify/wake delivery modes with faithful wake session routing (salvage #37865)",
        "text": "## Summary Kanban notify subscriptions gain explicit delivery modes — `notify`, `notify+wake`, `wake` — and woken turns now land in the creator's REAL session instead of a context-less parallel one. Salvage of #37865 by @verybigdog onto current main (authorship preserved; original commits carried a local placeholder identity, re-attributed to the contributor's public email), plus one follow-up fix of ours. ## Changes - `hermes_cli/kanban_db.py`: `delivery_mode` + `chat_type` + `user_id_alt` columns with idempotent migration; child tasks inherit the parent's notify return path (ACK-edge inheritance) - `gateway/kanban_watchers.py`: wake gated on `delivery_mode` instead of unconditional; passive send suppressed in pure-`wake` mode - `gateway/session_context.py` / `tools/kanban_tools.py`: persist `chat_type` and alt user id so the wake replay reconstructs the same `build_session_key`; gateway autosubscribes as `notify+wake`, TUI as `notify` - `hermes_cli/kanban.py`: `--delivery-mode` CLI flag; `gateway/slash_commands.py` autosubscribe; docs updated - **Follow-up (ours):** migration backfills pre-existing gateway subscriptions to `notify+wake` on first-add of the column — before this column existed, wake was unconditional, so defaulting legacy rows to `notify` would have silently disabled active wake on upgrade. Backfill runs only on first-add; explicit downgrades survive re-migration. Sabotage-verified regression tests. ## Validation | | Before | After | |---|---|---| | wake session key | could land context-less (user_id vs alt id divergence) | reconstructs creator's session | | legacy gateway subs after upgrade | silently lose wake | backfilled to notify+wake | | targeted tests | — | 60/60 pass (kanban_notify + kanban_tools + kanban_notifier) | Supersedes #37865; closes it with credit. Triage previously anointed #37865 canonical over dupes #49593/#49749/#49751. ## Infographic ![Kanban delivery modes](https://files.catbox.moe/se1zot.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85487",
        "createdAt": "2026-08-13T17:31:01Z",
        "updatedAt": "2026-08-13T17:47:43Z",
        "timestamp": "2026-08-13T17:47:43Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/cli",
          "comp/gateway",
          "comp/cron",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery",
          "sweeper:risk-compatibility"
        ],
        "author": "teknium1",
        "state": "closed",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85488",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(streaming): kimi-coding header-read timeout + anthropic routing",
        "text": "## Summary A `kimi-coding` streaming request blocked **forever** in httpcore header read — no timeout applied, fallback never engaged, the run had to be killed externally. ## Root cause Two layers: (1) the keepalive HTTP clients had `httpx.Timeout(read=None)` as a floor (per-request timeout still applied for most providers, but the floor made any path that skipped it unbounded); (2) `kimi-coding` was not routed through `anthropic_messages` in the agent_init chain — the request went over OpenAI wire against `/coding`, which never responds with headers. ## Change (defense-in-depth) - `agent/process_bootstrap.py` + `run_agent.py`: keepalive client `read=None` → `read=300.0` (fail-closed; per-request still overrides). - `agent/agent_init.py`: kimi `/coding` detection → `anthropic_messages` routing (the CLI runtime already resolved correctly; the agent_init chain didn't). - `tests/agent/test_kimi_stream_header_read_timeout_85446.py` (new, +8): header-read blocking → APITimeoutError (2.3s repro confirmed empirically); routing tests. ## Verification - `tests/agent/test_kimi_stream_header_read_timeout_85446.py`: **8 passed**. Empirical repro showed the per-request timeout fires (APITimeoutError in 2.3s) once routing is correct. The issue's `api_mode: anthropic_messages` workaround still works and is now redundant. Closes #85446",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85488",
        "createdAt": "2026-08-13T17:31:05Z",
        "updatedAt": "2026-08-13T17:43:20Z",
        "timestamp": "2026-08-13T17:43:20Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/kimi",
          "P2"
        ],
        "author": "webtecnica",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85489",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(image-gen): add Azure Foundry backend provider plugin",
        "text": "## Summary Adds Azure Foundry as a backend provider for the `image_generate` tool. Organizations using Azure-hosted GPT Image deployments can now keep image generation in-platform instead of switching out. ## Change (plugin, per Contributing guide) - `plugins/image_gen/azure-foundry/` (new): `plugin.yaml` (backend manifest, `requires_env: AZURE_FOUNDRY_IMAGE_API_KEY`) + `__init__.py` (`ImageGenProvider` + `register(ctx)`, 552 lines) — follows the existing `plugins/image_gen/<name>/` pattern. Auth via the existing `agent/azure_identity_adapter.build_token_provider()` (callable accepted by the OpenAI SDK as `api_key`). - `tests/plugins/image_gen/test_azure_foundry_provider.py` (new, +37): provider registration, env-gating, Entra ID auth wiring, payload shape. ## Verification - `tests/plugins/image_gen/test_azure_foundry_provider.py`: **37 passed**. Zero modifications to existing files (3 new files only). Closes #85448",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85489",
        "createdAt": "2026-08-13T17:31:27Z",
        "updatedAt": "2026-08-13T17:43:15Z",
        "timestamp": "2026-08-13T17:43:15Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/plugins",
          "tool/vision",
          "provider/openai",
          "P3"
        ],
        "author": "webtecnica",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85490",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(whatsapp): group history backfill via channel_context",
        "text": "## Problem In a mention-gated WhatsApp group (`require_mention: true`), messages that don't address the bot never reach the session transcript. When someone finally @-mentions the bot, it answers **blind to the conversation around it** — it can't even see the message immediately above the one that tagged it. Every other group-capable platform already solves this: - **Discord** fetches recent channel history and attaches it as `MessageEvent.channel_context` (`_fetch_channel_context`). - **Slack** does the same for thread backfill, with per-user watermarks. - **Telegram** has `observe_unmentioned_group_messages`. WhatsApp had neither mechanism — and no history API to fetch from. ## Approach Since Baileys offers no history fetch, the adapter **buffers the group messages its own mention gate skips** (bounded, in-memory, per chat) and replays them on the next triggered message through the existing `channel_context` contract. No core changes — `run.py` already prepends `channel_context` above the trigger message for every platform. ``` [Recent group messages] [Alice] has anyone seen the plumber? [Bob] the pool reopens sunday [New message] [Dana] @bot what did I miss? ``` Design decisions, each anchored to an existing precedent: - **Per-(chat, sender) watermarks** — WhatsApp group sessions are per-sender by default (`group_sessions_per_user: true`), so each participant's session catches up independently; a message is injected at most once per participant. Mirrors the Slack thread-watermark pattern. - **Output shape mirrors Discord** — `[Recent group messages]` header, `[name] text` lines, `[unverified]` tags (via the adapter's configured `_is_sender_authorized` check) with the same background-only preamble Discord uses. - **Prompt-injection guard** — sender display names (user-controlled `pushName`) are collapsed with `neutralize_untrusted_inline_text`, the same guard `run.py` applies to the trigger message's sender prefix. - **Only mention-gated messages are buffered** — messages that triggered the bot are already in a transcript; broadcasts, disallowed groups, and DMs are dropped exactly as before. - **Config keys copied from Discord**: `history_backfill` (default `true`) / `history_backfill_limit` (default `50`), plus `WHATSAPP_HISTORY_BACKFILL(_LIMIT)` env fallbacks, bridged from the `whatsapp:` YAML block like the existing group keys. **Default-on is behavior-neutral for existing deployments**: the buffer only fills and only injects when `require_mention` is on, which is off by default for WhatsApp. Happy to flip the default to opt-in if you'd prefer the Telegram convention over the Discord one here. ## Docs `whatsapp.md` previously didn't document groups at all — this adds a \"Group Chats\" section (`group_policy` / `group_allow_from` / `require_mention` / `mention_patterns` / `free_response_chats`) including the new backfill, plus `environment-variables.md` entries for the two new env vars. ## Tests `tests/gateway/test_whatsapp_group_history_backfill.py` (12 tests, style of `test_whatsapp_group_gating.py`): recording + replay round-trip, watermark semantics across senders, ring-buffer bound, per-chat isolation, broadcast/disallowed-group/DM exclusion, media placeholder, disabled-via-config, no-injection without a mention gap, sender-name neutralization, `[unverified]` tagging, adapter wiring through `_build_message_event`, and YAML→extra config bridging. ``` tests/gateway -k whatsapp: 192 passed, 4 skipped ruff check (changed files): clean ``` ## Future work (out of scope here) - Recording the bot's own group replies as `[bot]` lines so other participants' sessions see answered questions (Discord includes bot lines; needs an outbound-path hook). - The same buffer could back the Cloud API adapter once it gains group support. 🤖 Generated with [Claude Code](https://claude.com/claude-code)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85490",
        "createdAt": "2026-08-13T17:31:55Z",
        "updatedAt": "2026-08-13T17:43:11Z",
        "timestamp": "2026-08-13T17:43:11Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/gateway",
          "comp/plugins",
          "platform/whatsapp",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery"
        ],
        "author": "NehoraiHadad",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85493",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(desktop): map subagent timeout/error completion to terminal failed state",
        "text": "## Summary Timed-out (or exception-failed) subagents stay stuck as **\"running\"** in the desktop Agents panel and status-bar count forever — spinner glyph, counted in \"Agents N running\", never pruned — even though the parent conversation already received the failure. ## Root cause Status-vocabulary drift between the Python backend and the desktop store. The backend completes subagents with statuses the UI never learned: - `tools/delegate_tool.py:2479-2488` — hard child timeout (`delegation.child_timeout_seconds`) emits `subagent.complete` with `status=\"timeout\"` (status introduced in #13770) - `tools/delegate_tool.py:2485` — non-timeout exception exit emits the same event with `status=\"error\"` The desktop store's `asStatus()` (apps/desktop/src/store/subagents.ts) is an allowlist of `completed | failed | interrupted | queued`; anything else falls through to `'running'`. A stuck `running` row is never cleaned up because `pruneFinishedSessionSubagents` only prunes terminal-status rows at the `message.start` boundary, and `activeSubagentCount` keeps counting it in the status bar. ## Changes - **`apps/desktop/src/store/subagents.ts`** - Map backend `\"timeout\"` / `\"error\"` completion statuses to the existing terminal `failed` status — the row renders the failed glyph, counts as failed, and is pruned like any other finished row. No type/i18n/glyph surface changes needed. - Synthesize a `Timed out after Xs` summary (from the backend's `duration_seconds`, which it already sends) when the raw status is `timeout` and no summary arrived, so the row explains why it failed instead of rendering as a bare failure. - **`apps/desktop/src/store/subagents.test.ts`** — timeout → terminal failed + pruned, error → failed, unknown status stays `running` (fall-through guard). ## Testing - `npx vitest run src/store/subagents.test.ts` — **13 passed** (10 pre-existing + 3 new) - `npx eslint src/store/subagents.ts src/store/subagents.test.ts` — clean ## Notes **Open question:** a distinct `timeout` status with its own glyph/label (instead of mapping to `failed`) would be richer UI, but is a much larger surface change — type union, glyph mapping, i18n strings, status-bar counts — for the same user-visible outcome. This PR keeps the narrow mapping; the synthesized summary line preserves the reason. Closes #85492",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85493",
        "createdAt": "2026-08-13T17:32:59Z",
        "updatedAt": "2026-08-13T17:43:09Z",
        "timestamp": "2026-08-13T17:43:09Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "duplicate",
          "tool/delegate",
          "P2",
          "sweeper:risk-session-state",
          "comp/desktop"
        ],
        "author": "DavidMetcalfe",
        "state": "open",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85494",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(hindsight): memory provider improvements — recall_sync, retain_source, setup templates, memory indicators, error hints",
        "text": "## Summary Salvages PR #74379 (@benfrank241) with security and efficiency fixes applied on top. Hindsight memory provider improvements: opt-in synchronous recall (recall_sync), actionable error hints for missing local runtime, opt-in retain_source attribution, starter memory templates during setup, provider-unavailable warning, and deterministic \"recalled N memories\" / \"saving to memory\" status indicators. Fixes #5820, #7718, #2765. Supersedes #70278, #70295, #72926, #73415, #70257, #46504, #5838, #13101. ## Changes - `plugins/memory/hindsight/__init__.py`: recall_sync, retain_source default, indicators, local runtime hint, unavailable_reason(), recall_status() - `plugins/memory/hindsight/templates.py` (new): starter bank template catalog fetch + apply + overwrite warning - `agent/memory_provider.py`: RecallStatus dataclass, unavailable_reason() and recall_status() ABC hooks, generic INDICATOR_GLYPH - `agent/memory_manager.py`: describe_recall() aggregator - `agent/turn_context.py`: recall indicator emit after prefetch - `agent/agent_init.py`: provider-unavailable warning with dedup guard - `hermes_cli/memory_setup.py`: systemd/.env inheritance hint ## Fixes applied on top of @benfrank241's work 1. **Security:** `templates.py` apply_template() and probe_existing_customization() now use `open_credentialed_url()` instead of bare `urlopen()` — prevents credential leak via cross-origin redirect. 2. **Efficiency:** `agent_init.py` guards `unavailable_reason()` with the dedup set check before calling it — was running disk I/O + importlib probes on every gateway turn despite the warning being deduped. 3. **Abstraction:** `INDICATOR_GLYPH` in core is now a generic brain (🧠) instead of Hindsight's brand mark. Hindsight overrides with its own `_HINDSIGHT_GLYPH` (👁️). ## Validation - 129/129 targeted tests pass (7 test files) - Ruff clean - E2E smoke tests pass with real imports Contributor: @benfrank241 (ben.bartholomew@vectorize.io) — authorship preserved via commit 1.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85494",
        "createdAt": "2026-08-13T17:34:49Z",
        "updatedAt": "2026-08-13T17:45:27Z",
        "timestamp": "2026-08-13T17:45:27Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/cli",
          "comp/plugins",
          "tool/memory",
          "P3"
        ],
        "author": "kshitijk4poor",
        "state": "closed",
        "assignees": []
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85498",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): default context lookup for empty model IDs (salvage #65515) — un-reds main CI",
        "text": "## Summary Every open PR's CI is currently red on one shard with: ``` FAILED tests/run_agent/test_primary_runtime_restore.py::TestTryRecoverPrimaryTransport::test_allowed_for_nous_anthropic_messages ValueError: Model has a context window of 32,000 tokens, which is below the minimum 64,000 required by Hermes Agent. ``` (Seen on #85444 slice 7/12, #85452 slice 2/12; reproduces locally on plain `upstream/main` files.) Root cause: the test constructs an agent with `model=\"\"` against the live Nous portal URL. `get_model_context_length(\"\")` reaches `_resolve_endpoint_context_length`, whose fuzzy matcher ```python if model in key or key in model: ``` is **vacuously true for an empty model** — `\"\" in key` matches every entry — so it picks an arbitrary model from the live `/v1/models` response and returns that entry's context length. The portal catalog changed recently; the arbitrary match now lands on a 32K entry, `init_agent` raises the 64K-floor `ValueError`, and the junk value is persisted under a `@https://inference-api.nousresearch.com/v1` cache key (visible in the failing log: `Cached context length @https://... -> 32,000 tokens`). This is a salvage of #65515 by @whirmill (open since July 16), rebased onto current main — the same guard, now positioned after the malformed-base_url normalization that landed since, plus an explanatory comment documenting the fuzzy-match footgun. Contributor authorship preserved on the commit. ## Changes - `agent/model_metadata.py`: a blank/empty model id falls back to `DEFAULT_FALLBACK_CONTEXT` immediately — before any cache write or network probe. - `tests/agent/test_model_metadata.py`: `test_empty_model_uses_fallback_context` (covers `\"\"` and `None`). ## Validation - The two previously failing/new tests pass: `test_allowed_for_nous_anthropic_messages` + `test_empty_model_uses_fallback_context`. - Full `tests/agent/test_model_metadata.py` + `tests/run_agent/test_primary_runtime_restore.py`: 92 passed. - Mutation check: removing the guard makes `test_empty_model_uses_fallback_context` fail (arbitrary live-catalog match returns non-default), restoring goes green. - `ruff check` clean. Closes #65515.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85498",
        "createdAt": "2026-08-13T17:43:57Z",
        "updatedAt": "2026-08-13T17:52:11Z",
        "timestamp": "2026-08-13T17:52:11Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "P1"
        ],
        "author": "kshitijk4poor",
        "state": "closed",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85499",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cli): show context-length detection result when saving custom provider",
        "text": "Fixes #2513. When a custom provider is saved via `hermes model` / `hermes setup` with the context-length prompt left blank, the value stays `None` and the runtime resolution chain (`agent/model_metadata.get_model_context_length`) runs silently later. Users got no feedback about which context window was actually in effect — or whether it was a real detection vs. the hard fallback. This implements the approach the closed reference PR #2522 was verified for, adapted to current main (the flow moved from `hermes_cli/main.py::_model_flow_custom` to `hermes_cli/model_setup_flows.py`): - After parsing the blank input, call `get_model_context_length(model_name, base_url=effective_url, api_key=effective_key)` — the full 9-step resolution chain (endpoint probe, local server, registries, family defaults). - Distinguish a genuinely detected value from `DEFAULT_FALLBACK_CONTEXT` so the fallback is never mislabeled as \"auto-detected\". - Persist only real detections into the `custom_providers` entry; the bare fallback stays unset (so the runtime re-resolves each startup and logs its own fallback warning). - Print one clear line either way, using `_format_context_length` (`256K`-style). - Wrap the whole probe in try/except — a failing probe must never block saving the provider. Regression tests (`tests/hermes_cli/test_custom_provider_context_feedback.py`) cover: - known model resolves to a non-fallback value - unknown model falls through to `DEFAULT_FALLBACK_CONTEXT` - the fallback sentinel is distinguishable from a real detection - blank model name never triggers a probe Note: the fallback constant on current main is 256K (`CONTEXT_PROBE_TIERS[0]`), not the 128K mentioned in the issue — the messages reference `DEFAULT_FALLBACK_CONTEXT` directly so they stay correct if the tier list changes.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85499",
        "createdAt": "2026-08-13T17:46:24Z",
        "updatedAt": "2026-08-13T17:52:15Z",
        "timestamp": "2026-08-13T17:52:15Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "area/config",
          "P3",
          "sweeper:risk-compatibility"
        ],
        "author": "Luna161",
        "state": "open",
        "assignees": [],
        "change": "updated"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85500",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(file): fail closed on silent read transport",
        "text": "## Summary - reject empty or malformed successful size/line-count probes instead of coercing them to zero - reject empty in-range content for a known nonempty regular file - add transport-shaped regressions for linked-worktree and delegation-summary false empties ## Verification - `scripts/run_tests.sh tests/tools/test_file_operations_edge_cases.py tests/tools/test_file_operations.py tests/tools/test_read_shell_line_clamp.py` (97 passed, 2 platform skips) - `.venv/bin/ruff check tools/file_operations.py tests/tools/test_file_operations_edge_cases.py` - real `ShellFileOperations.read_file` smoke on this linked worktree and a delegation-cache summary",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85500",
        "createdAt": "2026-08-13T17:53:35Z",
        "updatedAt": "2026-08-13T18:01:07Z",
        "timestamp": "2026-08-13T18:01:07Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "tool/file",
          "P2"
        ],
        "author": "ajoslin",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85501",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(kanban): inherit ALL routing columns in notify-sub inheritance",
        "text": "## Summary Child kanban tasks now inherit EVERY routing/delivery column of their parent's notify subscription — `chat_type`, `user_id_alt`, `delivery_mode`, and `delivery_metadata` included. Fixes #73030. Root cause: `_inherit_notify_subs()` (the link_tasks / triage-decompose / create-with-parents path) copied only platform/chat/thread/user/profile. A DM-originated child completion fell back to `chat_type='group'` and woke a fresh group-scoped session instead of the originating DM; Telegram DM-topic subscriptions lost their persisted reply-fallback metadata. ## Changes - `hermes_cli/kanban_db.py`: `_inherit_notify_subs` copies all routing/delivery columns; the duplicated inline inheritance block in `create_task` (added by #85487) consolidated onto the single-owner helper — one inheritance path, every column, one owner - `tests/hermes_cli/test_kanban_notify.py`: sabotage-verified regression tests for both the `link_tasks` and create-with-parents paths, asserting all four previously-dropped columns ## Validation | | Before | After | |---|---|---| | inherited chat_type / metadata | NULL (dropped) | preserved | | DM child wake | group-scoped fallback session | originating DM | | inheritance code paths | 2 (helper + inline dup) | 1 owner | | targeted tests | — | 22/22 (63/63 across kanban suites) | ## Infographic ![Kanban inheritance fix](https://files.catbox.moe/8uk9xi.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85501",
        "createdAt": "2026-08-13T17:54:01Z",
        "updatedAt": "2026-08-13T18:00:44Z",
        "timestamp": "2026-08-13T18:00:44Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "comp/cron",
          "platform/telegram",
          "P3",
          "sweeper:risk-session-state",
          "sweeper:risk-message-delivery"
        ],
        "author": "teknium1",
        "state": "closed",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85502",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(kanban): terminalize iteration exhaustion",
        "text": "## Summary - make Kanban iteration-budget exhaustion terminal for the exact run/revision instead of routing it through retryable wall-clock timeout handling - preserve workspace/evidence, record `retryable=false` and `resume_policy=never`, and prevent automatic claim/unblock of the exhausted card - notify and wake the owner for replan while retaining the existing delivery-mode boundary - keep normal wall-clock timeout retry behavior unchanged ## Why The iteration ceiling is an emergency stop. Retrying the same task revision after it has exhausted its model/tool budget can repeat the same broad run and risks obscuring the preserved partial artifact. Continuing work should use an owner-planned replacement revision that references the preserved workspace. ## Tests ```text 17 passed, 5 third-party deprecation warnings ruff: pass py_compile: pass git diff --check: pass ``` Focused coverage includes: - exact-run terminal task/run state - stale-finalizer guard against a successor run - idempotent repeated finalization - preserved workspace/evidence and terminal metadata - no automatic claim or unblock - notifier text and owner wake context - unchanged retryable wall-clock timeout semantics",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85502",
        "createdAt": "2026-08-13T17:55:04Z",
        "updatedAt": "2026-08-13T18:01:03Z",
        "timestamp": "2026-08-13T18:01:03Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "comp/gateway",
          "comp/cron",
          "P3",
          "sweeper:risk-message-delivery"
        ],
        "author": "stigrunar",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85503",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(whatsapp): strip the multi-device suffix when normalizing bot ids",
        "text": "## Bug Baileys hands the bridge its own identity **with a device index** — `sock.user.id` is `\"15551234567:17@s.whatsapp.net\"` — while inbound `mentionedJid` entries and `contextInfo.participant` arrive **without** one. Both normalizers (`normalizeWhatsAppId` in `bridge_helpers.js`, `_normalize_whatsapp_id` in `whatsapp_common.py`) folded the first `:` into an `@` instead of stripping the suffix, producing a malformed two-`@` id: ``` \"15551234567:17@s.whatsapp.net\" → \"15551234567@17@s.whatsapp.net\" ← never equals the clean form ``` So every set-membership comparison of `botIds` against clean inbound ids silently failed. ## User-visible impact | Path | Effect | |---|---| | `_message_mentions_bot` — `mentionedIds ∩ botIds` | Never matched. @-mentions only kept working through the bare-number **substring fallback** on the body text. | | `_message_is_reply_to_bot` — `quotedParticipant ∈ botIds` | Never matched. Under `require_mention`, **quote-replying the bot's own answer does not re-trigger it** — the natural \"reply to the bot in a group\" flow is dead, with no fallback. | | `reply_to_is_own_message` | Always `False` — the gateway renders `[Replying to: ...]` instead of `[Replying to your previous message: ...]`, and downstream logic keyed on it never fires. | ## Why this is clearly a bug, from the tree itself - The **owner message gate** in `bridge.js` strips the suffix inline (`.replace(/:.*@/, '@')`) when resolving \"my\" number, with a comment documenting the `sock.user.id` shape. - `gateway/whatsapp_identity.normalize_whatsapp_identifier` both splits on `:` and lists `\"60123456789:47@s.whatsapp.net\"` in its docstring as a shape \"the WhatsApp bridge may emit\". Only the `botIds` path missed it — and the existing tests never caught the mismatch because they hand-write clean botIds (`tests/gateway/test_whatsapp_group_gating.py`, `bridge.native.test.mjs`). ## Fix Strip `\":<device>\"` before the `\"@\"` in **both** normalizers, so any producer/consumer combination converges on the clean JID. No behavior change for already-clean ids, group JIDs, or bare numbers. ## Tests - `tests/gateway/test_whatsapp_botids_device_suffix.py` — drives the mention/quote gates with the **realistic suffixed identities**. **5 of the 6 tests fail against the previous normalization** (the passing one is the substring fallback, matching the analysis above). Includes the mirror case (suffixed `quotedParticipant` vs clean botIds) as defense in depth. - `bridge.native.test.mjs` — new `normalizeWhatsAppId` block with the suffixed → clean cases. ``` tests/gateway -k whatsapp: 186 passed, 4 skipped bridge JS test files: all pass ruff check (changed files): clean ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85503",
        "createdAt": "2026-08-13T17:55:19Z",
        "updatedAt": "2026-08-13T18:01:00Z",
        "timestamp": "2026-08-13T18:01:00Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/bug",
          "duplicate",
          "comp/gateway",
          "platform/whatsapp",
          "P2",
          "sweeper:risk-message-delivery"
        ],
        "author": "NehoraiHadad",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85504",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "feat(providers): discover pip-installed model providers via entry points (salvage #81419)",
        "text": "## Summary `pip install`ed model-provider plugins now register their providers and show their models in the registry, matching filesystem-plugin behavior — gated by the same `plugins.enabled` opt-in the general PluginManager enforces. Salvages #81419 by @albertodepaola (cherry-picked, authorship preserved) with hardening on top. ## Changes - `providers/__init__.py`: new step-0 entry-point scan of the `hermes_agent.plugins` group in `_discover_providers()`; supports `module:func` callables and self-registering bare modules (contributor's work). Follow-up hardening: - honors `plugins.enabled` allow-list / `plugins.disabled` deny-list — installed ≠ loaded - skips callables that require arguments, so `register(ctx)`-style general plugins sharing the group are never invoked (no TypeError warning spam) - discovered FIRST → lowest precedence: last-writer-wins means bundled/`$HERMES_HOME` profiles always beat a pip impostor on name collision - `tests/providers/test_entry_point_discovery.py`: contributor's 3 tests + 3 new (opt-in gate, deny-list wins, `register(ctx)` never invoked); fixed inverted precedence docstring - `website/docs/developer-guide/model-provider-plugin.md`: corrected mechanism wording, documented the config gate with a `plugins.enabled` example ## Validation | Check | Result | |---|---| | `tests/providers/` (56 tests) | pass | | E2E: real pip-built package (`pip install --target`), temp HERMES_HOME | not enabled → skipped; enabled → registered; deny-list → skipped | ## Infographic ![PIP providers via entry points](https://files.catbox.moe/shbze8.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85504",
        "createdAt": "2026-08-13T17:55:28Z",
        "updatedAt": "2026-08-13T18:00:48Z",
        "timestamp": "2026-08-13T18:00:48Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [
          "type/feature",
          "comp/agent",
          "comp/plugins",
          "P3"
        ],
        "author": "teknium1",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85505",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(sessions): keep gateway reset continuations listable (salvage #84009)",
        "text": "## Summary Sessions created after a gateway reset (`/new`, idle/daily timeout, suspension, expired resume recovery) are listable again in every session surface — `/sessions`, `hermes sessions list`, desktop sidebar, dashboard — and are no longer classified as ephemeral (cascade-delete-class) children. Root cause: since d2a4d373eb, gateway resets write `parent_session_id` on the continuation row for durable lineage, but `_LISTABLE_CHILD_SQL` only admitted `branched` parents — every reset continuation was treated as a hidden subagent/compression row, and `_ephemeral_child_sql` classified it as a cascade-delete target. Salvage of #84009 by @embwl0x (cherry-picked, authorship preserved) plus follow-up fixes from review. ## Changes **Salvaged from #84009 (@embwl0x, 2 commits):** - Durable `_reset_from` marker in `model_config` for gateway reset continuations (survives parent reopen/re-end churn — the mutable `end_reason` heuristic alone regresses to hidden after a parent resume, the same fragility class fixed for `/branch` in #39214) - Legacy same-routing-key fallback in `_RESET_CHILD_SQL` for pre-marker rows; reset children excluded from `_ephemeral_child_sql` - Marker preservation across the `create_session` ON CONFLICT upsert (gateway identity row first, agent enriches later) - `reopen_session()` stamps markerless legacy children durable before clearing the parent's `end_reason`; two raw CLI reopen SQL sites routed through the shared helper - Real-path tests: reset→`/sessions full`, legacy rows per end reason, count parity, migration classification, negative cases **Follow-up commit (review findings):** - `session_switch` added to `_RESET_END_REASONS`: a reset continuation's parent can later be promoted to `session_switch` (resume, then switch away), permanently hiding pre-marker legacy children — reopen-time stamping can't rescue them because the parent is being ended, not reopened. Probe-verified. - `resolve_resume_session_id` forward walker now excludes reset children (marker + legacy shape): resuming a reset parent could redirect into the post-reset conversation — the exact context the user reset away. Compression-tip following unchanged (regression-tested). - Boundary-set dedup: the legacy heuristic is shared via `_legacy_reset_child_sql()` between the listing predicate and the stamping UPDATE, and `find_latest_gateway_session_for_peer`'s two hand-written recovery-fence literals now derive from `_RESET_END_REASONS_SQL` (was a third copy of the same set; its own docstring warned about exactly this drift). ## Validation | Probe (reset child, parent reopened then re-ended) | main | #83987 | #84198 | this PR | |---|---|---|---|---| | fresh reset listed | ✗ | ✓ | ✓ (but still ephemeral) | ✓ | | after parent reopen + re-end | ✗ | ✗ | ✗ | ✓ | | legacy child, parent promoted to `session_switch` | ✗ | ✓ | ✗ | ✓ | - `tests/test_hermes_state.py` 229 passed (3 new); `tests/gateway/test_resume_command.py` + `tests/cli/test_resume_display.py` 42 passed; `tests/hermes_state/` 88 passed; session-reset-notify + continuity + session-API suites green - Walker regression tests mutation-checked (guard fails with the fix reverted, passes restored) - EXPLAIN QUERY PLAN: new predicate arm is an indexed PK probe; reopen stamping uses `idx_sessions_parent` (sub-ms) - ruff clean ## Credit Based on #84009 by @embwl0x — commits cherry-picked with authorship preserved. Same bug also targeted by #83987 (@albert748) and #84198 (@JonthanaHanh), both using the mutable `end_reason` heuristic that regresses after a parent resume; #83987's boundary-reason analysis informed the `session_switch` follow-up. Fixes #83861. Fixes #84109. Closes #84009. Closes #83987. Closes #84198.",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85505",
        "createdAt": "2026-08-13T17:57:04Z",
        "updatedAt": "2026-08-13T18:00:52Z",
        "timestamp": "2026-08-13T18:00:52Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/gateway",
          "P1",
          "sweeper:risk-session-state",
          "area/sessions"
        ],
        "author": "kshitijk4poor",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85506",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): reject stale 32K metadata underreports for MiniMax (extends Kimi guard)",
        "text": "## Summary MiniMax M2-family models wrongly capped at 32K: stale third-party metadata underreports them as 32,768 (real window 204,800), and both the persisted cache and the OpenRouter/Nous fallback paths trusted it — tripping the 64K minimum and breaking previously-working setups after upgrade. This extends the existing Kimi 32K-underreport guard to a shared `_model_name_suggests_stale_32k_underreport()` covering both families. Salvages #24436 by @luoxiao6645 (cherry-picked, authorship preserved; resolves the remaining half of #24140's class that our generic pre-catalog guard in #85434 did not cover — that guard handles persisted pre-catalog values, this one rejects live 32K underreports). ## Changes - `agent/model_metadata.py`: `_model_name_suggests_minimax()` + `_model_name_suggests_stale_32k_underreport()`; the step-1 cache invalidation, the Nous-path `_safe_ctx`, and the step-6 OpenRouter guard now cover MiniMax alongside Kimi. - `tests/agent/test_model_metadata.py`: cache-invalidation, OR-fallback-rejection, and non-MiniMax-safety tests (contributor's), conflict-resolved onto the current generic-guard code. ## Validation | | Result | |---|---| | `test_model_metadata.py` + `test_minimax_provider.py` | 99/99 pass | | Stale-base gate | 0 behind, 3-file diff | ## Infographic ![MiniMax 32K underreport guard](https://files.catbox.moe/vgt8sk.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85506",
        "createdAt": "2026-08-13T17:58:31Z",
        "updatedAt": "2026-08-13T18:00:40Z",
        "timestamp": "2026-08-13T18:00:40Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [
          "type/bug",
          "comp/agent",
          "provider/minimax",
          "P2"
        ],
        "author": "teknium1",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85507",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): guard against non-positive context_length poisoning the cache",
        "text": "## Summary A cached `context_length` of 0 (corrupted cache, failed probe, manual edit) poisoned the whole resolution chain: `0 is not None` short-circuits step 1, so the compressor and every downstream display got context_length=0 forever. This refuses to persist non-positive values and drops any pre-existing non-positive entry so it re-resolves. Salvages #25812 by @OmarB97 (cherry-picked, authorship preserved). The original shipped without tests; we added sabotage-verified regression coverage on top. ## Changes - `agent/model_metadata.py` (@OmarB97): `save_context_length()` refuses `length <= 0`; step-1 drops `cached <= 0` entries and re-resolves. - `tests/agent/test_model_metadata.py` (ours): never-persisted + dropped-and-re-resolved regression tests. ## Validation | | Result | |---|---| | `test_model_metadata.py` | 76/76 pass | | Sabotage run (guard disabled) | both new tests fail as expected, restore → green | ## Infographic ![Zero poisons the cache — non-positive context lengths rejected](https://files.catbox.moe/8jaai3.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85507",
        "createdAt": "2026-08-13T17:58:34Z",
        "updatedAt": "2026-08-13T17:58:34Z",
        "timestamp": "2026-08-13T17:58:34Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "teknium1",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85508",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(cron): honest fallback/timeout/drift alerts + drift-guard alert-once",
        "text": "Cron failure alerts stop lying about fallback chains, timeouts, and drift — and drift alerts fire once per job instead of once per tick. Salvage of #85011 (@victor-kyriazakos), which itself carries #81579 (@georgell-ceo); reconciled with #72056 (@sashmatash), the earliest submission of the drift-alert-delivery half. All contributor authorship preserved via cherry-pick. ## Changes - `cron/scheduler.py`: `_fallback_chain_phrase()` distinguishes \"no chain configured\" (with the fix commands: `hermes fallback add`, or `cron.model` + `cron.model_provider`) from genuine exhaustion (#81579 + #85011). - `cron/scheduler.py`: the scheduler's own inactivity watchdog (`idle for Ns (limit Ns)`) is classified before the generic timeout match instead of being reported as a provider timeout (#81579). - `cron/scheduler.py`: sibling site widened — the TERMINAL_CWD lock-wait `TimeoutError` (#79768) also no longer falls through as \"provider timeout\" (our follow-up; gap flagged in #81579 review). - `cron/scheduler.py` + `cron/jobs.py`: #44585 drift-guard skips alert once per job via a persisted `drift_alerted` bit with a `:silent` marker (same shape as the blocked-config preflight, #73506); cleared on the next successful run so future drift re-alerts (#85011). - Drift alert delivered untruncated (bypasses the summarizer's 180-char cap) and with lifecycle-aware remediation from #72056: recurring jobs get the pin command, consumed finite one-shots are told to recreate the job. - `website/docs/user-guide/features/cron.md`: drift-guard tip documents alert-once and the one-shot recreation path. ## Validation | | Before | After | |---|---|---| | No chain configured, provider fails | \"Fallback chain was exhausted or unavailable.\" | \"No fallback chain configured — add one with `hermes fallback add`, or…\" | | Inactivity watchdog trips | \"provider timeout. Fallback chain was exhausted…\" | \"the job itself stalled — no tool/API activity…\" | | TERMINAL_CWD lock timeout | \"provider timeout. Fallback chain was exhausted…\" | \"could not acquire the scheduler's working-directory lock…\" | | 40 unpinned jobs after config change | identical drift alert per job per tick | one alert per job; re-arms when drift heals | | Consumed finite one-shot drifts | told to `cronjob action=update` a dead job | told to create a new one-shot with explicit provider/model | Tests: 13 new across 4 files, asserting delivered content through `run_one_job` + `_deliver_result`. Full cron suite: 578 passed, 1 skipped. Closes #85011, closes #81579, closes #72056. ## Infographic ![Honest cron alerts](https://files.catbox.moe/icm527.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85508",
        "createdAt": "2026-08-13T17:58:34Z",
        "updatedAt": "2026-08-13T18:01:56Z",
        "timestamp": "2026-08-13T18:01:56Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [],
        "author": "teknium1",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85509",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(agent): write context-length cache atomically",
        "text": "## Summary The context-length cache was written with a plain truncating `open(path, \"w\")` — a crash/kill mid-dump leaves empty or partial YAML, and the next load swallows the parse error and returns `{}`, silently wiping every persisted context length. Both writers now use the existing `atomic_yaml_write()` (temp file + fsync + `os.replace`). Salvages #40919 by @sasquatch9818 (cherry-picked, authorship preserved), the cleaner of the two duplicate implementations. #35140 by @annguyenNous proposed the same fix 8 days EARLIER — first-submitter credit to @annguyenNous; #40919 was picked for its regression test and comments. ## Changes - `agent/model_metadata.py`: `save_context_length()` + `_invalidate_cached_context_length()` → `atomic_yaml_write()`. - `tests/agent/test_model_metadata.py`: interrupted-write-leaves-cache-intact regression test. ## Validation | | Result | |---|---| | `test_model_metadata.py` | 75/75 pass | | Stale-base gate | 0 behind, 2-file diff | ## Infographic ![Atomic cache write — no torn YAML](https://files.catbox.moe/uachrd.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85509",
        "createdAt": "2026-08-13T17:58:36Z",
        "updatedAt": "2026-08-13T17:58:36Z",
        "timestamp": "2026-08-13T17:58:36Z",
        "metrics": {
          "reactions": 0,
          "comments": 0
        },
        "labels": [],
        "author": "teknium1",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85510",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(xai): preserve xhigh effort and priority service_tier for Grok 4.6",
        "text": "## Summary Grok 4.6 is the first xAI Responses model to accept `reasoning.effort=xhigh` and xAI Priority Processing (`service_tier`), but Hermes' provider-wide xAI guards silently clamped xhigh→high and stripped `service_tier` for every Grok model — so 4.6 users were silently downgraded. This model-gates both guards on the 4.6 family and exposes the `/fast` toggle for it. Fixes #84799. Salvages #84820 by @fangliquanflq (cherry-picked, authorship preserved), the tighter of two competing fixes (exact-slug family matcher + priority-value check on the service_tier preserve). #84848 by @Chukwuebuka-2003 was submitted 1h21m later with the same coverage — both credited; we carried over its docstring line for `resolve_fast_mode_overrides` as a follow-up commit. ## Changes - `agent/model_metadata.py` (@fangliquanflq): `is_grok_46_family()` matcher. - `agent/transports/codex.py` (@fangliquanflq): xhigh clamp and `service_tier` strip both model-gated on the 4.6 family. - `hermes_cli/models.py` (@fangliquanflq): `/fast` toggle exposed for Grok 4.6 (`model_supports_fast_mode`), falls through to `{\"service_tier\": \"priority\"}`. - Tests (@fangliquanflq): transport preserve/strip/clamp matrix + fast-command coverage. - Docstring follow-up (ours), mirroring #84848. ## Validation | | Result | |---|---| | `test_codex_transport.py` + `test_fast_command.py` + `test_model_metadata.py` | 169/169 pass | | Stale-base gate | 0 behind, 5-file diff | ## Infographic ![Grok 4.6 wire capabilities — xhigh + priority preserved](https://files.catbox.moe/3xg4ww.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85510",
        "createdAt": "2026-08-13T17:58:39Z",
        "updatedAt": "2026-08-13T18:02:12Z",
        "timestamp": "2026-08-13T18:02:12Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [],
        "author": "teknium1",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:85511",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(tools): symlink-safe exclusive creation for all spill/cache writers",
        "text": "## Summary Spill-file writes can no longer be redirected through a planted symlink, and raw pre-redaction spills are now owner-only instead of world-readable. Root cause: all four spill/cache writers (terminal overflow tee, hook context spill, web_extract full-text store, subagent summary spill) used plain `open()`/`Path.write_text` into predictable directories under `~/.hermes` — which follows a pre-existing symlink, so any local process able to write to those directories could divert our write onto an arbitrary user-owned file (`~/.bashrc`, `authorized_keys`, ...). Terminal and hook spills — which hold raw output BEFORE secret redaction — also landed world-readable under the default umask. ## Changes - `tools/spill_safety.py` (new): `ensure_spill_dir` / `open_exclusive` / `write_text_exclusive` — create with `O_CREAT|O_EXCL|O_NOFOLLOW` (a link-shaped path fails the write instead of following it); overwrite goes through lstat-checked unlink + exclusive re-create, so it removes only the link, never touches its target, and cannot be raced. Two tiers: `private=True` forces `0o700` dir / `0o600` file. - `tools/environments/base.py`: terminal overflow tee → private + exclusive. - `tools/terminal_tool.py`: post-redaction spill rewrite → symlink-safe overwrite (covers a link planted between collector write and redaction). - `tools/hook_output_spill.py`: hook context spill → private + exclusive. - `tools/web_tools.py`, `tools/delegate_tool.py`: exclusive create, umask perms kept — `cache/web` and `cache/delegation` are bind-mounted into remote backends (Docker/Modal/SSH) whose container UID must still read them. - `tests/tools/test_spill_safety.py`: 12 tests including the planted-symlink, dangling-symlink, symlinked-dir, and overwrite-keeps-target cases. ## Validation | | Before | After | |---|---|---| | Symlink at spill path | write follows link into target | `OSError`, target untouched (E2E: all 4 sites) | | Raw terminal/hook spill perms | umask (0644 typical) | 0600 in 0700 dir | | Normal spill content | — | byte-identical (21 existing spill tests pass) | E2E-tested against real functions with isolated `HERMES_HOME`: hook spill attack refused with \"spill write failed\" preview, collector attack leaves victim intact and command execution unbroken, web_extract re-extraction overwrite still works, remote-mount cache files remain readable. Pattern adapted from DeepSeek Harness `dsh-spill-local` (MIT): private spill root + exclusive owner-only opens. ## Infographic ![Spill files: symlink-safe](https://files.catbox.moe/abz8f6.png)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/85511",
        "createdAt": "2026-08-13T17:59:22Z",
        "updatedAt": "2026-08-13T18:01:54Z",
        "timestamp": "2026-08-13T18:01:54Z",
        "metrics": {
          "reactions": 0,
          "comments": 1
        },
        "labels": [],
        "author": "teknium1",
        "state": "open",
        "assignees": [],
        "change": "new"
      },
      {
        "id": "github:NousResearch/hermes-agent:pull_request:9942",
        "source": "github",
        "group": "coding-agent-infrastructure",
        "project": "NousResearch/hermes-agent",
        "kind": "pull_request",
        "title": "fix(gateway,cli,tui): pass user arguments to exec quick_commands",
        "text": "## Problem The gateway, CLI, and TUI gateway `type: exec` quick_commands all silently dropped user arguments. Any slash command wrapping a CLI tool would fail to receive the query. **Root cause:** `exec_cmd` was used directly without appending user arguments. The alias type handler already forwarded args, but all three exec dispatch paths were missing the same logic. ## Real-World Example ```yaml quick_commands: poly: type: exec command: \"polymarket markets search\" ``` **User types in Telegram:** `/poly white sox` - **Before fix:** Gateway runs `polymarket markets search` (no query) — script returns usage message - **After fix:** Gateway runs `polymarket markets search white sox` — returns market results ## Changes All three exec dispatch paths now extract user args, `shlex.quote()` them for shell injection safety, and append to `exec_cmd` before spawning the subprocess. The append is gated on non-empty args so zero-arg commands are unaffected. - **gateway/run.py** (~line 10016): `event.get_command_args()` + `shlex.quote()` - **cli.py** (~line 8929): `cmd_original[len(base_cmd):]` + `shlex.quote()` - **tui_gateway/server.py** (~line 11823): `arg` from `command.dispatch` params + `shlex.quote()` ## Tests (7 new) **Gateway (`tests/cli/test_quick_commands.py`):** - Multi-word arg forwarding (`/echo hello world foo` → output contains all words) - No-args regression (no trailing space added to command) - Shell injection prevention (`; echo INJECTED` treated as literal text, not executed) **CLI (`tests/cli/test_quick_commands.py`):** - Multi-word arg forwarding - No-args regression **TUI Gateway (`tests/test_tui_gateway_server.py`):** - Multi-word arg forwarding (captures the actual command string passed to subprocess) - No-args regression ## Reviewer Feedback Addressed Per @teknium1's review on #62717: - ✅ Added `tui_gateway/server.py` exec path (was missing) - ✅ Added multi-word argument regression test - ✅ Added empty-argument regression test (confirms no regression for zero-arg commands)",
        "url": "https://github.com/NousResearch/hermes-agent/pull/9942",
        "createdAt": "2026-04-15T00:05:02Z",
        "updatedAt": "2026-08-13T15:53:22Z",
        "timestamp": "2026-08-13T15:53:22Z",
        "metrics": {
          "reactions": 0,
          "comments": 7
        },
        "labels": [
          "type/bug",
          "comp/cli",
          "comp/gateway",
          "P2",
          "sweeper:risk-security-boundary",
          "sweeper:risk-compatibility",
          "sweeper:risk-platform-windows",
          "sweeper:blast-contained"
        ],
        "author": "GusBot69",
        "state": "open",
        "assignees": [],
        "change": "new"
      }
    ],
    "events": [
      {
        "id": "event:a473a5deeeaaac7c2a50",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85330",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85330",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cua): make governed capture failures explicit",
          "text": "## What changed - Add an explicit failed capture result with `ok:false`, `success:false`, `status:\"failed\"`, a bounded error code/phase/repair hint, and target-presence metadata. - Preserve that contract through addon pre-tool governance blocks instead of returning a generic error string. - Reject half-pairs and invalid native capture targets explicitly; keep raw app names, IDs, titles, and native detail out of model-facing results. - Document the explicit-app and exact-pair capture contract and add focused backend, hook, and serialization tests. ## Why The previous failure path returned a normal `0x0` capture or a generic blocked-tool error, allowing downstream callers to treat a rejected capture as completed. The producer now has one bounded failure shape for backend and governance rejection paths. ## Validation - Hermes computer-use focused contract tests — 6 passed - Hermes model-tools focused contract tests — 2 passed - `git diff --check` — passed Related: AGC-390, AGC-388, AGC-391, AGC-281. No release, carrier install, or deployment is included.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85330",
          "createdAt": "2026-08-13T13:47:09Z",
          "updatedAt": "2026-08-13T13:47:09Z",
          "timestamp": "2026-08-13T13:47:09Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "leonagente",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:8315d64e0c744e1f0490",
        "signalId": "github:NousResearch/hermes-agent:pull_request:77507",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:77507",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): eliminate sidebar scroll jitter during dynamic sizing",
          "text": "## What does this PR do? Fixes the sessions sidebar jumping/flickering while you scroll a long virtualized list (date dividers plus the virtualizer’s padding fighting native scroll anchoring). That is the only behavior change. Not in this PR: mouse-wheel dead-zones once the list is long (~25+ sessions), where the wheel stops mid-list but dragging the scrollbar still works. That is a separate bug and needs its own issue. ## Related Issue Fixes #77505 The issue as filed is the jump/flicker. This PR is the fix for that. The later wheel-stall comment on the same thread is out of scope and should be tracked separately so it is not lost when this issue closes. ## Type of Change - [x] 🐛 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 - `apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx`: disable native scroll anchoring on the virtual list container so the browser does not fight padding updates. - Same file: estimate divider rows at 26px and session rows at 28px so measured heights do not snap the list. - Same file: set the virtualizer `gap` to 1px to match the existing `gap-px` row layout. ## How to Test 1. Open Hermes Desktop with enough sessions for the sidebar to virtualize (25+), including several date dividers. 2. Scroll up and down past the dividers with the wheel and with arrow keys. 3. The list should no longer jump or flicker as rows are measured. This PR does **not** claim that mid-list wheel stalls are gone. If the wheel stops while scrollbar drag still works, that is out of scope here. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] 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) - [x] I've tested on my platform: desktop app via `npm run dev` ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/77507",
          "createdAt": "2026-08-03T08:42:55Z",
          "updatedAt": "2026-08-13T13:47:06Z",
          "timestamp": "2026-08-13T13:47:06Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "P2",
            "comp/desktop"
          ],
          "author": "StanleyStetson",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c10f5857b38d1115f65d",
        "signalId": "github:NousResearch/hermes-agent:pull_request:80847",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:80847",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(tools): stop browser_cdp frame_id routing from skipping private-page SSRF guard",
          "text": "## What does this PR do? `browser_cdp(..., frame_id=...)` had two private-page / SSRF gaps on the OOPIF path: (1) a non-dict `params` value skipped the clear validation error and could let the guard fail open, and (2) even with valid params, the guard only checked the top-level page URL before supervisor dispatch, so a public parent could embed a private OOPIF and still receive page-content CDP such as `Runtime.evaluate`. This PR closes both paths with shared fail-closed checks before child-session dispatch. ### Symptom A `browser_cdp` call that sets `frame_id` and passes a truthy non-dict `params` (for example a string) does not return `'params' must be an object/dict`. Separately, when the top-level page is public but the selected OOPIF URL/origin is private (or still empty), page-content CDP can still reach the child session. ### Impact Malformed `params` on the `frame_id` path can silently skip the private-page / SSRF boundary that other `browser_cdp` entry points enforce after validating `params`. A public top-level page with a private child OOPIF can also expose private frame content through `frame_id` routing. Blast radius beyond these paths was not measured. ### Bug Cause **Trigger:** `tools/browser_cdp_tool.py` `browser_cdp()` / `_browser_cdp_via_supervisor()` - `frame_id` early-return before `isinstance(params, dict)`; private-page guard uses top-level `_current_page_private_url` only; selected-frame metadata is available but was not checked before child-session dispatch. **Causal chain:** 1. Caller passes `frame_id` with either non-dict `params`, or a well-formed call targeting a private OOPIF under a public top page. 2. Non-dict `params` AttributeErrors inside the guard probe; broad except fail-opens. Or the top-page probe returns public while the child frame is private / metadata-empty. 3. Call reaches `_browser_cdp_via_supervisor` and dispatches into the child CDP session without a validation error or frame-level private-page block. **Why it is wrong:** Params validation lived only on the post-early-return / stateless branch, while the guard is intentionally fail-open for probe failures. Frame routing resolves child URL/origin but never validated them before page-content sinks. **Working sibling / contrast:** Without `frame_id`, non-dict `params` already returned a clear validation error. Top-level private pages already blocked restricted methods; the gap is specifically the selected OOPIF address. **Ruled out:** Not a missing private-page guard call on `frame_id` for well-formed top-level-private cases - that path already invoked the top-page guard. The remaining gaps are malformed params + child-frame address coverage. ### Fix Validate `params` as a dict before the `frame_id` early-return. After resolving the selected frame (frame_tree or raw `_frames` fallback), validate URL/origin for private/internal addresses before dispatch; fail closed when an OOPIF has a `session_id` but empty URL/origin metadata. Retain `_CDP_PRIVATE_PAGE_ALLOWED_METHODS` so navigation/inspection still works on blocked frames. ## Related Issue Fixes #80846 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [x] Security fix ## Changes Made - `tools/browser_cdp_tool.py` - move params dict validation above the `frame_id` early-return; validate selected frame URL/origin (and empty-metadata OOPIF) before supervisor dispatch; keep navigation/inspection allowlist - `tests/tools/test_browser_cdp_tool.py` - regression coverage for non-dict params, public-top/private-child OOPIF, raw `_frames` fallback, allowlist survival, and empty URL/origin fail-closed ## How to Test 1. Manual: with the private-page guard active, call `browser_cdp(method='Runtime.evaluate', params='not-a-dict', frame_id='...')` and confirm the tool returns a params object/dict validation error (not a successful supervisor dispatch). 2. Manual / unit: public top-level page + private OOPIF `frame_id` must block `Runtime.evaluate` / `DOM.getDocument`; allowlisted methods such as `Page.reload` must still dispatch; OOPIF with `session_id` but empty url/origin must block page-content CDP. 3. Automated: ```bash scripts/run_tests.sh tests/tools/test_browser_cdp_tool.py -q ``` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `scripts/run_tests.sh` on relevant tests and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Windows 11 (WSL test runner) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys - N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows - N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) - N/A (validation helpers are platform-agnostic) - [x] I've updated tool descriptions/schemas if I changed tool behavior - N/A (behavior matches the documented params object contract and private-page boundary on sibling paths)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/80847",
          "createdAt": "2026-08-07T06:20:47Z",
          "updatedAt": "2026-08-13T13:46:20Z",
          "timestamp": "2026-08-13T13:46:20Z",
          "metrics": {
            "reactions": 0,
            "comments": 5
          },
          "labels": [
            "type/security",
            "tool/browser",
            "P2",
            "sweeper:risk-security-boundary"
          ],
          "author": "fangliquanflq",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:eb0de7f9bccdca78ebb2",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85329",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85329",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(desktop): add in-app problem reporting",
          "text": "## What does this PR do? Adds a clear **Report a problem on GitHub** action to Desktop Settings → About for users who do not know where or how to file a bug. The action opens an editable GitHub issue draft in the system browser with a small allowlist of diagnostics Desktop already knows: app version, platform, Electron version, and a validated short source revision when available. The user reviews and submits the draft. Hermes does not upload or submit anything automatically. This deliberately uses GitHub's blank issue composer because URL parameters cannot prefill arbitrary fields in the repository's structured issue form. The draft mirrors the essential reporting prompts while keeping the Desktop pathway privacy-minimal. ## Related Issue Fixes #84941 Related: #68735 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - Add a localized Support row to Settings → About. - Open the canonical upstream `/issues/new` composer through the existing `openExternal` bridge. - Build the report URL in a pure helper with a strict diagnostic allowlist. - Omit missing, blank, or malformed diagnostic values. - Add helper and component interaction tests. - Update all Desktop locales. ## How to Test 1. Open Desktop Settings → About. 2. Select **Report on GitHub**. 3. Confirm the system browser opens an editable `[Desktop Bug]:` issue draft. 4. Confirm the draft includes basic Desktop diagnostics but no logs, paths, profiles, connection details, configuration, or transcript content. Automated verification: ```text npm run test:ui # 423 files, 3,786 tests passed npm run typecheck # passed npm run lint -- --quiet # passed npm run build # passed ``` ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains only changes related to this feature - [ ] I've run `pytest tests/ -q` and all tests pass (N/A: Desktop-only TypeScript change; full Desktop gates above passed) - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 26.3.1, arm64 ### Documentation & Housekeeping - [x] Documentation update N/A - [x] `cli-config.yaml.example` update N/A - [x] `CONTRIBUTING.md` / `AGENTS.md` update N/A - [x] Cross-platform impact considered: uses the existing cross-platform `openExternal` bridge and platform value - [x] Tool descriptions/schemas update N/A ## Screenshots / Logs The new About-page row is intentionally small and uses existing `SectionHeading`, `ListRow`, `Button`, and icon primitives. No new native capability or backend endpoint is added.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85329",
          "createdAt": "2026-08-13T13:46:17Z",
          "updatedAt": "2026-08-13T13:46:17Z",
          "timestamp": "2026-08-13T13:46:17Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "ctaylor86",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:9426752ea452bc751b75",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85328",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85328",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(irc): stop recommending Libera.Chat for agentic clients",
          "text": "## What does this PR do? Libera.Chat [forbids LLM-powered agents](https://libera.chat/news/bot-policy-update). Hermes still used `irc.libera.chat` as the example host in IRC setup, plugin env docs, and the user guide, so the recommended configuration walked users into a policy violation. Examples now point at a local IRCd (`127.0.0.1`). Setup text says so explicitly. If someone still configures a Libera hostname, `connect()` and interactive setup warn — they are not hard-blocked, so existing deployments keep working. ## Related Issue Fixes #61181 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `plugins/platforms/irc/adapter.py`: `is_libera_chat_host()`, setup/docs examples, connect warning. - `plugin.yaml`, `hermes_cli/config_defaults.py`, IRC user-guide + env-var docs. - Regression tests for host detection, example strings, and the connect warning. ## How to Test 1. On `origin/main`, plugin.yaml / setup prompt still say `e.g. irc.libera.chat`. 2. `scripts/run_tests.sh tests/plugins/platforms/test_irc_libera_policy.py` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — or N/A - [x] I've updated `cli-config.yaml.example` — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — or N/A - [x] I've considered cross-platform impact — or N/A - [x] I've updated tool descriptions/schemas — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85328",
          "createdAt": "2026-08-13T13:46:12Z",
          "updatedAt": "2026-08-13T13:46:12Z",
          "timestamp": "2026-08-13T13:46:12Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "Christopher-Schulze",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:f442f34e075d2aaed578",
        "signalId": "github:NousResearch/hermes-agent:issue:4667",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:4667",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Feature]: Auto-discover project-local skills from working directory",
          "text": "### Problem or Use Case When working in a project that has skills installed via `npx skills experimental_install` (Vercel Labs' [agent skills CLI](https://github.com/vercel-labs/skills)), those skills land in `.claude/skills/` within the repo. These skills contain project-specific best practices (e.g., Langfuse instrumentation patterns, code review guidelines) that are only relevant when working in that project. Currently, the only way for Hermes to discover these is via `external_dirs` in `~/.hermes/config.yaml` — but that's **global**. Adding a project's `.claude/skills/` directory globally means those skills pollute every other project's context. There's no project-scoped skill discovery. This is a growing ecosystem problem. The [agentskills.io](https://agentskills.io/specification) spec standardized the skill FORMAT (SKILL.md + frontmatter), but each agent uses a different discovery path (`.claude/skills/`, `.codex/skills/`, etc.). The community has identified this fragmentation — see [this Reddit thread](https://www.reddit.com/r/ClaudeCode/comments/1qcigma/agent_skills_the_spec_unified_us_the_paths/): *\"The spec unified us. The paths divided us.\"* Hermes already auto-discovers `.hermes.md` / `HERMES.md` from the project root (#681). The same pattern should work for skills. ### Proposed Solution Auto-discover skills from the working directory (project root) at session start, using progressive disclosure like global skills. Suggested discovery paths, checked in order: 1. `<project_root>/.hermes/skills/` — Hermes-native, project-scoped 2. `<project_root>/.agents/skills/` — Agent-agnostic convention (forward-looking) 3. `<project_root>/.claude/skills/` — Where `npx skills` currently installs (interop with Claude Code, Codex, Cursor, 41+ agents) These would be **read-only** (same as `external_dirs`) — `skill_manage` still writes to `~/.hermes/skills/`. Local skills shadow project skills of the same name, same precedence rules as today. **Behavior:** - On session start, Hermes checks if the cwd (or nearest git root) contains any of the above paths - Discovered skills appear in `skills_list()` alongside global skills, tagged with their source (e.g., `[project]`) - Progressive disclosure unchanged: Level 0 lists them, Level 1 loads full content, Level 2 loads references - When switching projects (different cwd), different project skills are discovered - No config needed — zero setup, just works **Optional enhancement:** Allow `.hermes.md` or a `.hermes/config.yaml` in the project root to specify custom skill paths: ```yaml # .hermes/config.yaml (project-level) skills: local_dirs: - .claude/skills - custom/skills ``` ### Alternatives Considered 1. **Global `external_dirs` in `~/.hermes/config.yaml`** — Works today but pollutes all projects with project-specific skills. Requires manual config per project. 2. **Symlinks** — Fragile, doesn't solve the discovery problem, requires manual setup per project. 3. **Pre-fetching skill content into sub-agent context** — Workaround for `delegate_task`, but loses progressive disclosure benefits and bloats context. ### Feature Type Configuration option ### Scope Medium (<300 lines) ### Additional Context Related issues: - #681 — `.hermes.md` project config (merged, establishes the per-project discovery pattern) - #4381 — `external_dirs` write path configuration (open, related but different — about write path, not auto-discovery) The `npx skills` ecosystem is growing fast (41+ agents supported). Making Hermes auto-discover project skills would close a real interop gap without requiring any changes to the skills CLI itself.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/4667",
          "createdAt": "2026-04-02T23:01:02Z",
          "updatedAt": "2026-08-13T13:45:59Z",
          "timestamp": "2026-08-13T13:45:59Z",
          "metrics": {
            "reactions": 13,
            "comments": 4
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "tool/skills",
            "P3"
          ],
          "author": "leighton-tidwell",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:70aa84286a6f5ef5cece",
        "signalId": "github:NousResearch/hermes-agent:issue:85327",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85327",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Desktop boot fails: /api/ws rejects session token when .env pins HERMES_DASHBOARD_SESSION_TOKEN",
          "text": "## Bug Description Hermes Desktop fails to boot with: ``` [hermes] [boot] could not read served dashboard token (Hermes backend): 404: {\"error\":\"Headless backend (hermes serve): web UI disabled — use `hermes dashboard` for the browser UI.\"} [hermes] [boot] Desktop boot failed: Local Hermes backend is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: WebSocket connection failed. ``` The desktop spawns its own headless backend (`hermes serve`), the child reports HTTP-ready, but the WebSocket auth probe is rejected. This reproduces on every boot attempt (retried 3x, same result). Trigger: any setup where `~/.hermes/.env` contains `HERMES_DASHBOARD_SESSION_TOKEN` (the documented remote-TUI setup in the dashboard docs). Works when the token is absent from `.env`. ## Root Cause Three behaviors collide: 1. **Desktop mints a fresh spawn token.** `apps/desktop/electron/main.ts` spawns the backend with `const token = crypto.randomBytes(32).toString('base64url')` and passes it via `HERMES_DASHBOARD_SESSION_TOKEN` in the child env. 2. **The backend's dotenv loader overrides it.** `hermes_cli/env_loader.py` loads `~/.hermes/.env` with `override=True`, so a token pinned there clobbers the spawn token before `hermes_cli/web_server.py` resolves `_SESSION_TOKEN` at import time. 3. **Headless `serve` hides the drift.** `mount_spa()` deliberately 404s every route when `HERMES_SERVE_HEADLESS=1` (never serves the SPA page that injects `window.__HERMES_SESSION_TOKEN__`). The desktop's token discovery (`adoptServedDashboardToken` in `apps/desktop/electron/dashboard-token.ts`) therefore falls back to its own spawn token, which is now wrong. Net effect: the child's `/api/ws` expects the `.env`-pinned token; the desktop probes with its fresh token; `_ws_auth_ok()` rejects it. The legacy runtime path (`dashboard --no-open`) served the SPA, so the desktop could adopt the served token and the drift was invisible. Any update that moves the desktop onto the `serve` runtime exposes this. ## Steps to Reproduce 1. Set a stable token: `echo \"HERMES_DASHBOARD_SESSION_TOKEN=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')\" >> ~/.hermes/.env` 2. Launch `hermes desktop` (v0.20.0+ with the `serve` runtime). 3. Observe boot failure in `~/.hermes/logs/desktop.log`. Minimal probe without the GUI: ```bash HERMES_DASHBOARD_SESSION_TOKEN=fresh-test-token-abc123 HERMES_DESKTOP=1 \\ hermes serve --host 127.0.0.1 --port 0 > /tmp/serve.out 2>&1 & sleep 8; PORT=$(grep -oP 'port=\\K[0-9]+' /tmp/serve.out | head -1) H=(-H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==') curl -s -o /dev/null -w 'fresh token -> %{http_code}\\n' \"${H[@]}\" \"http://127.0.0.1:$PORT/api/ws?token=fresh-test-token-abc123\" STABLE=$(grep '^HERMES_DASHBOARD_SESSION_TOKEN=' ~/.hermes/.env | cut -d= -f2-) curl -s -o /dev/null -w 'stable token -> %{http_code}\\n' \"${H[@]}\" \"http://127.0.0.1:$PORT/api/ws?token=$STABLE\" ``` Result: `fresh token -> 403`, `stable token -> 101`. ## Expected Behavior Desktop boots when `.env` pins a stable `HERMES_DASHBOARD_SESSION_TOKEN` (or at minimum fails with a message explaining the drift instead of a generic WS rejection). ## Actual Behavior Desktop boot fails on the WS auth probe and never connects. ## Environment - OS: Linux (Pop!_OS 24.04) - Version: v0.20.0 (2026.8.3), commit fa83af3f9a - Desktop: pre-built release at `apps/desktop/release/linux-unpacked` ## Proposed Fix The desktop should prefer an existing `HERMES_DASHBOARD_SESSION_TOKEN` from `$HERMES_HOME/.env` as the spawn token, minting fresh only when nothing is pinned. Since the backend loads `.env` with `override=True`, spawning with the pinned value keeps both sides in agreement; the headless 404 no longer matters because there is no drift to discover. Working patch (verified locally, 6 new unit tests in `apps/desktop/electron/dashboard-token.test.ts`): - `apps/desktop/electron/dashboard-token.ts`: add `resolveSpawnSessionToken(hermesHome)` which reads `HERMES_DASHBOARD_SESSION_TOKEN` from `process.env` first, then from `$HERMES_HOME/.env` (with quote/comment handling), falling back to `crypto.randomBytes(32).toString('base64url')`. - `apps/desktop/electron/main.ts`: both spawn sites (`const token = crypto.randomBytes(32).toString('base64url')`, lines ~8123 and ~8402) call `resolveSpawnSessionToken(HERMES_HOME)` instead. Alternative upstream directions worth considering: - Include the resolved token in the desktop ready-file payload (`HERMES_DESKTOP_READY_FILE`) so the desktop can adopt the child's actual token even headless. - Have `hermes serve` not override a spawn-env token when `HERMES_DESKTOP=1` (rejected in the patch above: the `.env` pin should win; the desktop is the component making the wrong assumption). ## Additional Context Removing `HERMES_DASHBOARD_SESSION_TOKEN` from `.env` also \"fixes\" the symptom but breaks the remote-TUI stable-token setup (the dashboard then rotates its token on every restart, invalidating clients that hardcode it).",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85327",
          "createdAt": "2026-08-13T13:45:47Z",
          "updatedAt": "2026-08-13T13:45:47Z",
          "timestamp": "2026-08-13T13:45:47Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "tommulkins",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:64d628ec24b60ee3107f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85326",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85326",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(providers): add SayGM model provider",
          "text": "## What does this PR do? Adds SayGM through Hermes' `ProviderProfile` architecture so users get safe live model discovery, curated fallbacks, output limits, and the provider-specific Sol request adjustment. Static prices are intentionally omitted because SayGM routing prices can change at runtime. ## Related Issue Fixes #85325 ## Type of Change - [x] ✨ New feature (non-breaking change that adds functionality) ## Changes Made - Adds the SayGM profile and curated Chat Completions fallback models - Uses `https://api.saygm.com/v1` and `SAYGM_API_KEY` - Filters discovery to `available: true` and `api_shapes: chat.completions` - Adds per-model output limits and the SayGM Sol tool-call request adjustment - Adds focused provider/profile tests ## How to Test 1. Set `SAYGM_API_KEY` and select the SayGM provider. 2. Confirm live discovery includes only available Chat Completions models. 3. Run `.venv/bin/python -m pytest tests/hermes_cli/test_saygm_provider.py -q`. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched open and merged PRs for duplicates - [x] My PR contains only SayGM provider changes - [ ] I've run the full suite and all tests pass - [x] I've added tests for my changes - [x] I've tested on macOS ### Documentation & Housekeeping - [x] Provider setup metadata is included; standalone docs are N/A - [x] `cli-config.yaml.example` is N/A (no new config key) - [x] `CONTRIBUTING.md` / `AGENTS.md` are N/A (no architecture change) - [x] Cross-platform impact considered; the profile is platform-independent - [x] Tool descriptions/schemas are N/A ## Test Results Final rebased commit: focused SayGM suite **23 passed**. The canonical full run completed with **29,553 passed, 306 skipped, and 21 failed across 13 files**. None of the failures involve SayGM; they are current environment/platform failures including macOS `/tmp` aliasing, unavailable optional audio runtimes, stale global test configuration, and Linux/WSL process assumptions. This PR remains draft until upstream CI establishes the clean baseline. No screenshot is applicable because this is a CLI provider/profile integration.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85326",
          "createdAt": "2026-08-13T13:45:44Z",
          "updatedAt": "2026-08-13T13:45:44Z",
          "timestamp": "2026-08-13T13:45:44Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "markdavison",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:3f2f87918f1d7b034406",
        "signalId": "github:NousResearch/hermes-agent:issue:85325",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85325",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Feature]: Add SayGM model provider support",
          "text": "### Problem or Use Case Hermes users cannot select SayGM through the provider/profile system today, despite SayGM exposing compatible Chat Completions models and live model discovery. Generic endpoint configuration does not provide curated fallback models, protocol filtering, model output limits, or the required Sol request adjustment. ### Proposed Solution Add a SayGM `ProviderProfile` using `https://api.saygm.com/v1` and `SAYGM_API_KEY`. Discover only available `chat.completions` models, retain a curated fallback list, map output limits, and omit static prices because routing prices can change at runtime. ### Alternatives Considered A generic OpenAI-compatible configuration was considered, but it cannot safely filter the mixed-protocol public catalogue or carry the provider-specific model metadata. A bundled provider profile matches Hermes' existing architecture. ### Feature Type Other: model provider integration ### Scope Medium (few files, < 300 lines) ### Contribution - [x] I'd like to implement this myself and submit a PR",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85325",
          "createdAt": "2026-08-13T13:45:24Z",
          "updatedAt": "2026-08-13T13:45:24Z",
          "timestamp": "2026-08-13T13:45:24Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "markdavison",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:11638b9cd4f1157f4f08",
        "signalId": "github:NousResearch/hermes-agent:issue:83390",
        "event": "changed",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:83390",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Auxiliary title_generation fails on DeepSeek: HTTP 400 \"This response_format type is unavailable now\"",
          "text": "# Auxiliary title_generation fails on DeepSeek: HTTP 400 \"This response_format type is unavailable now\" ## Summary When `auxiliary.title_generation` is configured with `provider: auto` (or explicitly routed to DeepSeek as the main model), the auxiliary title generation task fails with an HTTP 400 error from the DeepSeek API: ``` This response_format type is unavailable now ``` Hermes Agent v0.20.0. Environment: WSL, DeepSeek as primary model provider. ## Root cause 1. `agent/title_generator.py` (lines ~81–93) hardcodes a `json_schema` response format for title generation: ```python _TITLE_RESPONSE_FORMAT = { \"type\": \"json_schema\", \"json_schema\": {...}, ... } ``` 2. At line ~378, `response_format` is unconditionally passed to the underlying chat completions call: ```python extra_body={\"response_format\": ...} ``` 3. The DeepSeek API does **not** support the `json_schema` response format type — it only supports `text` and `json_object`. Sending `{\"type\": \"json_schema\", ...}` returns HTTP 400 `This response_format type is unavailable now`. ## Why the existing fallback logic does not save us - The exception-handling chain in `auxiliary_client.py` only retries/degrades for: HTTP 402 (insufficient balance), transient errors, `temperature` / `max_tokens` parameter issues. - A `400 invalid_request_error` caused by the unsupported `response_format` is **not** handled there, so it propagates straight up and the title-generation task fails outright. - The \"loose JSON scan\" fallback only runs **after** a successful HTTP response; it cannot help when the request itself is rejected with 400. ## Reproduction ```bash curl https://api.deepseek.com/v1/chat/completions \\ -H \"Authorization: Bearer $DEEPSEEK_API_KEY\" \\ -H \"Content-Type: application/json\" \\ -d '{ \"model\": \"deepseek-chat\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}], \"response_format\": { \"type\": \"json_schema\", \"json_schema\": {\"name\": \"x\", \"schema\": {\"type\": \"object\", \"properties\": {}}} } }' ``` Response: `400 Bad Request` — `This response_format type is unavailable now`. ## Expected behavior / suggested fix - Detect (or allow configuration of) providers that do not support `json_schema` and degrade gracefully: - Retry the request **without** `response_format`, or - Fall back to `{\"type\": \"json_object\"}` **and** ensure the prompt contains the literal word `json` (DeepSeek requires the prompt to mention \"json\" for `json_object` mode). - Apply this capability detection/degradation to **all** auxiliary tasks that currently hardcode `json_schema` (title generation, summarization, etc.), not just title generation. - Alternatively, catch `400 invalid_request_error` mentioning `response_format` in `auxiliary_client.py`'s retry chain and retry with a degraded format. ## Environment - Hermes Agent v0.20.0 - Provider: DeepSeek (`deepseek-chat` / `deepseek-reasoner`) - OS: WSL (Windows Subsystem for Linux) Thanks for the great project!",
          "url": "https://github.com/NousResearch/hermes-agent/issues/83390",
          "createdAt": "2026-08-10T18:47:39Z",
          "updatedAt": "2026-08-13T13:45:00Z",
          "timestamp": "2026-08-13T13:45:00Z",
          "metrics": {
            "reactions": 2,
            "comments": 5
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/deepseek",
            "P2"
          ],
          "author": "scottchan60-netizen",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:13c01aacd8c9218f2e73",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85324",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85324",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): wire /learn and /init slash commands to the backend",
          "text": "## Summary Desktop's `isDesktopSlashCommand` gate rejected `/learn` and `/init` because they had no row in `DESKTOP_COMMAND_SPECS`, so they never reached the TUI backend handlers that already build the prompt (`agent/learn_prompt.py::build_learn_prompt` and `hermes_cli/init_command.build_init_prompt_for_cwd`). Add both as `exec()` specs (`argumentMode: text`), matching the existing backend-executed commands. The desktop dispatcher now routes them through `slash.exec` -> `command.dispatch`, which the backend already handles. No client-side logic is duplicated, so the behavior inherits all upstream changes to the backend prompt builders. ## Test plan - Added a routing case asserting both resolve to `exec()` and are suggestible/runnable. - `vitest run src/lib/desktop-slash-commands.test.ts` -> 28 passed. - `tsc --noEmit -p tsconfig.json` clean. ## Notes `/init` had the identical gap (same fall-through path), so it is wired in the same change.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85324",
          "createdAt": "2026-08-13T13:43:04Z",
          "updatedAt": "2026-08-13T13:43:04Z",
          "timestamp": "2026-08-13T13:43:04Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "rarf",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:6e440a4b5e91bdb5ce6e",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84624",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84624",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): strip empty tool_calls on the auxiliary client path",
          "text": "## What does this PR do? Strict OpenAI-compatible providers (DeepSeek v4, **Console Go / opencode.ai zen**) reject an assistant message carrying `tool_calls: []` with HTTP 400 `Invalid 'messages[N].tool_calls': empty array. Expected an array with minimum length 1, but got an empty array instead.` The main loop already strips these pre-send in `sanitize_api_messages` (#58755), but the **auxiliary client path** — `call_llm` / `async_call_llm`, used by the MoA aggregator and reference advisors, compression, vision and title generation — bypassed that chokepoint entirely. A poisoned live-history message (interrupted turn, dangling tool-call state after restart, or a consecutive-assistant merge) 400ed the whole auxiliary call with a **non-retryable** error, wedging the turn. ## Changes - Add `_strip_empty_tool_calls(messages)` in `agent/auxiliary_client.py`, mirroring the main-loop pass semantics exactly: - drop the `tool_calls` key on assistant messages where it is present but not a non-empty list (never write `[]`) - placeholder `(tool call removed)` when content is empty, so the turn is not an empty non-final message (Anthropic-family providers reject those; the auxiliary path has no `repair_empty_non_final_messages` backstop) - request-local copy — caller's list is never mutated - zero-copy fast path when nothing needs stripping (hot MoA reference path) - Wire it into both `call_llm` and `async_call_llm`, right after `_build_call_kwargs`, before any wire-format conversion. - Add `tests/agent/test_auxiliary_empty_tool_calls.py`: 9 unit cases + sync and async end-to-end wiring tests (monkeypatched provider, asserts the wire messages never carry an empty `tool_calls`). ## How to test ```bash PYTHONPATH=. python -m pytest tests/agent/test_auxiliary_empty_tool_calls.py -v ``` ## Related issues Fixes #84169 Note: the related dedup re-introduction gap in the main-loop sanitizer (`sanitize_api_messages` step 9 writing `tool_calls: []` after stripping) is tracked upstream in #74101 / #83203 / #64335 / #76862 / #77921 and is intentionally out of scope here — that pass already has open PRs (#64345, #77377, #82252).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84624",
          "createdAt": "2026-08-12T15:58:13Z",
          "updatedAt": "2026-08-13T13:42:57Z",
          "timestamp": "2026-08-13T13:42:57Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "P2"
          ],
          "author": "falling-streamlet",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:0d2d95ef8de3936a28b9",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85323",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85323",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): stop tool loops across result-hash and arg jitter",
          "text": "## Summary The no-progress detector keyed on `(signature, result_hash)`, so any repeat whose *result* differed reset the streak. Two mechanisms exploited that in live sessions, and a third survived even after signature counting: 1. The context compressor rewrites an older duplicate tool body to `[Duplicate tool output ...]`, which changes the hash. 2. Tools that vary their payload for a repeat call by design (`skill_view` returns `status: \"unchanged\"`) never look like a repeated result. 3. Even after counting identical signatures, a loop could mint a *fresh* signature every iteration by jittering one irrelevant field (`todo` alternating `merge: true`/`false` while re-asserting a byte-identical list). Repeating an identical call is the loop; the shape of the response is not what makes it one. This PR stacks three commits: - `bcb9e0374` — count identical **signatures**, not identical result hashes. Applies to every tool, including mutating bookkeeping tools like `todo`. Membership in `MUTATING_TOOL_NAMES` is not evidence of progress. - `ba0330f1e` — carry no-progress streaks across turn boundaries. `reset_for_turn()` no longer wipes `_no_progress`; streaks clear only when the world actually moves, gated on `file_mutation_result_landed()`. - `c1d9de8f5` — close the remaining two holes: normalize declarative `todo` payloads before hashing (item order + `merge` do not change the asserted end state), and collapse the compressor duplicate stub to a stable sentinel in `_result_hash()`. A landed file mutation still resets its own signature count, so write → re-run → write cycles and post-edit re-verification stay allowed. ## Test plan - [x] `tests/agent/test_tool_guardrails.py` + related runtime/sidecar tests: **82 passed** on `c1d9de8f5` with the repo venv (`~/.hermes/hermes-agent/venv/bin/python`, pytest 9.1.1, Python 3.11.15). - [x] Mutation-kill of `c1d9de8f5`: disabling arg normalization fails 2 tests; disabling the duplicate sentinel fails 1; byte-exact restore returns 12/12 green. - [ ] CI on this PR. Do not merge from review. Draft until the author decides.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85323",
          "createdAt": "2026-08-13T13:42:33Z",
          "updatedAt": "2026-08-13T13:42:33Z",
          "timestamp": "2026-08-13T13:42:33Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "patrykkopycinski",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:9a2714f29236f1f7768c",
        "signalId": "github:NousResearch/hermes-agent:pull_request:64345",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:64345",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(sanitize): drop tool_calls key when dedup removes all calls",
          "text": "## What does this PR do? Fixes an HTTP 400 error from DeepSeek v4 and newer OpenAI providers when `sanitize_api_messages()` produces an empty `tool_calls: []` array after deduplicating all tool_calls in a message. The dedup pass introduced by #58327 removes duplicate `tool_call_id`s to comply with provider requirements. However, when ALL tool_calls in a message are duplicates of earlier messages in a long conversation history, the pass writes `tool_calls: []`. Strict providers reject this with HTTP 400: \"Invalid 'messages[N].tool_calls': empty array.\" This fix drops the `tool_calls` key entirely when all tool_calls are removed by deduplication, treating the empty key as \"no tool calls\" rather than an invalid empty array. ## Related Issue Fixes #64335 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - **agent/agent_runtime_helpers.py** (~line 2638): Modified dedup logic - When `kept_tcs` is non-empty: update `tool_calls` with deduplicated list (preserves existing behavior) - When `kept_tcs` is empty and original message had tool_calls: remove `tool_calls` key (new fix) - When `kept_tcs` is empty and original message had no tool_calls: no change (preserves existing behavior) - **tests/run_agent/test_message_sequence_repair.py**: Added regression test - `test_sanitize_dedup_drops_tool_calls_key_when_all_removed()` validates the fix ## How to Test 1. Run the new regression test: ```bash python -m pytest tests/run_agent/test_message_sequence_repair.py::test_sanitize_dedup_drops_tool_calls_key_when_all_removed -xvs ``` Expected: Test passes, confirming tool_calls key is dropped when dedup removes all calls. 2. Run all sanitize-related tests to ensure no regressions: ```bash python -m pytest tests/run_agent/test_message_sequence_repair.py -k \"sanitize\" -xvs ``` Expected: All 8 tests pass. 3. Verify the fix logic manually: ```python from agent.agent_runtime_helpers import sanitize_api_messages messages = [ {\"role\": \"assistant\", \"content\": \"first\", \"tool_calls\": [{\"id\": \"A\", \"type\": \"function\", \"function\": {\"name\": \"foo\"}}]}, {\"role\": \"tool\", \"tool_call_id\": \"A\", \"content\": \"result\"}, # Later message with duplicate tool_call_id {\"role\": \"assistant\", \"content\": \"retry\", \"tool_calls\": [{\"id\": \"A\", \"type\": \"function\", \"function\": {\"name\": \"foo\"}}]}, ] out = sanitize_api_messages(messages) # First assistant should have tool_calls (first occurrence kept) # Second assistant should have NO tool_calls key (all were duplicates) ``` Expected: Second assistant message has no `tool_calls` key, content preserved as \"retry\". ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS (development environment) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/64345",
          "createdAt": "2026-07-14T10:54:01Z",
          "updatedAt": "2026-08-13T13:41:31Z",
          "timestamp": "2026-08-13T13:41:31Z",
          "metrics": {
            "reactions": 1,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/deepseek",
            "P2",
            "sweeper:risk-session-state",
            "sweeper:blast-broad"
          ],
          "author": "liuhao1024",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:84ad11fbbd9c74bdef8d",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85222",
        "event": "changed",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85222",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(desktop): add 'Add as context' right-click to message blocks and file explorer",
          "text": "## What does this PR do? Adds right-click \"Add as context\" to both message blocks (selected text) and file explorer rows, staging content as composer attachment chips that ride along with the next prompt. **Two surfaces, one architecture:** | Surface | Gesture | Result | |---|---|---| | Message thread (user + assistant) | Select text → right-click → \"Add as context\" | Text chip staged in composer strip, serialized as `> Referenced from earlier...` quoted block on submit | | Message thread (user + assistant) | Select text → right-click → \"Paste as text\" | Quoted text inserted directly into composer input | | File explorer (browser + review/git trees) | Right-click file/folder → \"Add as context\" | File/folder chip staged in composer strip (same code path as shift+click) | Both features converge on the existing `ComposerAttachment` system. A new `'text'` attachment kind carries selected message text; the existing `'file'`/`'folder'` kinds are reused for file explorer right-clicks. Nothing is sent until the user submits — attachments just stage in the composer strip, and multiple items can accumulate before sending. ## Related Issues - Closes #73138 (Feature Request: Quote-to-reply — right-click context menu on selected text) - Closes #69741 (Files panel — no quick-to-add-input shortcut) - Related: #73658, #70550 (prior art using direct-insertion approach; this PR uses chip-staging instead) ## Type of Change - [x] ✨ New feature ## Changes Made | File | Change | |---|---| | `apps/desktop/src/store/composer.ts` | Add `'text'` kind to `ComposerAttachment` union, `textContent`/`sourceMessageId` fields, `addComposerTextAttachment()` helper | | `apps/desktop/src/components/assistant-ui/thread/message-context-menu.tsx` | **NEW** — shared `MessageContextMenu` component (Radix ContextMenu) with \"Add as context\" and \"Paste as text\" | | `apps/desktop/src/components/assistant-ui/thread/user-message.tsx` | Wrap bubble content in `MessageContextMenu` | | `apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx` | Wrap content area in `MessageContextMenu` | | `apps/desktop/src/app/chat/composer/attachments.tsx` | Handle `'text'` kind in `AttachmentPill` with `MessageSquareText` icon | | `apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts` | Serialize text attachments as quoted blocks prepended to message text | | `apps/desktop/src/app/right-sidebar/file-actions.tsx` | Add `attachFileAsContext()` helper and \"Add as context\" menu item to `FileEntryContextMenu` | | `apps/desktop/src/store/composer.test.ts` | Tests for text attachment kind | | `apps/desktop/src/components/assistant-ui/thread/message-context-menu.test.tsx` | **NEW** — tests for context menu rendering | ## How to Test 1. Start the desktop app in dev mode: `cd apps/desktop && npm run dev` 2. Start a conversation and get a response 3. **Message text → chip:** Select text in any message → right-click → \"Add as context\" → verify a text chip appears in the composer attachment strip → type a prompt and submit → verify the selected text appears as a `> Referenced from earlier...` quoted block 4. **Message text → paste:** Select text → right-click → \"Paste as text\" → verify quoted text appears directly in the composer input 5. **File explorer:** Open the file explorer pane → right-click any file or folder → \"Add as context\" → verify a file/folder chip appears in the composer → submit → verify the `@file:` reference is in the message 6. **Multi-item staging:** Stage a text chip, a file chip, and type a prompt → submit → verify all three serialize together ## Screenshots ### 1. Add as Context → Add Text Chip Right-clicking selected text in a message and choosing \"Add as context\" stages a text chip in the composer attachment strip. On submit, the text is serialized as a `> Referenced from earlier...` quoted block. ![Add text chip](https://raw.githubusercontent.com/kumamaster-web/hermes-agent/feat/desktop-add-as-context/apps/desktop/screenshots/add-text-chip.png) ### 2. Add as Context → Paste as Text Right-clicking selected text and choosing \"Paste as text\" inserts the quoted text directly into the composer input at the cursor position. ![Paste as text](https://raw.githubusercontent.com/kumamaster-web/hermes-agent/feat/desktop-add-as-context/apps/desktop/screenshots/paste-as-text.png) ### 3. Add as Context → Add File Chip Right-clicking a file or folder in the file explorer and choosing \"Add as context\" stages a file chip in the composer attachment strip. ![Add file chip](https://raw.githubusercontent.com/kumamaster-web/hermes-agent/feat/desktop-add-as-context/apps/desktop/screenshots/add-file-chip.png) ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains only changes related to this feature - [x] I've run `npx tsc --noEmit` and all checks pass - [x] I've added tests for my changes (6 tests, all passing) - [x] I've tested on my platform: Windows 10 ### Documentation & Housekeeping - [x] N/A — no config keys, tool schemas, or architecture changes - [x] N/A — cross-platform: TypeScript/React only, no native modules or shell commands",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85222",
          "createdAt": "2026-08-13T10:27:25Z",
          "updatedAt": "2026-08-13T13:40:19Z",
          "timestamp": "2026-08-13T13:40:19Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "P3",
            "comp/desktop"
          ],
          "author": "kumamaster-web",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:019d94b6491fe91929d1",
        "signalId": "github:NousResearch/hermes-agent:pull_request:77592",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:77592",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(security): prevent multiplex dotenv credential leakage",
          "text": "## What does this PR do? Prevents turn-scoped `load_hermes_dotenv()` calls from copying a routed profile's credentials into process-global `os.environ` while a multiplex profile scope is active. The root cause was that `load_hermes_dotenv()` always reached `_load_dotenv_with_fallback(..., override=True)`, even when the active Hermes home came from a routed multiplex profile. The gateway helper guarded one reload call site, but lazy imports, cron, and other callers could invoke the loader directly and bypass that guard. The guard now lives at the shared loader boundary and requires both: - multiplex mode is active; and - a routed profile-home override is installed. This keeps unscoped gateway startup loading unchanged. Inside a routed profile scope, the loader refreshes external secret providers through `hydrate_profile_secret_sources()`, which writes to the existing profile-private snapshot, and returns without mutating the shared process environment. ## Related Issue Fixes #77562 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [x] 🔒 Security fix - [ ] 📝 Documentation update - [ ] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `hermes_cli/env_loader.py`: skip process-global dotenv mutation only during an active routed multiplex profile scope, while retaining profile-private external-secret hydration. - `tests/gateway/test_multiplex_credential_isolation.py`: prove two routed profiles retain distinct credentials and channel allowlists while the process-global allowlist remains unchanged. - `tests/test_env_loader_secret_sources.py`: prove unscoped multiplex startup still loads `.env`, and routed profile loading still hydrates Bitwarden-backed credentials without exporting bootstrap/provider secrets globally. ## Review Follow-up This revision addresses the startup-order concern raised by @DonShelly and @egilewski: - the early return is now restricted by `get_hermes_home_override() is not None`; - multiplex startup without a routed profile scope still loads `DISCORD_ALLOWED_CHANNELS`; - the profile-private secret-source hydration path remains covered; - the field scenario from #77970 is covered by distinct profile A/B `DISCORD_ALLOWED_CHANNELS` values plus an unchanged process-global value. The routed-scope discriminator follows the boundary identified by @DonShelly in #77970, while this PR keeps its existing external secret-provider hydration behavior and coverage. ## How to Test 1. RED proof before narrowing the production guard: `scripts/run_tests.sh tests/test_env_loader_secret_sources.py -k multiplex_without_profile_scope_still_loads -q` failed because `DISCORD_ALLOWED_CHANNELS` remained unset instead of loading `123,456`. 2. Targeted regression files: `scripts/run_tests.sh tests/gateway/test_multiplex_credential_isolation.py tests/test_env_loader_secret_sources.py -q` → **25 passed**. 3. Broader affected-surface suite across env loader, secret scope, gateway, cron, and runtime profile isolation: → **91 passed** across 9 files. 4. Ruff, `git diff --check`, and `scripts/check-windows-footguns.py` on the three changed files: → all passed. 5. Repository-standard full suite: `scripts/run_tests.sh -j 16` → **30,678 passed, 22 failed, 264 skipped** across 2,816 files in 1,313.8s. 6. Re-ran all 16 files implicated by the full run with identical `-j 4` parameters on this branch and a clean `upstream/main` worktree at `762610538`: → both produced **698 passed, 21 failed, 6 skipped**, with the same failing tests and the same separate import-error file. The extra FIFO timing failure seen only in the 16-worker full run passed in both four-worker comparisons. GitHub `main` advanced after that full comparison. The final commit was rebased onto `fe5e7799f`; none of the 20 intervening commits touched this PR's three files, and the 91-test affected-surface suite passed again after the rebase. The full-suite checkbox remains unchecked because the repository-wide suite has existing macOS/environment failures. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26.5.1, Python 3.11.15. Linux and Windows were not tested directly. ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A (function behavior documented in its docstring) - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — pure Python control flow; Windows footgun scan passed - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## Screenshots / Logs No screenshot is applicable because this is a process-level credential-isolation fix with no UI change. ```text Targeted regression files: 25 passed, 0 failed Broader affected-surface suite: 91 passed, 0 failed Ruff: All checks passed Windows footgun scan: No issues found git diff --check: passed Full repository suite (-j 16): 2,816 files 30,678 passed 22 failed 264 skipped 1,313.8 seconds Failed-file comparison (-j 4, same 16 files): fix branch: 698 passed, 21 failed, 6 skipped upstream/main: 698 passed, 21 failed, 6 skipped ```",
          "url": "https://github.com/NousResearch/hermes-agent/pull/77592",
          "createdAt": "2026-08-03T11:10:51Z",
          "updatedAt": "2026-08-13T13:39:57Z",
          "timestamp": "2026-08-13T13:39:57Z",
          "metrics": {
            "reactions": 0,
            "comments": 6
          },
          "labels": [
            "type/security",
            "comp/cli",
            "comp/gateway",
            "area/auth",
            "P2",
            "sweeper:risk-security-boundary",
            "sweeper:risk-compatibility",
            "area/profiles"
          ],
          "author": "lesterlxt",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ce49baf6f6f98e57a90e",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85314",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85314",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(plugins): keep lifecycle hooks aligned across session rotation surfaces",
          "text": "## What does this PR do? This fixes lifecycle plugin hooks that received different session transition and workspace payloads depending on whether `/new` ran through the CLI, TUI, or messaging gateway. Without this fix, plugins cannot reliably release old-session resources, initialize the replacement session, or associate model turns with the authoritative task workspace. ### Symptom An explicit `/new` produced incomplete or inconsistent `on_session_finalize` and `on_session_reset` keyword arguments across surfaces. `pre_llm_call` also omitted the authoritative task/session workspace, while some lifecycle paths could substitute process state for an unproven session cwd. ### Impact Native plugins that coordinate per-session state cannot distinguish the old and new session consistently across CLI, TUI, and Gateway. Workspace-aware plugins can attribute lifecycle events or model turns to the wrong directory, and TUI prewarming could emit reset before the old session was finalized. ### Bug Cause **Trigger:** `cli.py::_start_new_session`, `ui-tui/src/app/useSessionLifecycle.ts::startNewSession`, `gateway/slash_commands.py::_handle_new_session`, and `agent/turn_context.py::build_turn_context` **Causal chain:** 1. A user starts a new session or a model turn from a surface with an authoritative session/task workspace. 2. Each surface constructs plugin hook payloads independently, omitting transition IDs or cwd on some paths. The TUI also created the replacement session before associating it atomically with teardown of the old session. 3. Plugins observe surface-dependent payloads, and TUI plugins can observe reset before finalize. **Why it is wrong:** Lifecycle payloads describe one semantic session transition, so their identifiers, reason, cwd, and ordering must not depend on the client surface. A process cwd is not evidence of a session workspace. **Working sibling / contrast:** Gateway already supplied old and new session IDs for explicit rotation, but it lacked authoritative cwd. CLI, TUI, and pre-LLM paths each supplied different subsets of the same context. **Ruled out:** This is not a plugin callback compatibility issue. Hook dispatch already filters additive keyword fields for callbacks with narrow signatures; the failure was at the surface-specific payload construction and TUI transition ordering. ### Fix - Add a shared authoritative session cwd resolver that uses recorded task/session state and returns an empty value when no cwd is proven. - Add consistent `old_session_id`, `new_session_id`, `reason`, and `cwd` fields to explicit session rotation hooks across CLI, TUI, and Gateway. - Include authoritative cwd in `pre_llm_call` payloads. - Associate TUI rotations atomically and defer the replacement-session reset until old-session finalization, without fabricating transitions for ordinary startup, resume, or shutdown. ## Related Issue Closes #85262 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `agent/runtime_cwd.py` and `agent/turn_context.py` - resolve and expose authoritative session/task cwd without a process-cwd fallback. - `cli.py` - emit complete explicit session transition payloads. - `gateway/run.py` and `gateway/slash_commands.py` - propagate current session identity and authoritative cwd to model-turn and rotation hooks. - `tui_gateway/methods_session.py`, `tui_gateway/server.py`, `ui-tui/src/app/useSessionLifecycle.ts`, and `ui-tui/src/gatewayTypes.ts` - preserve old-session workspace context and enforce finalize-before-reset ordering. - `tests/` and `ui-tui/src/__tests__/useSessionLifecycle.test.ts` - cover payload parity, unknown cwd behavior, rotation ordering, and gateway/TUI races. ## How to Test 1. Register a native plugin that records `pre_llm_call`, `on_session_finalize`, and `on_session_reset` keyword arguments. 2. Start a session in an explicit workspace through CLI, TUI, or Gateway, send one turn, and run `/new`. 3. Confirm finalize precedes reset, both lifecycle hooks carry identical transition IDs, reason, and authoritative cwd, and ordinary startup does not invent a transition. 4. Run the related automated suites: ```bash scripts/run_tests.sh tests/agent/test_runtime_cwd.py tests/agent/test_api_content_sidecar.py tests/cli/test_session_boundary_hooks.py tests/test_tui_gateway_server.py tests/gateway/test_35994_reset_button_deadlock.py cd ui-tui && npm test -- --run src/__tests__/useSessionLifecycle.test.ts cd ui-tui && npm run typecheck ``` The Python suite passed 597 tests, the targeted TUI suite passed 7 tests, TypeScript typecheck passed, and the lifecycle contract was verified with a real native plugin across CLI, TUI, Gateway, and pre-LLM paths on Windows 11. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run the repository test entry on the relevant suites and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Windows 11 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - N/A, no user-facing configuration or documented API changed - [x] I've updated `cli-config.yaml.example` if I added/changed config keys - N/A, no config keys changed - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows - N/A, the existing additive plugin hook contract is unchanged - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) - [x] I've updated tool descriptions/schemas if I changed tool behavior - N/A, no model tool behavior changed ## Screenshots / Logs Not applicable. Verification results are listed in How to Test.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85314",
          "createdAt": "2026-08-13T13:21:13Z",
          "updatedAt": "2026-08-13T13:39:50Z",
          "timestamp": "2026-08-13T13:39:50Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "comp/cli",
            "comp/gateway",
            "comp/tui",
            "comp/plugins",
            "P3",
            "sweeper:risk-session-state",
            "area/sessions"
          ],
          "author": "fangliquanflq",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:d2a130cf9b214690e8b8",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84926",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84926",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(whatsapp): observe authorized group context before response",
          "text": "## Summary - optionally retain authorized unmentioned WhatsApp group traffic in the shared transcript - invoke the agent only when trusted native WhatsApp mention metadata targets the bot - bypass plain-text mention heuristics, wake-word regexes, slash commands, quoted replies, and free-response exemptions in observation mode - check authorization and group allowlists before persistence - retain sender attribution, cached attachment paths, and native mention metadata ## Test plan - `pytest tests/gateway/test_session.py tests/gateway/test_config.py tests/gateway/test_whatsapp_group_gating.py tests/gateway/test_whatsapp_formatting.py tests/gateway/test_whatsapp_text_batching.py tests/gateway/test_whatsapp_cloud.py tests/gateway/test_75349_whatsapp_multiplex_secret_scope.py -q` - `ruff check gateway/config.py gateway/run.py gateway/session.py gateway/platforms/whatsapp_common.py plugins/platforms/whatsapp/adapter.py tests/gateway/test_config.py tests/gateway/test_session.py tests/gateway/test_whatsapp_group_gating.py` ## Dependency Stacked on #84925. Review and merge #84925 first; this PR will be rebased onto `main` after it merges.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84926",
          "createdAt": "2026-08-13T00:54:03Z",
          "updatedAt": "2026-08-13T13:39:45Z",
          "timestamp": "2026-08-13T13:39:45Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/gateway",
            "comp/plugins",
            "platform/whatsapp",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility"
          ],
          "author": "Diaspar4u",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:4bd114cdc5535bf4490e",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85316",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85316",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(title): honor reasoning_effort so thinking can be disabled on title generation",
          "text": "## Summary Closes part of #83390 (the pre-existing DeepSeek thinking-leak on `title_generation`). The `title_generator.py` docstring claimed thinking was disabled, but `generate_title()` set neither `reasoning_config` nor `reasoning_effort`. On reasoning-aware providers whose default is thinking ON (DeepSeek V4 family, including via OpenCode Go), the model burns the 64-token title budget on `reasoning_content` and returns an empty `content`. This PR reuses Hermes' existing provider-agnostic `auxiliary.title_generation.reasoning_effort` knob instead of hardcoding a provider-specific wire field: - When `auxiliary.title_generation.reasoning_effort: none` (or a disabled synonym) is configured, `generate_title()` now passes `reasoning_config={\"enabled\": False}`. - Reasoning-aware provider profiles (`DeepSeekProfile`, `OpenCodeGoProfile`) translate `reasoning_config={\"enabled\": False}` into native wire controls (`thinking.type: disabled`). - When unset, the provider default stands (thinking ON for DeepSeek V4). Providers without reasoning controls are untouched. ## Why provider-agnostic instead of hardcoding `thinking`? PR #83725 proposes unconditionally setting `extra_body.thinking = {\"type\": \"disabled\"}` on the title retry path. That works for DeepSeek, but hardcodes a vendor wire parameter on a generic auxiliary path — non-DeepSeek OpenAI-compatible upstreams that reject unrecognised `extra_body` fields will HTTP 400. Routing through `reasoning_config` keeps the field provider-agnostic: each `ProviderProfile.build_api_kwargs_extras()` decides how to translate `reasoning_config` into its vendor's wire shape. ## Verification 1. **Unit tests**: added 2 unit tests covering `reasoning_effort: none` → `reasoning_config={\"enabled\": False}` mapping and the unset fallback (`tests/agent/test_title_generator.py`). Full suite green (35 passed). 2. **Wire inspection**: verified `_build_call_kwargs` with `reasoning_effort: none` emits `thinking: {\"type\": \"disabled\"}` for `opencode-go` / `deepseek-v4-flash`. 3. **Live generation**: verified against `deepseek-v4-flash` via OpenCode Go — title generation returns clean JSON titles (`{\"title\": \"...\"}`) without `finish_reason: length` or empty `content`. Pairs with #83725 / #84767 (which handle the `json_schema` 400 rejection); this PR handles the empty-content thinking leak.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85316",
          "createdAt": "2026-08-13T13:25:23Z",
          "updatedAt": "2026-08-13T13:39:44Z",
          "timestamp": "2026-08-13T13:39:44Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/deepseek",
            "P3"
          ],
          "author": "Jeffgithub0029",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:135496704b1999370935",
        "signalId": "github:NousResearch/hermes-agent:pull_request:83314",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:83314",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(skills): add configurable skill index injection",
          "text": "## Summary - add `skills.inject_index` (default `true`) to control `<available_skills>` injection - add `--no-skills-index` for classic CLI, TUI, and `-z` one-shot runs - keep skill tools available when the prompt index is omitted ## Why Large skill catalogues can consume substantial prompt space even when a caller provides another discovery surface. This follows the focused config direction suggested in #26815 and supplies the per-invocation mechanism requested by #26806 without adding an environment-backed user setting or a general prompt mutation hook. The value is resolved once when the agent is constructed, so the system prompt remains stable for the conversation lifetime. Closes #26806. ## Validation - `14 passed` across the focused system-prompt, config, parser, classic CLI, one-shot, and TUI propagation tests - Ruff checks passed for all changed Python files - `git diff --check` passed",
          "url": "https://github.com/NousResearch/hermes-agent/pull/83314",
          "createdAt": "2026-08-10T16:07:08Z",
          "updatedAt": "2026-08-13T13:39:36Z",
          "timestamp": "2026-08-13T13:39:36Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/cli",
            "comp/tui",
            "tool/skills",
            "area/config",
            "P3",
            "sweeper:risk-compatibility"
          ],
          "author": "Vocllum",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e7d70db4ef81bef80131",
        "signalId": "github:NousResearch/hermes-agent:pull_request:83316",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:83316",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(skills): emit lifecycle events for removals",
          "text": "## Summary - emit `deleted` after a successful hard delete through `skill_manage` - emit `uninstalled` after a successful Skills Hub uninstall - preserve provenance before removal and keep curator archives on their existing `archived` action - document the complete observer scope and its filesystem boundary ## Why The merged `on_skill_lifecycle` observer from #68883 reports skill creation, mutation, loading, and curator transitions, but successful removal paths were silent. Plugins that maintain an external index therefore cannot invalidate a removed skill when Hermes performs the operation. This extends the existing observer rather than adding another hook. Emission happens only after the authoritative filesystem operation succeeds. Usage-sidecar cleanup remains best-effort because its failure cannot undo a deletion that already occurred. ## Scope - no filesystem watcher or scan loop - no promise to detect edits made by Git, editors, sync clients, or other processes - no per-command plugin-discovery special case; cross-entrypoint hook delivery remains the centralized concern tracked in #64178 - no new pre-operation or mutating hook contract ## Validation - `32 passed` across skill usage, hard-delete, archive, and Hub uninstall tests - Ruff checks passed for all changed Python files - `git diff --check` passed",
          "url": "https://github.com/NousResearch/hermes-agent/pull/83316",
          "createdAt": "2026-08-10T16:08:19Z",
          "updatedAt": "2026-08-13T13:39:32Z",
          "timestamp": "2026-08-13T13:39:32Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "tool/skills",
            "P3"
          ],
          "author": "Vocllum",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e0595cfcd795c1626fbd",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85317",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85317",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(mcp): skip startup discovery for disabled servers",
          "text": "## Summary - Treat disabled native MCP server entries as inactive configuration. - Prevent startup discovery from opening when all configured servers are disabled. - Preserve agent-plugin MCP detection. ## Test plan - `uv run --with pytest python -m pytest -q tests/hermes_cli/test_mcp_startup.py` - Result: 4 passed, 1 warning. The change is based directly on the current upstream `main`.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85317",
          "createdAt": "2026-08-13T13:28:19Z",
          "updatedAt": "2026-08-13T13:39:27Z",
          "timestamp": "2026-08-13T13:39:27Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "tool/mcp",
            "area/config",
            "P3",
            "sweeper:risk-compatibility"
          ],
          "author": "Wahrheitssuchender",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:be8f46cc5132dda7e283",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84605",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84605",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): inherit auxiliary provider transport",
          "text": "## Summary - resolve auxiliary vision transport from the selected provider profile instead of silently falling back to OpenAI chat completions - preserve explicit auxiliary overrides while inheriting provider-level api_mode for custom and built-in providers - add focused regression coverage for Codex Responses, explicit overrides, unknown transports, and main-model isolation ## Validation - canonical focused runner: 20 tests passed - `scripts/check-windows-footguns.py --diff upstream/main`: PASS - full CI-like suite used the repository exact locked Python 3.11 extras - patch run and clean upstream/main baseline reproduced the same 14 stable failures across 10 unrelated platform/environment files; regression delta: 0 - one additional picker prewarm failure from the full patch run passed on focused rerun (3/3) and was not reproducible in the baseline comparison No installed Hermes runtime, live config, credentials, or gateway process was changed.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84605",
          "createdAt": "2026-08-12T14:59:01Z",
          "updatedAt": "2026-08-13T13:39:21Z",
          "timestamp": "2026-08-13T13:39:21Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/openai",
            "P3"
          ],
          "author": "Const-Iv",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:38b225746a6558dbe9b6",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85318",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85318",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(webhook): bind signatures to explicit provider schemes (Webhook Revolution)",
          "text": "Part of the [Webhook Revolution campaign](https://github.com/NousResearch/hermes-agent/issues/84834). Task 9. Closes #47451, #80327. ## Explicit, mode-bound signature verification Replaces header-driven scheme inference in `gateway/platforms/webhook_auth.py` with a verifier registry keyed by `signature_mode`: `github`, `gitlab`, `svix`, `generic_v2`, `generic_v1`. A route's configured mode decides the scheme; a route never infers a weaker scheme from attacker-controlled headers. `_validate_signature` gains a `signature_mode` param (default `generic_v2`), wired from the route config in `webhook.py`. ## Replay-safe - generic_v2 / svix reject stale, malformed, missing, and downgrade-attempt timestamps (bounded tolerance, default 300s). - A V2 request missing its timestamp is rejected — never downgraded to V1. - generic_v1 stays explicit compatibility mode with a deprecation warning; never an automatic fallback from V2. - Constant-time comparison throughout. ## Verification (67 passed) - `tests/gateway/test_webhook_signatures.py` — 14 new attack tests (wrong provider header, mixed V1/V2 downgrade, stripped/expired/future timestamp, malformed/non-ASCII sig, rotated signature, changed body, missing header, unknown mode). - `test_webhook_auth_seam.py` + `test_webhook_adapter.py` + `test_webhook_signature_rate_limit.py` + `test_webhook_integration.py` + `test_webhook_dynamic_routes.py` + `test_webhook_deliver_only.py` — no behavior regression (tests updated to set explicit signature_mode). - `git diff --check` clean; `webhook_auth.py` 213 lines, `webhook.py` 1173 lines (both < 2000).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85318",
          "createdAt": "2026-08-13T13:30:27Z",
          "updatedAt": "2026-08-13T13:39:16Z",
          "timestamp": "2026-08-13T13:39:16Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/security",
            "comp/cli",
            "comp/gateway",
            "platform/webhook",
            "area/auth",
            "P2",
            "needs-repro",
            "sweeper:risk-message-delivery",
            "sweeper:risk-security-boundary"
          ],
          "author": "andrexibiza",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ad6f28a2535bbbcc9e3a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85319",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85319",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(compress): bound offline dataset pipeline RAM",
          "text": "## What does this PR do? The offline dataset pipeline loaded every trajectory into RAM, then created one coroutine per row and `asyncio.gather(*tasks)`. The semaphore only capped in-flight API calls — not task/closure count or retained results — so large JSONL dumps OOM'd. Sampling now streams HuggingFace rows (streaming load when the hub supports it) and reservoir-samples qualifying trajectories instead of `random.sample(all_filtered)`. Directory compression processes each file in batches of `max_concurrent_requests`, writes incrementally to a temp file, and atomically replaces the output. Timeouts are still omitted; unexpected errors still keep the original row. ## Related Issue Fixes #84703 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `scripts/sample_and_compress.py`: `iter_dataset_entries` + Algorithm-R `reservoir_add`; merge writes through a temp file instead of a full in-memory list. - `trajectory_compressor.py`: `_process_directory_async` streams JSONL and `gather`s at most `max_concurrent_requests` awaitables. - Tests: gather-size cap, timeout/error output contract, reservoir cap, no `random.sample` on the full qualifying pool. ## How to Test 1. On `origin/main`, `test_process_directory_does_not_gather_one_task_per_entry` fails (`gather` of 12 vs cap 3) and `test_sample_from_datasets_does_not_sample_from_full_materialized_pool` fails (`random.sample` on 40). 2. `scripts/run_tests.sh tests/test_trajectory_compressor_async.py tests/test_sample_and_compress_ram.py` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — or N/A - [x] I've updated `cli-config.yaml.example` — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — or N/A - [x] I've considered cross-platform impact — or N/A - [x] I've updated tool descriptions/schemas — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85319",
          "createdAt": "2026-08-13T13:30:47Z",
          "updatedAt": "2026-08-13T13:39:12Z",
          "timestamp": "2026-08-13T13:39:12Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/perf",
            "comp/tools",
            "P3"
          ],
          "author": "Christopher-Schulze",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:607cdd2901e272e3caa3",
        "signalId": "github:NousResearch/hermes-agent:issue:85315",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85315",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug] auxiliary.free_only gate rejects explicitly-requested :free models and misreports the skip as a payment/credential error",
          "text": "## Title [Bug] `auxiliary.free_only` gate rejects explicitly-requested `:free` models and misreports the skip as a payment/credential error ## Summary When an auxiliary task resolves an OpenRouter client via `resolve_provider_client(provider=\"openrouter\")` with `auxiliary.free_only: true` and the caller passes an **explicit** `:free` model, the `model` argument is dropped and the gate evaluates the configured `auxiliary.openrouter_model` default (a **paid** SKU) instead. Every explicit `:free` request is silently rejected, OpenRouter is marked unhealthy as a bogus \"payment / credit error\", and the diagnostic blames missing/expired credentials that are actually present and valid. ## Root cause In `agent/auxiliary_client.py`, `resolve_provider_client`'s OpenRouter branch calls: ```python client, default = _try_openrouter(explicit_api_key=explicit_api_key) ``` dropping `model`. `_try_openrouter` computes `or_model = model or cfg_model`, so it falls back to `cfg_model` (from `_aux_openrouter_settings`, i.e. `auxiliary.openrouter_model` — the paid default). The gate then rejects any caller that explicitly asked for a `:free` model: ```python if free_only and not _is_free_model(or_model): ... _mark_provider_unhealthy(\"openrouter\", ttl=60) # logged as \"payment / credit error\" return None, None ``` `_describe_openrouter_unavailable()` also takes no `model` and cannot name the gate, so the failure is blamed on credentials. ## Impact With `auxiliary.free_only: true`, any explicitly-configured `:free` auxiliary model (e.g. `auxiliary.title_generation.model`, or any task in the auto-chain) is rejected, and OpenRouter is spuriously marked unhealthy for 60s — the exact free, reliable traffic the option is meant to guarantee. Confusing for users: the log hunts for a missing/expired API key that is present and funded. ## Proposed fix (patch attached) 1. Forward the caller's `model` into `_try_openrouter` from `resolve_provider_client`. 2. Give `_mark_provider_unhealthy`/`_describe_openrouter_unavailable` a precise `reason` so a config-gate skip is NOT reported as a payment/credit error. Regression tests: `TestFreeOnlyGateHonoursCallerModel` (4 tests) — all pass. ## Related Distinct from #75803 / #75838 / #75819 (those fix the hardcoded **paid default** model & paid-lane warning). This is the **gate-resolution** bug: the gate judges the wrong model because the request's model is dropped at the call site.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85315",
          "createdAt": "2026-08-13T13:22:47Z",
          "updatedAt": "2026-08-13T13:39:07Z",
          "timestamp": "2026-08-13T13:39:07Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/openrouter",
            "area/config",
            "P2",
            "sweeper:risk-compatibility",
            "area/billing"
          ],
          "author": "JulesLscx",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:7ce631a3dc85420f2e14",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84622",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84622",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(tts): split long-form text on CJK punctuation",
          "text": "## Summary Long-form TTS chunking now respects **CJK punctuation** as sentence boundaries. Chinese/Japanese/Korean text has no spaces, so the old logic (`re.split(r\"(?<=[.!?;:,])\\s+\", ...)`) treated an entire paragraph as one \"word\" and hard-split it at arbitrary characters — e.g. `发现三个隐|患点` — producing unnatural mid-word breaks in spoken output. Latin punctuation behavior is unchanged (still breaks only after whitespace), so `3.14` and `Dr.` are never split mid-token. ## Background The `text_to_speech` tool splits long text into provider-safe chunks before synthesis (`_split_text_for_tts`). The chunking pipeline was built for space-delimited languages: sentence detection splits on `.!?;:,` **followed by whitespace**, and `_split_oversized_sentence` splits on word boundaries. CJK scripts don't use spaces, so: 1. Sentence detection never fires for pure-CJK text → the whole paragraph becomes a single \"sentence\" 2. `sentence.split()` on space-free CJK returns the paragraph as one giant word 3. `_split_oversized_sentence` hard-splits that word at `max_chars` boundaries — **mid-word, mid-sentence** Symptom: Chinese users get TTS chunks like `发现三个隐` / `患点。第一是` — the synthesis of each chunk reads with a broken word, and chunk boundaries have no relation to natural pauses. ## Root Cause `tools/tts_tool.py::_split_text_for_tts` (sentence regex) — CJK punctuation was simply absent from the break set, and the break condition required whitespace which CJK never has. ## Fix Sentence detection now also breaks after CJK punctuation (`。！？；，、：”’`) — as a hard break with or without trailing whitespace — while Latin punctuation keeps the whitespace requirement: ```python r\"(?<=[.!?;:,])\\s+|(?<=[。！？；，、：”’])(?:\\s+|(?=[^\\s]))\" ``` The existing greedy merge pass (short sentences combined up to `max_chars`) is untouched, so boundaries land after punctuation whenever the text fits, and only truly punctuation-free runs fall back to character hard-split. ## Verification Reproduction before fix (`max_chars=30`): ``` 块1: 今天安全审计进展汇报。我们完成了核心系统全面排查，发现三个隐 ← mid-word break 块2: 患点。第一是认证模块会话管理存在过期时间过长风险。第二是日志 ``` After fix: ``` 块1: 今天安全审计进展汇报。 我们完成了核心系统全面排查， 块2: 发现三个隐患点。 块3: 第一是认证模块会话管理存在过期时间过长风险。 块4: 第二是日志系统敏感信息脱敏不完整。 第三是备份任务监控缺失。 ``` - `python -m pytest tests/tools/test_tts_long_form_chunking.py -x -q` → **19 passed** - New regression tests: CJK boundary placement (every chunk starts after `。！？；，、：”’`), content-loss check, short CJK sentence passthrough, and Latin `3.14` / `Dr.` preservation. ## Who should enable this - **Chinese/Japanese/Korean users** reading long text aloud via any TTS provider (built-in, command-type, or plugin). No config change — the fix applies automatically to every provider that goes through `text_to_speech_tool`. - Anyone with CJK content in mixed-language passages. ## Platform compatibility | Platform | Impact | |---|---| | macOS / Linux | ✅ Same regex path, CI-verified tests | | Windows | ✅ Pure stdlib `re`, no OS-specific behavior | | All TTS providers | ✅ Fix lives in shared chunking, not in any provider | ## Quick-start guide No action needed — update to the next release and read a Chinese paragraph aloud. To see the chunking directly: ```python from tools.tts_tool import _split_text_for_tts chunks = _split_text_for_tts(\"今天安全审计进展汇报。我们完成了核心系统全面排查，发现三个隐患点。\", 30) ``` ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | Chinese TTS still breaks mid-word | Provider caps text before the shared chunker (e.g. a command wrapper truncates) | Pass full text to `text_to_speech_tool`; let Hermes chunk | | Mixed CJK/Latin text breaks oddly | Latin punctuation without trailing space (e.g. `3.14`) is intentionally not a boundary | Add a space after Latin periods in source text if a break is desired | | Very long punctuation-free CJK run | No boundary exists; hard split is the only option | Insert punctuation or raise provider `max_text_length` |",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84622",
          "createdAt": "2026-08-12T15:53:21Z",
          "updatedAt": "2026-08-13T13:38:59Z",
          "timestamp": "2026-08-13T13:38:59Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "tool/tts",
            "P2"
          ],
          "author": "x7peeps",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:333caf5df999f7eb2362",
        "signalId": "github:NousResearch/hermes-agent:issue:4335",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:4335",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Feature Request: Cross-platform session context sharing (CLI ↔ Telegram)",
          "text": "## Summary Hermes Agent supports multiple messaging platforms (CLI, Telegram, Discord, etc.) through the gateway architecture. Each platform maintains isolated session stores, meaning an agent handling Telegram messages has no knowledge of conversations the user had via CLI, and vice versa. This feature request proposes a mechanism to share conversation context across platforms so users experience a continuous, unified agent relationship regardless of how they reach Hermes. --- ## Problem Statement **Current behavior:** - CLI sessions are stored locally in `~/.hermes/sessions/` and can be searched via `session_search` - Telegram sessions are stored in the gateway's session store (`telegram:chat_id` keys) - Honcho is configured and active, but sessions from each platform are written to separate Honcho session IDs - A user messaging `@Philgram_bot` on Telegram asking \"what did we discuss on my PC?\" receives no useful answer because the Telegram agent has no access to CLI session history **User impact:** - Users who switch between Telegram and CLI mid-conversation lose continuity - Context built up in one platform doesn't transfer to another - Cross-platform workflows (e.g., \"ask Hermes on Telegram to start a task, continue debugging in CLI\") require the user to re-explain everything --- ## Proposed Solution ### Approach 1: Shared Honcho session layer (preferred) Each platform session (`cli`, `telegram`, `discord`) writes to the **same Honcho session** rather than separate ones. This requires: 1. Deriving a **common user identifier** across platforms (e.g., from `honcho_config.peer_name` or `TELEGRAM_HOME_CHANNEL`) 2. Using a **shared session key pattern** like `agent:main:user:{user_id}` instead of `agent:main:{platform}:dm:{chat_id}` 3. Modifying `build_session_key()` in `gateway/session.py` to accept an optional user-level override 4. Honcho context prefetch on each message already supports this — the infrastructure is in place, it just needs the key generation logic to unify **Benefits:** - No per-message overhead — context already prefetched via Honcho's daemon thread pattern - No duplication of conversation history - Works with existing Honcho profile/memory layer ### Approach 2: Context injection hook A gateway hook that, on each incoming Telegram message, fetches recent CLI session history via FTS5 search and injects it into the Telegram agent's context prompt. **Benefits:** - Doesn't require changing session key architecture - Can be implemented as a custom hook without modifying core gateway code **Drawbacks:** - Adds latency on every message (Honcho fetch + LLM context window) - Duplicates history into each platform's session (storage bloat) - Less elegant --- ## Expected Behavior After implementation, the following should work: ``` User (CLI): \"Hermes, update my Nexus dashboard\" Hermes (CLI): [does the work] User (Telegram): \"@bot what was the last thing I asked Hermes to do?\" Hermes (Telegram): \"You asked Hermes to update your Nexus dashboard...\" ``` --- ## Technical Context - Hermes Agent repo: `NousResearch/hermes-agent` - Honcho integration: `honcho_integration/` (client.py, session.py) - Gateway session management: `gateway/session.py` (`build_session_key()`, `SessionStore`) - Gateway message handling: `gateway/run.py` (`_handle_message_with_agent()`) - Existing Honcho config at `~/.hermes/honcho.json` or `~/.honcho/config.json` --- ## Prior Art - Honcho already supports cross-session context via `session.context()` and `peer.chat()` - The CLI session search tool (`session_search`) already demonstrates FTS5 cross-session recall - Gateway hooks architecture (`gateway/hooks.py`) provides a plugin point for custom behavior --- ## Questions for Maintainers 1. Is there an existing design for cross-platform session unification? 2. Would a shared session key approach be acceptable architecturally? 3. Is there interest in a proof-of-concept PR implementing Approach 2 as a gateway hook?",
          "url": "https://github.com/NousResearch/hermes-agent/issues/4335",
          "createdAt": "2026-03-31T20:10:53Z",
          "updatedAt": "2026-08-13T13:38:54Z",
          "timestamp": "2026-08-13T13:38:54Z",
          "metrics": {
            "reactions": 3,
            "comments": 14
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/gateway",
            "P3",
            "needs-decision",
            "sweeper:risk-session-state",
            "area/sessions"
          ],
          "author": "Logi4k",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:4cb53446b430d0081878",
        "signalId": "github:NousResearch/hermes-agent:issue:84834",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:84834",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Webhook Revolution — graph-gated repair campaign (meta-issue)",
          "text": "# Hermes Webhook Revolution — Campaign Meta-Issue (EPIC) Graph-gated 5×2×3 repair campaign for the entire Hermes webhook surface: ingress, execution, delivery, configuration, management UI, deployment, and documentation. **Canonical plan:** [Hermes Webhook Revolution](https://hermes-webhook-revolution.axlibiza.chatgpt.site) — 20 execution tasks, 5×2×3 graph-gate geometry, 2K file ceiling. **Pinned base:** `origin/main` at `1d3d021282098261ce2ad224a76d97d89b16188c` (re-pinned from plan-time `b3aa561`; base moved, evidence re-pinned per plan rule). ## Campaign table (canonical state) | Task | Status | PR | Notes | |------|--------|----|-------| | 1. Storage + pinned workspace | ✅ | — | D:-hosted worktree, rebased 4 shipped commits onto main | | 2. Source/issue/PR graph | 🔄 | — | Materializing | | 3. Contract + Master Meta-Lock | 🔄 | — | This EPIC | | 4. Extract webhook REST routes | ✅ | — | `hermes_cli/web_routers/webhooks.py` (shipped, rebased) | | 5. Extract WebhookAdapter seam | 🔄 | — | profile admission done; auth/ingress/rendering/delivery pending | | 6–20 | ⬜ | — | Pending | | Profile-admission allowlist propagation regression | ✅ | #85054 | repaired on all 7 live branches; exact heads and CI linked below | ## Shipped work (rebased onto main) Controller branch `campaign/webhook-revolution-b3aa561f` carries 4 commits: - `refactor(web): extract webhook API router without behavior change` - `fix(web): preserve webhook summary monkeypatch seam` - `refactor(webhook): extract profile admission policy` - `fix(webhook): preserve profile annotation resolution` Seam tests pass on rebased base: `tests/gateway/test_webhook_profile_admission_seam.py` + `tests/test_web_server_webhooks_seam.py` → 5 passed. ## Issue-family routing (from plan §5) - Effective config/setup: #13240, #24911, #39598, #40324 - Secret persistence: #77471 - Idempotency/raw payload: #7448, #55829 - Signature/provider: #47451, #80327 - Profile/session/resume: #57056, #65939, #67277, #71352, #74980 - Clarify/approval: #31565, #37284, #71571, #78296 - Listener lifecycle: #4260 (wontfix), #78022 - Callback/sync response: #4386, #73828 - Multi-delivery/debounce/cancel: #20201, #32403, #39999 - Model/completion: #43730, #80531 - Provider recipes: #43575, #54693, #66893, #71968 - Source restrictions: #18041 ## Acceptance gates (plan §6) Analyst/witness agreement per lane at final SHA; no dangling graph edges; all god-file touches via approved seams; no file >2K lines; gateway/CLI/REST/web/desktop/setup agree under all config modes; no secret sentinel in any surface; explicit constant-time replay-safe signatures; same-route retry dedupe; valid-JSON raw payload; HTTP contract matches docs; observable execution; profile/session isolation; fail-fast approvals; SSRF-safe callbacks; versioned outbound hooks; profile-correct web/desktop; exercised deployment; executable docs. ## Handoff rule (plan §8) Ares = controller, interlock owner, ledger keeper. Ares does NOT act as blind implementer or blind witness. Fresh implementation/review actors per defect-class PR with two-stage review.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/84834",
          "createdAt": "2026-08-12T21:41:37Z",
          "updatedAt": "2026-08-13T13:37:54Z",
          "timestamp": "2026-08-13T13:37:54Z",
          "metrics": {
            "reactions": 0,
            "comments": 15
          },
          "labels": [
            "type/refactor",
            "comp/cli",
            "comp/gateway",
            "platform/webhook",
            "P3"
          ],
          "author": "andrexibiza",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:cbceda6a80acae371d62",
        "signalId": "github:NousResearch/hermes-agent:issue:85322",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85322",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "DaemonThreadPoolExecutor crashes on Python 3.14: '_initializer' attribute removed in CPython 3.14",
          "text": "## Bug Report **Version:** Hermes Agent v0.20.0 (2026.8.3), running on Python 3.14.6 (Homebrew) ### Symptom Parallel tool calls fail with: ``` AttributeError: 'DaemonThreadPoolExecutor' object has no attribute '_initializer' ``` This happens when multiple tool calls are dispatched concurrently (e.g. several `web_search` calls in one turn). Single tool calls work fine. ### Root Cause `tools/daemon_pool.py` `_adjust_thread_count()` copies the CPython 3.8–3.13 implementation and references `self._initializer` / `self._initargs`: ```python args=( weakref.ref(self, weakref_cb), self._work_queue, self._initializer, # <-- AttributeError on 3.14 self._initargs, ), ``` CPython 3.14 **removed** `ThreadPoolExecutor._initializer` / `_initargs`. The constructor now calls `type(self).prepare_context(initializer, initargs)` and stores `_create_worker_context` / `_resolve_work_item_task` instead. Also, `_worker`'s signature changed in 3.14 from `(executor_reference, work_queue, initializer, initargs)` to `(executor_reference, ctx, work_queue)`. ### Impact Any parallel (batched) tool dispatch on Python 3.14 fails the whole batch. Hermes's Homebrew formula installs Python 3.14 (this user's install: `libexec/lib/python3.14/site-packages`), so this affects all Homebrew-installed Hermes on macOS. ### Fix (verified locally) Version-adaptive `_adjust_thread_count` that handles both paths: ```python if hasattr(self, \"_create_worker_context\"): # Python 3.14+ path ctx = self._create_worker_context() args = ( weakref.ref(self, weakref_cb), ctx, self._work_queue, ) else: # Python 3.8–3.13 path args = ( weakref.ref(self, weakref_cb), self._work_queue, getattr(self, \"_initializer\", None), getattr(self, \"_initargs\", ()), ) ``` Verified working on Python 3.14.6: `DaemonThreadPoolExecutor` parallel execution succeeds with the patch. ### Repro ```python from tools.daemon_pool import DaemonThreadPoolExecutor with DaemonThreadPoolExecutor(max_workers=4) as ex: futures = [ex.submit(print, i) for i in range(10)] [f.result() for f in futures] # AttributeError before patch, OK after ``` ### Environment - macOS (Homebrew install: `hermes-agent 2026.8.3_1`) - Python 3.14.6 - OpenAI SDK 2.24.0",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85322",
          "createdAt": "2026-08-13T13:37:41Z",
          "updatedAt": "2026-08-13T13:37:41Z",
          "timestamp": "2026-08-13T13:37:41Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "ShawnLiann",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:7ce6e449f4eac63c65b6",
        "signalId": "github:NousResearch/hermes-agent:pull_request:56929",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:56929",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(guardrails): reset failure and no-progress counts on successful file mutation",
          "text": "Currently, if the agent runs a terminal command (like a unit test runner) that fails, and then successfully modifies files to fix the issue (using `patch` or `write_file`), the terminal failure counts are not reset. After `hard_stop_after.exact_failure` (default: 5) attempts, even with different code patches in between, the guardrail blocks the terminal command as a duplicate failure. This PR fixes this issue by resetting the guardrail counters whenever a successful file mutation lands (detected via `file_mutation_result_landed` helper).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/56929",
          "createdAt": "2026-07-02T08:48:39Z",
          "updatedAt": "2026-08-13T13:35:51Z",
          "timestamp": "2026-08-13T13:35:51Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "P2",
            "sweeper:risk-session-state",
            "sweeper:blast-contained"
          ],
          "author": "dichvuhuuich",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:eeac6b0c9cfe6ffe0228",
        "signalId": "github:NousResearch/hermes-agent:issue:85321",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85321",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Approval gate: file tools bypass the shell-rc gate the terminal tool enforces; touch/mkdir/ln uncovered",
          "text": "## Summary `~/.bashrc` and the other shell rc files are gated by the terminal tool but **not** by the file tools, so `patch`/`write_file` can rewrite them with no prompt. Separately, `touch`, `mkdir` and `ln` are not covered by any `DANGEROUS_PATTERNS` rule, so the `write_file` deny on `~/.ssh/` is trivially reachable around via `terminal`. I know from SECURITY.md §2.4 that the approval gate is a heuristic and not a boundary, and from §3.2 that regex bypasses are out of scope for the private channel — hence a regular issue. The report is not \"the denylist is incomplete\". It is that **two layers documented as paired have drifted apart**, so the gate reports a denial it does not actually enforce, and the in-tree comments say this pairing is intended (`tools/approval.py:305-308`: *\"Pair the write_file/patch deny … with terminal-side coverage … otherwise the deny is unpaired theater\"*, and `tools/approval.py:930-933` on the earlier `cp evil ~/.bashrc` fix, *\"Same unpaired-door rationale as #14639\"*). If maintainers read item 1 as a §3.1 trust-model documentation violation rather than a §2.4 heuristic limit, I'm happy to refile it privately — I filed publicly because §3.2 seemed to be the better fit, not to pre-empt that judgement. ## Environment - Verified on `origin/main` @ `fa83af3f9` (2026-08-13) in a clean `git worktree`, **and** on the installed `v0.20.0 (2026.8.3)` @ `1d3d02128` - Arch Linux, Python 3.13, bundled venv, default local terminal backend - Surface where it was first hit: Telegram gateway (`plugins/platforms/telegram/`) ## 1. File tools do not honour the shell-rc gate the terminal tool enforces `tools/approval.py` puts `_SHELL_RC_FILES` inside `_SENSITIVE_WRITE_TARGET`, so the terminal side prompts. The file tools have no corresponding entry — `build_write_approval_paths()` (`agent/file_safety.py:111`) contains only `~/.ssh/config`. ```python >>> import os >>> from agent.file_safety import is_write_denied, is_write_approval_required >>> p = os.path.expanduser(\"~/.bashrc\") >>> is_write_denied(p), is_write_approval_required(p) (False, False) >>> from tools.approval import detect_dangerous_command as d >>> d(\"echo x > ~/.bashrc\")[0] # terminal side: gated True >>> d(\"sed -i 's/a/b/' ~/.bashrc\")[0] # terminal side: gated True ``` Functional repro against `main` with a sandboxed `HOME`: ```python r = patch_tool(path=f\"{HOME}/.bashrc\", mode=\"replace\", old_string=\"export X=1\\n\", new_string=\"export X=1\\n# INJECTED\\n\") # -> {'success': True}, no prompt; \"# INJECTED\" is in the file ``` Observed first over the Telegram gateway on v0.20.0: `patch` appended to `~/.bashrc`, returned `success: true`, no approval was shown. A shell rc write executes on the next shell, which is the same authority the terminal path already asks about. Same asymmetry applies to `.zshrc`, `.profile`, `.bash_profile` (all `False`/`False`). ## 2. The `~/.ssh` write deny is unpaired for file-creation verbs Real transcript, one turn earlier in the same Telegram session: 1. `write_file(\"~/.ssh/test.txt\")` → `Write denied: '/home/…/.ssh/test.txt' is a protected system/credential file.` 2. the model retried on its own initiative with `terminal`: `mkdir -p /home/…/.ssh && touch /home/…/.ssh/test.txt` → **exit 0, file created, no prompt.** `detect_dangerous_command()` on `main` covers redirection, `tee`, `cp|mv|install`, `sed -i`, `perl/ruby -i`, `dd`, `python3 -c open(...)`, nested `bash -c` and `curl | bash` against these targets, but nothing matches `touch` or `mkdir`: ```python >>> d(\"touch ~/.ssh/test.txt\")[0] False >>> d(\"mkdir -p ~/.ssh && touch ~/.ssh/test.txt\")[0] False ``` Related: the deny string in `agent/file_safety.py:216` carries no instruction against retrying by another route, while `_check_approval_required_write` (`tools/file_tools.py:991`) does (*\"Do NOT retry it via another path (terminal, execute_code)\"*). After I added the same sentence locally, the model stopped retrying — a one-line change with a visible behavioural effect. ## 3. `ln` is not covered ```python >>> d(\"ln -sf /tmp/evil ~/.bashrc\")[0] False >>> d(\"ln -s /tmp/evil ~/.ssh/authorized_keys\")[0] False ``` The rc file is replaced by a pointer to attacker-controlled content without touching any gated write verb. ## 4. Two correctness issues in `_check_approval_required_write` `tools/file_tools.py:985-1007`: - the prompt text is hardcoded to `\"SSH client config file(s)\"` — the moment any non-SSH path enters `build_write_approval_paths()`, the operator is asked to approve the wrong thing; - `pattern_key=\"ssh_config_write\"` and `autoapprove_log_prefix=\"ssh_config_write\"` are hardcoded too, so a single approve-**always** answer for `~/.ssh/config` would silently authorise every other approval-gated path from then on. Both are latent today (only one path is registered) but they are the reason a fix for item 1 cannot be a one-line list append. ## Suggested fix Shape of the patch I'm running locally (happy to open a PR): - `build_write_approval_paths()` — add `.bashrc .zshrc .profile .bash_profile .zprofile .zshenv .bash_login`, at the **approval** tier rather than hard deny, since editing one's own rc file is routine and the operator can still say yes. - `DANGEROUS_PATTERNS` — add `(touch|mkdir)` and `ln` rules against `_SENSITIVE_WRITE_TARGET`. - `_check_approval_required_write()` — derive the prompt text and `pattern_key` from the target class (`ssh_config_write` / `shell_rc_write`). - optionally, carry the \"do not retry via another path\" sentence into the hard-deny message. ### Verification of that patch - 5/5 previously-escaping commands now gated - 0 false positives on ordinary usage (`touch /tmp/x`, `mkdir -p ~/projects/new`, `cat ~/.bashrc`, `ln -s /tmp/a /tmp/b`, `ls ~/.ssh`) - no regressions on previously-gated commands (redirection, `cp`, `sed -i`, `dd`, `rm -rf /`, `curl | bash`) - `pytest tests/tools/test_file_write_safety.py` → 57 passed - `pytest tests/tools/test_approval.py tests/tools/test_approval_deny_rules.py tests/tools/test_file_tools.py` → 154 passed, 2 skipped - live re-test over Telegram: both requests returned `BLOCKED: denied by user`, the filesystem was unchanged, and the model did not attempt an alternate path ## Unrelated observation `tests/tools/test_approval_config_readonly.py::test_guard_never_calls_deepcopy_variant` and `::test_config_readers_never_call_deepcopy_variant` fail when the approval test files are run in one invocation (`assert calls[\"readonly\"] == 5` gets `4`) but pass in isolation. Reproduced on a clean tree with no local changes, so it looks like test-ordering pollution rather than anything in this report. Can file separately if useful.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85321",
          "createdAt": "2026-08-13T13:33:07Z",
          "updatedAt": "2026-08-13T13:33:07Z",
          "timestamp": "2026-08-13T13:33:07Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "ihsandeniz",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:204a822194c55dce08e5",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85320",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85320",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(kanban): reconcile detached running runs",
          "text": "## Summary - close `task_runs` left running after their task is blocked, terminal, or moved to a successor run - preserve task workflow state and write a correlated `run_reconciled` audit event transactionally - defer reconciliation while the detached run still records a live PID - run the repair in the existing opt-out orphan-reconciliation dispatch phase ## Root cause Card-level reconciliation only scanned `tasks.status='running'`. A run row could therefore remain `running` forever after its task had already left the running phase, creating stale operational debt that no existing recovery path could see. ## Verification - strict RED observed: missing `reconcile_stale_task_runs` raised AttributeError - `uv run --frozen --extra dev pytest tests/hermes_cli/test_kanban_stale_run_reconciliation.py -q` -> 4 passed - lifecycle regression set (stale-run/orphan/review/reclaim/dispatch-lock) -> 58 passed - `uv run --frozen --extra dev ruff check hermes_cli/kanban_db.py tests/hermes_cli/test_kanban_stale_run_reconciliation.py` -> passed - `git diff --check` -> passed - independent review -> PASS, no security or logic findings ## Safety / non-goals - does not move blocked/terminal task rows - does not reconcile a run that still records a live PID - does not enable broad sync, dispatch, LiveOp Done writeback, main, or production - 24-hour canary and live crash/restart/offline/resource drills remain post-review operational gates",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85320",
          "createdAt": "2026-08-13T13:32:03Z",
          "updatedAt": "2026-08-13T13:33:02Z",
          "timestamp": "2026-08-13T13:33:02Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "mtccressall",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:8ae29a0ae92ac7436fbc",
        "signalId": "github:NousResearch/hermes-agent:pull_request:80493",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:80493",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(memory): entry-point + project-dir discovery parity for out-of-tree providers (salvage of #18842, #40644, #76567)",
          "text": "Closes #40101. ## Problem `plugins/memory/` is closed to new providers, so every new memory backend now ships out of tree. But the out-of-tree path is measurably weaker than the in-tree one, and the docs already promise otherwise. `plugins/memory/__init__.py:_iter_provider_dirs()` walks **directories only** — bundled and `$HERMES_HOME/plugins/`. The general `PluginManager` (`hermes_cli/plugins.py:1371-1391`) scans four sources including pip entry points. `CONTRIBUTING.md:78` and `AGENTS.md` both state memory discovery \"picks them up from user/project plugin directories and pip entry points\". It does not. The user-visible symptom is #40101: a correctly-registered pip-installed provider reports `Plugin: NOT installed`. The workaround in the wild is a bespoke second install step — note that Memori, documented in `memory-providers.md`, ships `pip install hermes-memori` **plus** `hermes-memori install` for exactly this reason. ## This is a salvage, not a fourth attempt Three PRs already address parts of this and none have merged. Rather than add another competing implementation, this rebases them onto current `main` with authorship preserved and adds what none of them cover — the same approach as #79239. | PR | Author | Taken | |---|---|---| | #18842 | @smarzola | Entry-point discovery via `hermes_agent.memory_providers`, `ctx.register_skill()` forwarding, tests, developer-guide docs | | #76567 | @mlsmith | Classify entry-point provider plugins without importing them, including the dotted-name parent-import fix | | #40644 | @AxDSan | Not taken — its discovery half duplicates #18842, and its pipx switch was correctly rejected in review (pipx venvs are invisible to both `importlib.metadata` and the agent runtime) and reverted by the author. Its Mnemosyne docs belong in a docs PR. | #18842 and #76567 are complementary rather than competing: one adds memory entry-point discovery, the other stops the general PluginManager from eagerly importing such packages in every hermes process. ## What this adds on top **Project-local providers** (`./.hermes/plugins/<name>/`), gated on `HERMES_ENABLE_PROJECT_PLUGINS` exactly as `PluginManager` gates its own project scan. Completes the four sources. **`find_provider_dir()` resolves package entry points.** This is load-bearing rather than cosmetic: `config_schema.py` (the dashboard config panel) and `cli.py` (the `hermes <provider>` subcommands) are read **from disk**, not imported — `plugins/memory/config_schema.py:14-17` does this deliberately so the web server never pulls in the agent runtime. Without a directory, a pip-installed provider silently loses both. **`list_memory_provider_names()` includes entry-point providers**, so they appear in the dashboard's `memory.provider` dropdown. **Resolution stays import-free.** `resolve_module_origin()` is extracted from #76567's `_resolve_module_source()` and shared, so discovery walks a module's file layout instead of importing it. `find_provider_dir()` is called from the dashboard and from argparse setup — long before the operator has chosen a provider — so importing every installed candidate would execute third-party code on the strength of a package merely being present. A test asserts resolution leaves no side effect and no `sys.modules` entry. **`PluginContext.register_memory_provider()`.** Memory was the only provider category without one — context engine, image gen, video gen, web search, browser, TTS, transcription, secret source, dashboard auth and platform all have one. **`_ProviderCollector` delegates unknown `register_*` calls to a real `PluginContext`** instead of carrying three hand-written no-ops. It silently dropped `register_tool` / `register_hook`, and had **no `register_auxiliary_task` at all** — despite `PluginContext.register_auxiliary_task`'s own docstring using a memory provider (hindsight's pre-retain dedup) as its worked example. It can no longer drift behind `PluginContext`. **A raise after `register_memory_provider()` no longer costs the provider.** The loader caught it into a `logger.debug`, discarded the registered instance, and fell through to \"instantiate any `MemoryProvider` subclass\" — returning a *different, unconfigured* provider. A silent downgrade that looked like success, and the exact outcome of calling `register_auxiliary_task`. ## Precedence **bundled > user > project > entrypoint** — deliberately the reverse of the general PluginManager's later-wins order, and documented in the module docstring. A memory provider is activated by *name*, so letting a directory dropped into a working tree shadow a shipped provider would silently redirect the agent's memory. Existing behaviour is unchanged; the new sources sit below it. ## Safety Activation is still gated on `memory.provider` naming the plugin. Discovery **enumerates**; it does not load. Using a real `PluginContext` must not start also requiring the plugin in `plugins.enabled` — that would break every existing user-installed provider — and there is a test pinning it. ## Testing `406 passed` across `tests/plugins/memory/`, `tests/agent/test_memory_provider.py`, `tests/hermes_cli/test_plugins.py`, `tests/test_plugin_skills.py`, `tests/hermes_cli/test_web_server.py`. Six failures in `test_hindsight_provider.py` are a missing optional dependency (`hindsight_client_api`) and fail identically on `main`. New coverage in `tests/plugins/memory/test_discovery_sources.py`: project-dir discovery on and off, entry-point discovery, precedence, import-free resolution, bare-module entry points, the secondary-registration fix, and the `plugins.enabled` invariant. Verified end to end against a real third-party provider ([kainappsinc/elephant](https://github.com/kainappsinc/elephant)) installed by pip alone with no directory copy — it appears in the dropdown, resolves its directory, loads with its 34 tools, and renders its dashboard config panel. ## Follow-ups, deliberately not bundled - `hermes_cli/config_defaults.py:3792` hardcodes env-var metadata per bundled provider; external plugins cannot join. Largely served already by `get_config_schema()` / `config_schema.py`, and a much larger refactor. - `hermes_cli/main.py:9210` still hardcodes `\"honcho\"` in `_SUBCOMMANDS`. Cosmetic — it only affects unquoted multi-word session names — but it is the same class of thing PR #5295 removed.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/80493",
          "createdAt": "2026-08-06T17:19:28Z",
          "updatedAt": "2026-08-13T13:32:43Z",
          "timestamp": "2026-08-13T13:32:43Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/plugins",
            "tool/memory",
            "tool/skills",
            "P3"
          ],
          "author": "gigabyte22",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:34a5a967e0efb3123b3b",
        "signalId": "github:NousResearch/hermes-agent:issue:83683",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:83683",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Desktop restart reaps the live gateway but never relaunches it (WeChat/QQ go silent) — regression",
          "text": "## Summary On Windows (Hermes 0.20.0, desktop app), **every desktop-app restart force-kills the running messaging gateway and never relaunches it**. WeChat (iLink), QQ bot and Telegram go completely silent until the gateway is manually restarted. This is a regression: before this version, the old gateway survived desktop restarts (reparented), so messaging stayed online. ## Root cause In `hermes_cli/web_server.py`, when the desktop backend starts (`HERMES_DESKTOP=1`), it calls `_reap_unsupervised_gateway_orphans()` before boot, with the comment *\"Before forking a fresh gateway, reap any orphan left by a previous serve session... (#77276)\"*. However: 1. `_reap_unsupervised_gateway_orphans()` → `find_gateway_pids()` → `_scan_gateway_pids()` matches **any** process whose command line looks like `hermes_cli.main gateway run` (`looks_like_gateway_command_line`), including gateways started by the Windows scheduled task (`Hermes_Gateway`) or manually via `hermes gateway run`. It writes the planned-stop marker, sends SIGTERM, then SIGKILLs survivors after 5s. 2. The comment says \"before **forking** a fresh gateway\", but `hermes serve` does **not** spawn a gateway at all (per the same file: *\"The Desktop backend is spawned as `hermes serve` — it runs neither the interactive CLI nor the messaging gateway\"*). Gateway startup is delegated to external mechanisms (the login scheduled task, the update flow, or a manual `hermes gateway run`). 3. Result: **every desktop restart reaps the live gateway with no successor**. The gateway exits uncleanly (`gateway-exit-diag.log`: *\"exited UNCLEANLY (no exit path ran — SIGKILL / OOM / VM death)\"*), and `.gateway-planned-stop.json` is left behind. ## Steps to reproduce 1. Start the gateway (login scheduled task or `hermes gateway run`). WeChat/QQ/Telegram connect fine. 2. Close and reopen the Hermes desktop app. 3. The gateway process is gone (no exit logs; killed externally). Messages sent via WeChat/QQ get no reply until the gateway is manually restarted. Secondary symptom: the third-party skin plugin (`skin-studio`) re-asserts the configured skin on boot but only retries for ~2.5 s, while the desktop backend takes longer to become ready — so after a desktop restart the skin silently falls back to default until manually re-applied. Same class of \"desktop restart breaks things that depend on the gateway/backend\". ## Suggested fixes - **Option A**: when the desktop reap kills a live gateway, the desktop backend (or Electron main) should spawn a replacement immediately after reap (complete the \"fork a fresh gateway\" the comment promises). - **Option B**: only reap gateways that are truly unsupervised (no `gateway.pid` record / not the login scheduled-task instance), instead of every process matching `gateway run`. - **Option C**: add a config switch (e.g. `gateway.reap_on_desktop_start: false`) so users who manage the gateway externally (scheduled task / systemd / manual) can opt out. ## Workaround (user side) A 2-minute Hermes-internal cron job that checks for the gateway process and relaunches it detached with the full environment. Works, but leaves a gap and depends on the desktop backend's own cron scheduler. ## Environment - Windows 11, Hermes 0.20.0 (commit 33f8e96a72), desktop app, Python 3.12 - Platforms: WeChat (iLink), QQ Bot, Telegram (via local proxy) - `HERMES_DESKTOP=1` serve backend; gateway launched via `python.exe -m hermes_cli.main gateway run`",
          "url": "https://github.com/NousResearch/hermes-agent/issues/83683",
          "createdAt": "2026-08-11T04:56:35Z",
          "updatedAt": "2026-08-13T13:32:31Z",
          "timestamp": "2026-08-13T13:32:31Z",
          "metrics": {
            "reactions": 0,
            "comments": 16
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/gateway",
            "P1",
            "sweeper:risk-message-delivery",
            "sweeper:risk-platform-windows",
            "comp/desktop",
            "platform/windows"
          ],
          "author": "zuowen7",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:69575abc21b14b151642",
        "signalId": "github:NousResearch/hermes-agent:issue:84058",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:84058",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Desktop: composer caret lost when a tool call starts streaming (silent focus/remount, no exception)",
          "text": "## Symptom In the macOS desktop app, while the user is typing in the main composer input box, the text caret disappears the instant an agent tool call begins (the \"Ran … Nms\" tool row renders / the model starts executing a command). Typing resumes only after re-clicking the input. This happens on essentially every assistant turn. It reads to the user as an app \"refresh\" — the input loses focus and the in-progress text/caret is gone — but nothing actually reloads. ## What we ruled out (forensic evidence, not theory) - **Not the `Edit already in progress` crash.** That throw (`beginEdit` re-entrant on edit-composer remount, #3652/#4051 family) WAS previously firing — 17 occurrences in `desktop.log` from older bundles. A guard `IncrementalExternalStoreThreadRuntimeCore.beginEdit` returning early when `getEditComposer(messageId)` is open was added and is confirmed present in the running asar (`index-BRhtfo9X.js`, shipped in the 15:58 bundle). After that, `desktop.log` shows **zero** `Edit already in progress` lines. - **Not a renderer crash / reload.** `window-renderer-lifecycle` logs `render-process-gone`/`unresponsive`/`did-fail-load`. Across the active instance (since 17:43 local) there are **0** such lines. The app does not `boot`/reload at the moment of the symptom. - **Not an error-boundary remount with a thrown error.** The `[renderer console]` capture in `renderer-log.ts` only logs level-3 (error). No level-3 console line appears at tool-call time in the current bundle. So whatever drops the caret is **silent** — no exception, no crash, no reload — which is why it survives the guard fix. ## Likely areas (for a maintainer who can run the app) The composer lives in `app/chat/composer/` and is intentionally rendered as a **sibling** of the thread (outside the `contain:[layout paint]` wrapper in `app/chat/index.tsx` ~line 595) so dock⇄float never remounts the editor. `focusComposerInput` (`composer/focus.ts`) correctly skips `el.focus()` when `document.activeElement === el`, and `use-composer-draft.ts:165` only re-focuses on `focusKey`/`focusRequestId`/`inputDisabled` changes (`focusKey` = `activeSessionId`, stable per session). Suspects: 1. A React reconciliation at tool-call arrival that replaces the contentEditable DOM node (caret lost even without an explicit `blur`). 2. A focus steal from elsewhere on the `thread.runStart` / message-arrival path. 3. The agent message render path (`components/assistant-ui/thread/*`, `message-parts.tsx`, `tool/*`) causing the composer subtree to remount or the editor node to be re-keyed. ## Repro 1. Open the macOS desktop app, start a fresh session. 2. Begin typing in the main composer input (do NOT submit). 3. Send any prompt that triggers a tool call (e.g. a `terminal` command), OR observe mid-typing when the agent starts executing. 4. Caret is lost the moment the tool row / streaming starts. User must click back into the box. ## Environment - macOS 26.x (desktop packaged build, asar `index-BRhtfo9X.js`, built 2026-08-11 15:58). - Main model: deepseek/deepseek-v4-flash-0731 via Nous. - auxiliary.compression: nous D0731; auxiliary.vision: openrouter nemotron-3-nano-omni:free. ## Ask Please add instrumentation / a fix so the composer caret is preserved across assistant-tool transitions (focus should only move when the user explicitly navigates away, and the contentEditable node must not be replaced during normal streaming). Happy to provide `desktop.log` + a screen recording if helpful.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/84058",
          "createdAt": "2026-08-11T18:54:18Z",
          "updatedAt": "2026-08-13T13:29:58Z",
          "timestamp": "2026-08-13T13:29:58Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "P3",
            "needs-repro",
            "comp/desktop"
          ],
          "author": "sanyamjain2014",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:50d6f84929835c440042",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85227",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85227",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(agent): add model-agnostic session compiler and native lifecycle",
          "text": "## Problem Hermes persisted visible transcripts, but request construction and durable session ownership were split across the normal agent loop, Codex app-server, clients, and provider continuation state. That made history correctness depend on the selected transport and left model/provider switching, restart recovery, idempotency, and large tool envelopes without one enforceable contract. ## Architecture This PR makes Hermes state the authoritative session boundary and converges model invocation on one provider-neutral compilation path: - defines `TurnCommand`, `SessionSnapshot`, canonical events, `ModelCapabilities`, `CompiledTurn`, typed compilation failures, `ModelAdapter`, and disposable `ProviderContinuation` - routes the normal loop and Codex app-server through the same context compiler; fallback model/provider changes re-resolve capabilities and recompile - accounts once for instructions, history, current input, tools, fixed envelope overhead, and output reserve - retains full tool-call/result groups and returns typed no-fit before provider invocation instead of silently dropping prior history - adds versioned native session RPCs for create/open/snapshot/append-turn/cancel with exact session identity and no client-selected profile database ## Authoritative lifecycle and recovery - appends the user event before inference using stable turn and idempotency IDs plus expected session revision - records accepted/running/completed/failed/canceled lifecycle and expiring execution leases - replays duplicate live or terminal delivery without duplicating the user event - reclaims an expired running turn as a new attempt while preserving the original event identity - fences external tool effects by `(session_id, turn_id, tool_call_id)` and payload hash; completed results replay, retry-safe abandoned tools may retry, and ambiguous effectful calls become explicit `uncertain` rather than running twice - emits terminal `session.turn.updated` events with the canonical revision used by the durable receipt ## Append-only journal State schema 28 adds monotonic canonical/event revisions and projection transitions. Ordinary rewrite, compaction, rewind, and sidecar enrichment preserve source message rows; snapshots rebuild from recorded projection lineage instead of mutable `active` flags. Legacy rows use their existing durable row IDs as the migration floor, so startup does not copy entire databases. Memory-prefetch/API sidecars are written back to the exact accepted user event. Compaction checkpoints expose their source event IDs, and native turns use in-place compression so session identity remains stable. `ScopedSessionDB` is explicitly documented as a migration bridge with a deletion condition: native stateful tools must consume the canonical Hermes session snapshot/authorization API and stop inspecting SQLite internals. ## Verification After rebasing on current `origin/main`: - `scripts/run_tests.sh` across compiler, Codex integration, turn context, tool journal, session journal, state, native RPC, failure retention, in-place compaction, run-agent, and full gateway coverage: **1,140 passed, 2 skipped, 0 failed**, with no flaky retry - full `tests/test_tui_gateway_server.py`: **551 passed** - Ruff across the affected runtime/test surfaces: passed (two pre-existing invalid-`noqa` warnings) - `git diff --check`: passed Synthetic coverage includes a 165-tool Office Manager-scale catalog, dependent second-turn history, model/provider switching over one Hermes session, Hebrew UTF-8 accounting, atomic tool-call/result retention, typed mandatory-envelope no-fit with no provider call, journal reconstruction after projection-flag corruption, duplicate delivery, expired-turn restart recovery, side-effect fencing, and exact sidecar event persistence. ## Coordination and remaining integration This remains draft while the native Desktop client, addon canonical reader migration, cross-process/cross-model E2E, review, and CI gates are completed. Provider continuation remains optional and is not a correctness dependency. Compatible finalization/event-projection work from #61751 and #63798 remains reusable. This design does not adopt provider-thread persistence from #41905 as session truth and does not replace focused compaction work in #73715. No release, deployment, tag, or installation is part of this PR. Related: #73503, #26035, #41904",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85227",
          "createdAt": "2026-08-13T10:32:32Z",
          "updatedAt": "2026-08-13T13:29:42Z",
          "timestamp": "2026-08-13T13:29:42Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "P3",
            "needs-decision",
            "sweeper:risk-session-state"
          ],
          "author": "leonagente",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:5aeb6fc4af4125c6950f",
        "signalId": "github:NousResearch/hermes-agent:issue:85215",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85215",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: Cron jobs pin to a dead model and ignore fallback_providers — jobs fail with HTTP 402 for days",
          "text": "### Bug Description ## Summary When a cron job is created, it captures a model_snapshot/provider_snapshot of the active model. If that model's quota/plan later becomes exhausted (HTTP 402 from the provider), every subsequent run of the job fails permanently with the same 402 error. The configured fallback_providers chain in config.yaml is NOT consulted for cron runs (Available Model Group Fallbacks=None). Jobs keep failing until the quota resets or the user manually edits ~/.hermes/cron/jobs.json. Additionally, cronjob action=update ... model=X provider=Y does not reliably overwrite the pinned snapshot; the job keeps using the old provider. ## Environment - Hermes version: 0.20.0 (desktop app) - OS: macOS - Provider config: custom providers in config.yaml (providers: section), plus fallback_providers list ## Actual behavior Every run fails with: RuntimeError: HTTP 402: litellm.APIError: APIError: OpenAIException - The Token Plan usage limit has been reached. To avoid interruption, please upgrade your plan or buy credits and turn on auto consumption. (2067). Received Model Group=minimax-m3 Available Model Group Fallbacks=None - jobs.json keeps model_snapshot/provider_snapshot from creation time; cronjob action=update with model=/provider= does not update them (the job still reports the old Model Group even after the update). - config.yaml has a healthy fallback_providers list (deepseek, aws bedrock, etc.), but cron execution ignores it entirely. ------------------------------- ## What I tried (none worked) 1. cronjob action=update job_id=... model=aws-qwen3-235b provider=aws_qwen3 - API returned success but jobs.json still showed the old snapshot; next run used the dead provider anyway. 2. Editing ~/.hermes/cron/jobs.json directly to set model/provider - the ticker picked it up only partially; the job still resolved to the dead provider, and later a second bug surfaced: an \"Unknown provider\" error when the provider name in jobs.json did not byte-match the key in config.yaml's providers: section (names are not validated/normalized at write time, and the error message does not list valid provider names). 3. Wrapper script workaround (cronjob action=update script=... that sets HERMES_MODEL env and calls hermes cron run <id>) - hermes cron run reports \"Job is already being fired by the scheduler; not run again\" and does not execute the job inline, so the env-var override never reaches the actual run. 4. Commenting out the dead provider in config.yaml - cron still resolved to the removed provider name from the snapshot. ## Impact - Scheduled jobs (health checks, cost summaries, dataset maintenance) silently fail for days. - Users on quota-based plans get a broken set-and-forget cron experience with no notification. - Manual jobs.json surgery is required, which is error-prone (invalid provider names cause a different failure). ## Suggested fix direction - At cron run time, resolve the model like a normal chat turn would: if the pinned model is unavailable (402/429), walk fallback_providers before failing. - cronjob action=update should either update model_snapshot/provider_snapshot when model=/provider= is passed, or refuse with a clear message explaining that snapshots are immutable. - Validate provider names against config.yaml's providers: keys when writing jobs.json; on mismatch, surface the list of valid names in the error. ------------------------------ ### Steps to Reproduce 1. Configure provider A (e.g. a subscription-token provider) as the active model in config.yaml. 2. Create any cron job (e.g. cronjob action=create). Note: model_snapshot/provider_snapshot get written into ~/.hermes/cron/jobs.json. 3. Let provider A's quota/plan run out - all calls now return HTTP 402 \"Token Plan usage limit has been reached\". 4. Wait for the job's next scheduled run (or trigger cronjob action=run). Result: every run fails with the same 402 error, fallback_providers is never consulted, and the job stays broken until the quota resets or jobs.json is edited manually. ### Expected Behavior - The cron job should resolve the model like a normal chat turn: if the pinned model is unavailable (HTTP 402/429), it should walk the fallback_providers list before failing, so the job still runs. - cronjob action=update with model=/provider= should update the model_snapshot/provider_snapshot so the next run uses the new provider. - Provider names should be validated against config.yaml's providers: keys when writing jobs.json; on mismatch, the error should list the valid provider names. - The job should fail loudly with a clear \"quota exhausted, switch model\" message instead of silently retrying the same dead provider forever. ### Actual Behavior Every run of the job fails with the same error: RuntimeError: HTTP 402: litellm.APIError: APIError: OpenAIException - The Token Plan usage limit has been reached. To avoid interruption, please upgrade your plan or buy credits and turn on auto consumption. (2067). Received Model Group=minimax-m3 Available Model Group Fallbacks=None - jobs.json keeps model_snapshot/provider_snapshot from creation time; cronjob action=update with model=/provider= does not update them (the job still reports the old Model Group even after the update). - config.yaml has a healthy fallback_providers list (deepseek, aws bedrock, etc.), but cron execution ignores it entirely. - The job silently retries the dead provider on every scheduled run; there is no notification and no fallback. ### Affected Component Other ### Messaging Platform (if gateway-related) _No response_ ### Debug Report ```shell Environment: - Hermes 0.20.0 (desktop app) - macOS - Custom providers in config.yaml (providers: section) + fallback_providers list - Cron jobs created while provider A was active ``` ### Operating System MacOS Tahoe 26.5.1 ### Python Version 3.11.15 ### Hermes Version 0.20.0 (2026.8.3) ### Additional Logs / Traceback (optional) ```shell === ERROR ON EVERY SCHEDULED RUN === RuntimeError: HTTP 402: litellm.APIError: APIError: OpenAIException - The Token Plan usage limit has been reached. To avoid interruption, please upgrade your plan or buy credits and turn on auto consumption. (2067). Received Model Group=minimax-m3 Available Model Group Fallbacks=None === JOBS.JSON (AFTER cronjob action=update WITH NEW MODEL/PROVIDER) === \"model\": null, \"provider\": null, \"provider_snapshot\": \"custom\", \"model_snapshot\": \"minimax-m3\" NOTE: update returned success but the snapshot fields did not change; next run still used Model Group=minimax-m3. ``` ### Root Cause Analysis (optional) Root cause: cron job records persist model_snapshot/provider_snapshot at creation time. At run time the cron scheduler resolves the model EXCLUSIVELY from that snapshot and does not consult the fallback_providers list in config.yaml. When the snapshot provider's quota is exhausted (HTTP 402), every run fails identically and permanently. The failure is silent (job status = error, no delivery), so a broken set-and-forget job can fail for days unnoticed. Secondary issues found while investigating: 1. cronjob action=update with model=/provider= does NOT update model_snapshot/provider_snapshot in jobs.json, so the \"fix\" appears to succeed but changes nothing. 2. Provider names in jobs.json are not validated against config.yaml providers: keys at write time; a mismatch surfaces later as a confusing \"Unknown provider\" error that does not list valid names. 3. `hermes cron run <id>` does not execute the job inline; it returns \"Job is already being fired by the scheduler; not run again\", so env-var/wrapper workarounds cannot override the model. ### Proposed Fix (optional) 1. At cron run time, resolve the model through the same path as a normal chat turn: if the pinned snapshot model is unavailable (402/429), walk fallback_providers before failing. If all fail, record a visible error. 2. cronjob action=update: when model=/provider= is passed, either update model_snapshot/provider_snapshot (or clear them to fall back to current config) — or refuse with a clear message that snapshots are immutable. 3. Validate provider names against config.yaml providers: keys when writing jobs.json; on mismatch, include the list of valid provider names in the error. 4. Optional: surface cron job HTTP 402/429 failures as a notification instead of silent last_status=error. ### Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85215",
          "createdAt": "2026-08-13T10:07:20Z",
          "updatedAt": "2026-08-13T13:28:04Z",
          "timestamp": "2026-08-13T13:28:04Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/cron",
            "area/config",
            "P2",
            "sweeper:risk-compatibility",
            "area/billing",
            "bug"
          ],
          "author": "CPLANETAI",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:363ea1d31cba6bd95f50",
        "signalId": "github:NousResearch/hermes-agent:pull_request:67495",
        "event": "changed",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:67495",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(signal): add policy-aware read receipts",
          "text": "## Summary Add policy-aware outbound Signal read receipts as a focused slice of #39043. - keep read receipts **disabled by default** - expose the behavior through `signal.send_read_receipts` in `config.yaml` (no new behavioral env var) - schedule `sendReceipt` only after Signal's story/content/group/mention filters pass and the gateway's authorization callback returns true - use the original message author's ACI/UUID (phone number fallback) and the data-message timestamp, including for group messages - skip unauthorized senders, explicit routes to unserved or different transport profiles, Note to Self, and malformed/missing timestamps - track the best-effort receipt task through adapter shutdown without delaying message handling ## Why this shape signal-cli v0.14.6 supports `sendReceipt`; Hermes uses its canonical typed `targetTimestamps: Vec<u64>` JSON-RPC shape. Its daemon-level `--send-read-receipts` flag runs before Hermes can apply adapter filters or gateway authorization. The Hermes option therefore stays opt-in and the docs warn operators not to combine the two policies. An accepted RPC or empty `results` list is not treated as proof that the remote sender saw a receipt: signal-cli still respects the account's Signal read-receipt privacy setting. ## Policy decision Proceed with Hermes-owned, opt-in receipts at the adapter layer. This matches current Hermes practice: WhatsApp exposes `send_read_receipts` disabled by default and sends only after adapter policy filters, while BlueBubbles likewise keeps receipt behavior in adapter configuration. Signal's daemon-level switch is a worse fit because it runs before Hermes authorization and filtering. Keeping Signal disabled by default preserves existing behavior and the privacy-safe default. The implementation direction is resolved and the PR is ready for normal upstream review. ## Multiplex profile boundary The built-in Signal adapter receives the existing GatewayRunner back-reference so profile routing is resolved before receipt authorization. Primary and secondary Signal transports are stamped with their owner profile. A receipt is never sent when an explicit route is rejected or selects a different transport profile, because that adapter's authorization callback cannot establish the other profile's policy. This fixes a production-ordering edge found in final review: before the runner wiring, a primary Signal adapter could authorize and schedule a receipt under the default profile, after which normal ingress would resolve the route and drop the message as targeting an unserved profile. ## Prior art and credit This branch preserves Nigel Gibbs' original commit/authorship from #3705, then modernizes it for current main. It also incorporates lessons from the closed #1958, #4769, and #5014 attempts: - consistent opt-in default - no non-secret behavior flag in `.env` - authorization and adapter-filter gating - Note-to-Self protection - correct group-author semantics - lifecycle-managed background work - current-main tests and documentation ## Configuration ```yaml signal: send_read_receipts: true ``` ## Validation Refreshed conflict-free against `main@88ab589f6` on 2026-08-13. - focused Signal + profile-routing/multiplex/hook matrix after refresh: **123 passed, 0 failed** - contract coverage pins the canonical plural-array receipt payload - real factory/envelope-path tests cover active-profile, served cross-profile, and unserved-profile routes plus secondary transport stamping - Ruff and `git diff --check`: clean - independent, adversarial, and Claude Opus 4.8 final reviews: PASS after reproducing and fixing the factory/routing-order edge - no live gateway message was sent",
          "url": "https://github.com/NousResearch/hermes-agent/pull/67495",
          "createdAt": "2026-07-19T12:58:00Z",
          "updatedAt": "2026-08-13T13:25:57Z",
          "timestamp": "2026-08-13T13:25:57Z",
          "metrics": {
            "reactions": 0,
            "comments": 9
          },
          "labels": [
            "type/feature",
            "comp/gateway",
            "platform/signal",
            "area/config",
            "P3",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "poisdahl",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:8c87cce7b2d3f8ad0f6b",
        "signalId": "github:NousResearch/hermes-agent:issue:64335",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:64335",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Bug: sanitize_api_messages dedup pass writes tool_calls: [] — HTTP 400 on DeepSeek/OpenAI",
          "text": "## Summary In `sanitize_api_messages()`, the deduplication pass that removes duplicate `tool_call_id`s (#58327) can produce an empty `tool_calls: []` array on an assistant message when ALL of its tool calls are deduped. Strict providers (DeepSeek v4, newer OpenAI) reject this with HTTP 400: ``` Invalid 'messages[N].tool_calls': empty array. Expected an array with minimum length 1, but got an empty array instead. ``` ## Root cause The existing first-pass fix for empty arrays (#58755, #5737) covers messages that already *store* `tool_calls: []` on disk, but it runs **before** the dedup pass. When the dedup pass (`agent/agent_runtime_helpers.py` ~line 2600) removes every tool_call from a message because all IDs were already seen earlier in the same request, it writes: ```python msg = {**msg, \"tool_calls\": kept_tcs} # kept_tcs == [] → tool_calls: [] ``` This newly-created `tool_calls: []` is never caught by any later sanitizer stage. ## Reproduction conditions - Long sessions (200+ messages) where the same `tool_call_id` string appears on multiple assistant messages across different turns - The agent sends the full history as `conversation_history` in one shot (WebUI pattern; TUI accumulates incrementally, so it is unaffected) - Affects both direct DeepSeek and opencode-go providers ## Fix When `kept_tcs` is empty after dedup, drop the `tool_calls` key entirely instead of writing an empty list: ```python if kept_tcs: msg = {**msg, \"tool_calls\": kept_tcs} else: msg = {k: v for k, v in msg.items() if k != \"tool_calls\"} ``` ## Regression scope Introduced by #58327 (tool_call_id dedup). The earlier fixes #58755 / #5737 were insufficient because they only sanitize the *input* to sanitize_api_messages, not the intermediate state produced by the dedup pass itself.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/64335",
          "createdAt": "2026-07-14T10:35:17Z",
          "updatedAt": "2026-08-13T13:25:51Z",
          "timestamp": "2026-08-13T13:25:51Z",
          "metrics": {
            "reactions": 0,
            "comments": 5
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/openai",
            "provider/deepseek",
            "provider/qwen",
            "P2",
            "sweeper:risk-session-state"
          ],
          "author": "twunji",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:f512717c7a10fea2fec6",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85309",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85309",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat: add Nous Blue Contrast theme",
          "text": "## Summary - add a separate `nous-blue-contrast` dashboard theme that preserves the existing Nous Blue chrome while using a dark navy embedded terminal - add an independently selectable TUI/CLI skin with a complete high-contrast Nous Blue palette - expose both additions through existing catalogs and settings, with user documentation ## Motivation The existing `nous-blue` dashboard theme intentionally remains a light theme. The embedded TUI can benefit from a dark terminal canvas and matching truecolor skin without changing that existing appearance. ## Test plan - [x] `npm test -- --run src/themes/presets.test.ts` (4 passed) - [x] `npm run typecheck` - [x] `npm run build` - [x] targeted Python skin/palette tests before rebasing (35 passed) - [x] targeted dashboard theme tests before rebasing (9 passed) - [ ] post-rebase Python rerun unavailable locally: the only standalone pytest interpreter is Python 3.9, while current `main` imports Python 3.10+ union syntax; CI should run these with the supported interpreter ## Notes for reviewers - `nous-blue` is unchanged. - Dashboard themes and TUI skins remain independent configuration layers; selecting one does not silently activate the other. - The palette tests cover dark polarity, foreground contrast, shared identity anchors, and built-in registration.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85309",
          "createdAt": "2026-08-13T13:06:47Z",
          "updatedAt": "2026-08-13T13:25:48Z",
          "timestamp": "2026-08-13T13:25:48Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "duplicate",
            "type/feature",
            "comp/cli",
            "area/config",
            "P3",
            "comp/dashboard"
          ],
          "author": "gedigi",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a59f4d1c101407894036",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85310",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85310",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(compress): persist in-place prompt with transcript rewrite",
          "text": "## What does this PR do? In-place context compaction already rewrites the transcript atomically via `archive_and_compact()`, then called `update_system_prompt()` in a **second** write. A crash or SQLite failure between those two commits left a session with compacted messages and the old system prompt (split-brain on resume). Rotation already published prompt + handoff in one transaction. This PR gives the in-place path the same contract: `archive_and_compact(..., system_prompt=...)` stores the new prompt in the same `BEGIN IMMEDIATE` as the archive. A failed prompt store rolls back the transcript rewrite. ## Related Issue Fixes #84722 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_state.py`: optional `system_prompt` on `archive_and_compact`, written in the same transaction as the message archive / `model_config_patch`. - `agent/conversation_compression.py`: in-place path passes `new_system_prompt` into `archive_and_compact` and no longer calls `update_system_prompt` afterwards. - `tests/run_agent/test_compression_persistence.py`: success path plus a store-failure rollback. ## How to Test 1. On `origin/main`, in-place compaction calls `update_system_prompt` after `archive_and_compact` (two writes). 2. `pytest tests/run_agent/test_compression_persistence.py::TestFlushAfterCompression::test_archive_and_compact_persists_system_prompt_in_same_write -q` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — or N/A - [x] I've updated `cli-config.yaml.example` — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — or N/A - [x] I've considered cross-platform impact — or N/A - [x] I've updated tool descriptions/schemas — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85310",
          "createdAt": "2026-08-13T13:07:22Z",
          "updatedAt": "2026-08-13T13:25:39Z",
          "timestamp": "2026-08-13T13:25:39Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "P2",
            "sweeper:risk-session-state",
            "area/sessions",
            "area/compression"
          ],
          "author": "Christopher-Schulze",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e2bc0cea012cf62f6da3",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85311",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85311",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(whatsapp): do not send pairing codes in self-chat mode",
          "text": "## What does this PR do? WhatsApp **self-chat** is the owner's personal number, not a public bot. With the default `WHATSAPP_DM_POLICY=pairing`, unknown DMs still received `Here's your pairing code: …` — confusing and unprofessional (#84706). Self-chat now defaults unauthorized DMs to **ignore**, even when `dm_policy` is `pairing`. An explicit `unauthorized_dm_behavior` override still wins. Bot mode is unchanged and still pairs when no allowlist is set. ## Related Issue Fixes #84706 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `plugins/platforms/whatsapp/adapter.py`: remember `_whatsapp_mode` from config/env. - `gateway/authz_mixin.py`: `_get_unauthorized_dm_behavior` returns `ignore` for WhatsApp self-chat. - `tests/gateway/test_unauthorized_dm_behavior.py`: self-chat + pairing policy stays silent; bot-mode pairing test sets `WHATSAPP_MODE=bot`. ## How to Test 1. `pytest tests/gateway/test_unauthorized_dm_behavior.py -q` 2. Configure `WHATSAPP_MODE=self-chat`, `WHATSAPP_DM_POLICY=pairing`, allowlist yourself; a stranger DM must not receive a pairing code. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — or N/A - [x] I've updated `cli-config.yaml.example` — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — or N/A - [x] I've considered cross-platform impact — or N/A - [x] I've updated tool descriptions/schemas — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85311",
          "createdAt": "2026-08-13T13:12:25Z",
          "updatedAt": "2026-08-13T13:25:33Z",
          "timestamp": "2026-08-13T13:25:33Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "comp/plugins",
            "platform/whatsapp",
            "area/config",
            "P3",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility"
          ],
          "author": "Christopher-Schulze",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:68da3f0ae6b7f01068f7",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85312",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85312",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "docs: add Arabic (ar) translation for README",
          "text": "## Description Adds an Arabic translation of `README.md`. Arabic is already a supported language in the product (`agent/i18n.py`, desktop/web `ar.ts` locales) but had no README translation. ### Changes made: - Added `README.ar.md` following the `README.ur-pk.md` conventions: `<div dir=\"rtl\">` wrapper, `<div dir=\"ltr\">` around code blocks and command tables. - Terminology matches the repo's existing `ar.ts` locales. Technical terms without an established Arabic form keep the English in parentheses on first use — e.g. «بنية بلا خوادم (serverless)». - Added the Arabic language badge to the four existing READMEs. The badge uses pre-shaped Presentation Forms-B glyphs (`ﺍﻟﻌﺮﺑﻴﺔ`) because shields.io does no Arabic shaping — plain `العربية` renders as disconnected letters. ## Type of change - [x] Documentation / Translation addition ## How Has This Been Tested? - Rendered in Chromium: RTL prose, LTR code blocks, tables, and the badge all display correctly. - Checked cross-links between all five READMEs. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own changes - [x] I have made corresponding changes to the documentation",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85312",
          "createdAt": "2026-08-13T13:15:40Z",
          "updatedAt": "2026-08-13T13:25:28Z",
          "timestamp": "2026-08-13T13:25:28Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/docs",
            "P3",
            "area/i18n"
          ],
          "author": "aghyad97",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:face7bca9350c1e70d57",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85313",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85313",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): replay MiniMax thinking blocks on Anthropic endpoint",
          "text": "Fixes #85251 ## Problem `_manage_thinking_signatures` in `agent/anthropic_adapter.py` strips **all** thinking blocks from replayed assistant turns for third-party Anthropic-Messages endpoints. MiniMax was grouped into that generic \"cannot validate them and will reject them outright\" bucket, but that premise does not hold for MiniMax: it returns *signed* thinking blocks and accepts them back verbatim. MiniMax is an interleaved-thinking model, so stripping them costs real agentic performance — by MiniMax's own published figures, Tau² 87 → 64 and BrowseComp 44.0 → 31.4. Kimi (#13848) and DeepSeek (#16748) each already have an exception for the same class of problem; MiniMax had none. ## Fix Give MiniMax the same replay-as-is treatment as the Kimi family: in `_manage_thinking_signatures`, add a branch for `_is_minimax_anthropic_endpoint` (which already exists and covers both `api.minimax.io/anthropic` and `api.minimaxi.com/anthropic`) that preserves thinking blocks on replay. Also drop MiniMax from the docstring's \"will reject them outright\" list, since that sentence made the old behavior look intentional. ## Validation - New regression test `tests/agent/test_minimax_thinking_replay.py`: - Both MiniMax hosts keep their signed thinking blocks on replay (fail before the fix, pass after). - An unrelated third-party host still loses them (control). - Existing Kimi / DeepSeek / MiniMax / thinking-block-order suites: 30 passed. - `ruff check` clean. Diff is minimal: one branch + docstring in the adapter, one new test file.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85313",
          "createdAt": "2026-08-13T13:17:42Z",
          "updatedAt": "2026-08-13T13:25:24Z",
          "timestamp": "2026-08-13T13:25:24Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "duplicate",
            "comp/agent",
            "provider/minimax",
            "P3",
            "needs-decision",
            "sweeper:risk-compatibility"
          ],
          "author": "andyst-dev",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:9f861b440d8cbe1d78be",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85263",
        "event": "changed",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85263",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(webhook): stop route scripts from losing authenticated delivery identity",
          "text": "## What does this PR do? Webhook route scripts currently receive only payload JSON, so they cannot bind work to the event type and delivery identity that Hermes authenticated. This change passes bounded request metadata through a sanitized environment, rejects script deliveries without an external identity, and applies idempotency before script execution so a retry cannot run a stateful script twice. ### Symptom A signed GitHub webhook reaches a configured route script without `X-GitHub-Event` or `X-GitHub-Delivery`. A repeated delivery can also execute the script again before Hermes recognizes the duplicate. ### Impact Stateful script consumers cannot reliably associate processing with the authenticated delivery. They must otherwise infer identity from attacker-controlled payload data, and duplicate webhook retries can repeat script side effects. ### Bug Cause **Trigger:** `gateway/platforms/webhook.py:738` in `WebhookAdapter._handle_webhook` **Causal chain:** 1. Hermes validates the request signature and resolves the event type from request headers. 2. The adapter calls `run_route_script` with only parsed payload data, and it checks delivery idempotency only after the script returns. 3. The child process cannot observe authenticated metadata, while duplicate deliveries can invoke it more than once. **Why it is wrong:** The trusted request metadata is dropped at the subprocess boundary, and the side-effecting script runs outside the delivery idempotency boundary. **Working sibling / contrast:** Non-script routes do not need subprocess metadata and retain their timestamp fallback when no external delivery ID is present. **Ruled out:** Payload lookalike fields are not a safe substitute because the request body is attacker-controlled even when the signature identifies its sender. Live verification confirmed those fields did not populate the script environment on the base revision. ### Fix Resolve one bounded external delivery ID before script execution, require valid event and delivery metadata for script routes, and pass both values as `HERMES_WEBHOOK_EVENT_TYPE` and `HERMES_WEBHOOK_DELIVERY_ID` in the sanitized subprocess environment. The same delivery ID now gates script execution and downstream processing. Documentation and integration tests cover exact propagation, payload non-override, fail-closed missing identity, and duplicate suppression. ## Related Issue Closes #85244 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `gateway/platforms/webhook.py` - resolve and validate authenticated metadata before idempotency and route-script execution. - `gateway/platforms/webhook_filters.py` - expose bounded event and delivery values to scripts through the sanitized child environment. - `tests/gateway/test_webhook_adapter.py` - cover signed propagation, payload non-override, missing identity, and duplicate execution. - `website/docs/user-guide/messaging/webhooks.md` - document the route-script metadata contract. ## How to Test 1. Send a correctly signed webhook with `X-GitHub-Event` and `X-GitHub-Delivery` to a route with a real script and verify the script receives the exact values. 2. Repeat the same delivery and verify the script invocation count remains one; omit the delivery header and verify Hermes returns HTTP 400 without invoking the script. 3. Run the targeted gateway suite (41 passed locally): ```bash scripts/run_tests.sh tests/gateway/test_webhook_adapter.py tests/gateway/test_webhook_deliver_only.py ``` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run the repository test entry on the relevant tests and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Windows 11 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - [x] `cli-config.yaml.example` is N/A because this change adds no config keys - [x] `CONTRIBUTING.md` and `AGENTS.md` are N/A because this does not change contributor workflows - [x] I've considered cross-platform impact; the existing script launcher remains platform-specific and the metadata contract is platform-neutral - [x] Tool descriptions and schemas are N/A because this does not change a model tool ## Screenshots / Logs Live verification with an aiohttp listener and a real script subprocess reproduced the missing metadata, duplicate invocation, and synthesized missing identity on the base revision. At commit `a8e3dd055`, the script received the exact authenticated values, duplicate invocation remained at one, and missing identity returned HTTP 400 before script execution.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85263",
          "createdAt": "2026-08-13T11:38:59Z",
          "updatedAt": "2026-08-13T13:24:11Z",
          "timestamp": "2026-08-13T13:24:11Z",
          "metrics": {
            "reactions": 0,
            "comments": 5
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "platform/webhook",
            "P2",
            "sweeper:risk-message-delivery"
          ],
          "author": "fangliquanflq",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:4e53df5a01307a6dcc94",
        "signalId": "github:NousResearch/hermes-agent:issue:85301",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85301",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Bug: MCP servers page ignores the active profile — shows default profile's servers in any profile",
          "text": "**Bug: MCP servers page ignores the active profile — shows default profile's servers in any profile** I run Hermes with two profiles (default + a secondary business profile). MCP servers are configured **only** in default (`mcp_servers` in default's config.yaml). The secondary profile has no `mcp_servers` at all — `hermes --profile <name> mcp list` confirms \"No MCP servers configured\". **What happens:** in the desktop app / web dashboard, when I switch to the secondary profile and open the MCP page, it still lists all MCP servers from the **default** profile — and they show as error/failed, because they are not actually connected under the secondary profile's runtime. **Root cause (frontend):** `getMcpServers()` calls `GET /api/mcp/servers` **without** the `profile` query param — unlike `getSessions`, `getCronJobs`, `getToolsets` etc., which pass the active profile (`profile=v()`). The dashboard process runs under the default profile's home, so it always returns default's servers regardless of the profile selected in the UI. **Expected:** the MCP servers page should be profile-scoped — selecting a profile with no MCP servers should show an empty list, not the default profile's servers (with bogus error states). **Suggested fix:** pass the active profile to `getMcpServers` (and for consistency to `testMcpServer`, `setMcpServerEnabled`, `removeMcpServer`, `authMcpServer`). Related: #80746, #85204 (runtime side — different layer, but same profile-scoping theme).",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85301",
          "createdAt": "2026-08-13T13:05:24Z",
          "updatedAt": "2026-08-13T13:22:45Z",
          "timestamp": "2026-08-13T13:22:45Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "tool/mcp",
            "P2",
            "comp/dashboard",
            "area/profiles"
          ],
          "author": "antonbru",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:78f30b82329cf56794d9",
        "signalId": "github:NousResearch/hermes-agent:issue:85297",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85297",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: install.sh prints \"Installation Complete!\" and exits 0 after install_node_deps fails, leaving no node_modules",
          "text": "`scripts/install.sh` prints \"Installation Complete!\" and exits 0 after `install_node_deps` fails, leaving no `node_modules`. ## What happens `install_node_deps` runs `npm install` inside the cloned repo. The failure is caught under a `log_warn` and execution continues; `log_success` then runs unconditionally, `main()` reaches the completion banner, and the script exits **0**. So a dependency install can fail outright and the installer reports success. The hermes launcher is written and works — `hermes --version` responds — while the repo has no `node_modules` at all. ## Observed Automated image build, 2026-08-12, Ubuntu 24.04, node v22.23.2, installing from a pinned ref (`9de9c25f620ff7f1ce0fd5457d596052d5159596`). From npm's own debug log on the resulting disk: ``` verbose cwd /home/agent/.hermes/hermes-agent verbose argv \"install\" \"--loglevel\" \"silent\" ... error at ClientRequest.<anonymous> (.../got/dist/source/core/index.js:970:111) silly unfinished npm timer build:run:postinstall:apps/desktop/node_modules/electron verbose exit 1 verbose code 1 ``` A TLS/network failure during electron's postinstall. `npm` exited 1. The installer then printed: ``` ┌─────────────────────────────────────────────────────────┐ │ ✓ Installation Complete! │ └─────────────────────────────────────────────────────────┘ ``` and exited 0. The final image had no `~/.hermes/hermes-agent/node_modules`. The underlying network failure is transient and not the complaint — the issue is that it was reported as success. ## Why it is hard to catch downstream The obvious post-install checks all pass on the broken result: - `/home/agent/.local/bin/hermes` exists and is executable — the launcher is written regardless. - `hermes --version` responds correctly. - The root `package.json` declares **no** runtime dependencies (everything real lives in the workspaces), so \"do the root package's deps resolve\" is vacuously true on a broken tree as well as a healthy one. A consumer therefore has to know the workspace layout to write a check that can fail at all. In our case the broken image was caught only because an unrelated step aborted the build first; otherwise it would have shipped as a clean image and failed at first use of the browser tooling. ## Suggestion Have `install_node_deps` propagate the failure — either fail the install outright, or keep the warning but exit non-zero, so the completion banner is not reached. Anything that makes the exit status distinguish the two outcomes would be enough for automated consumers. ## Reference Line reference is against the pinned ref above; `install_node_deps` is around `scripts/install.sh:2132` there, and the same shape appears in current `main`. Happy to supply the full npm debug log or test a patch if useful.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85297",
          "createdAt": "2026-08-13T12:59:11Z",
          "updatedAt": "2026-08-13T13:22:44Z",
          "timestamp": "2026-08-13T13:22:44Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "P1",
            "sweeper:risk-compatibility",
            "comp/desktop",
            "area/install-update"
          ],
          "author": "oussemamaknii",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:334d1053142552730af7",
        "signalId": "github:NousResearch/hermes-agent:pull_request:72761",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:72761",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(honcho): sync config (supersedes #33440)",
          "text": "Running gateways could keep cached Honcho managers and SDK clients after construction-frozen settings changed, while active session peers retained prior observation settings. This change: - fingerprints every resolved Honcho setting frozen into the gateway agent/provider/manager; - fingerprints secrets without storing raw key material in cache signatures; - rebuilds the process-wide SDK client when its effective workspace, credential, environment, base URL, or timeout changes; - invalidates SDK peer/session objects atomically across configuration changes; - synchronizes explicit observation policy by updating only mismatched peers, disabling observers before enabling replacements; and - preserves server/UI-managed observation settings when no local policy is explicit. Supersedes #33440. Co-authored-by: Dusk1e <yusufalweshdemir@gmail.com> Validation: - `scripts/run_tests.sh tests/honcho_plugin tests/gateway/test_agent_cache.py tests/test_honcho_client_concurrency.py -q` — 373 passed - focused Ruff — passed - worktree and upstream-range `git diff --check` — passed - Windows footgun diff scan — passed - all upstream-range commits SSH-signature verified",
          "url": "https://github.com/NousResearch/hermes-agent/pull/72761",
          "createdAt": "2026-07-27T16:41:29Z",
          "updatedAt": "2026-08-13T13:22:02Z",
          "timestamp": "2026-08-13T13:22:02Z",
          "metrics": {
            "reactions": 0,
            "comments": 5
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "comp/plugins",
            "tool/memory",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-compatibility",
            "sweeper:risk-caching",
            "sweeper:blast-contained",
            "area/memory"
          ],
          "author": "Diaspar4u",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:7c6d5f072923a13b5341",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84879",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84879",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(telegram): scope batch queues by routed profile",
          "text": "## Coordinated successor to #69156 This PR is the current-main, behavior-tested successor to the still-open #69156 — **not an independent competing implementation**. Its first commit is #69156 rebased onto current `main`, preserving **Shunkleburger** as that commit's author. The second commit is the minimal follow-up required by current ingress behavior: a routed `event.source.profile` is authoritative; `adapter.profile_name` is only a fallback for a secondary adapter before its wrapper can stamp the event. A direct stack on #69156's old branch was conflict-heavy because that branch is substantially behind `main`. Please review this successor as the coordinated integration path and do not merge overlapping implementations independently. ## Root cause Telegram delayed ingress paths construct keys before the profile-scoped message handler necessarily runs: - a shared adapter may already have a profile selected by `gateway.profile_routes` on `event.source`; - a secondary adapter may only know its owning profile on the adapter instance; - album queues previously used raw Telegram `media_group_id` values. Those paths could therefore fall back to `agent:main`, or merge album buffers that shared a raw Telegram ID. ## Changes - Preserve a source-routed profile before busy-session routing; use the adapter-owned profile only when the source is unstamped. - Register the owning secondary-adapter profile before handlers are installed. - Use the same precedence for delayed Telegram text, photo, image-document, generic-document, and album keys. - Namespace media-group buffers as `agent:<profile>:...:album:<media_group_id>` rather than raw `media_group_id`. - Preserve legacy `agent:main` behavior when neither source nor adapter has a profile. ## Regression coverage - shared adapter: routed source profile wins over adapter profile; - secondary adapter: profile fallback exists before its wrapper executes; - legacy no-profile behavior; - busy-session lookup; - receive-path text batching; - photo bursts, native-photo albums, image-document albums, and generic-document albums; - two profiles receiving the same raw `media_group_id` do not merge. ## Validation ```text scripts/run_tests.sh tests/gateway 620 files, 5335 passed, 0 failed, 8 skipped (Windows-only) ``` The full gateway suite passed on the exact final source tree. The subsequent final rebase added only upstream changes outside `gateway/`, `plugins/platforms/telegram/`, and `tests/gateway/`; the final two-commit branch has the same Git tree as that fully tested candidate. On the final current-main base (`715d26cdf492a412a60e0c0349c8c3d5bd6a6b04`), the focused integration matrix also passed: ```text 90 passed, 0 failed ``` Static validation passed: targeted `ruff`, `py_compile`, `git diff --check`, and `scripts/check-windows-footguns.py --all`. ## Scope boundaries - Does not duplicate #82980's clarify-bypass work. - Does not duplicate #81256's image-during-clarify work. - Does not modify configuration, persistent state, credentials, gateway service, or production runtime. ## Rollback No migration is involved. Reverting these two commits restores prior keying behavior.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84879",
          "createdAt": "2026-08-12T23:43:35Z",
          "updatedAt": "2026-08-13T13:21:36Z",
          "timestamp": "2026-08-13T13:21:36Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "platform/telegram",
            "P2",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery"
          ],
          "author": "fmercurio",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:8244cd758c03f5fca005",
        "signalId": "github:NousResearch/hermes-agent:issue:85294",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85294",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: Desktop cross-profile prompt leak: one profile backend resumes and writes another profile's session",
          "text": "## Bug Description Hermes Desktop violated both session and profile isolation on a local Windows installation. While secondary/compact session windows belonging to two different profiles were active, a prompt intended for Profile A appeared, executed, and was durably stored in the unrelated Profile B session. It appeared twice in Profile B. This was not only a rendering artifact: - The exact same redacted prompt hash was persisted in three distinct message rows. - One row belongs to Profile A / Session A. - Two rows belong to Profile B / Session B. - Every row has a distinct message ID and its embedded `session_id` matches the destination session. - The Profile B backend also resumed, auto-continued, and compressed Profile A's session. The replies were generated using each destination session's different conversation context. **Impact:** cross-profile information and instructions can contaminate unrelated conversation histories and be executed by the wrong agent context. This breaks the integrity and confidentiality boundary users expect from Hermes profiles. There is no evidence of external exfiltration or total database corruption in this incident, but this should be triaged as P1/High because stored conversation data crossed a profile boundary. ## Steps to Reproduce This is the exact observed incident sequence. It has not been reduced to a deterministic click-by-click reproduction because intentionally repeating it would risk additional cross-profile writes. 1. Run Hermes Desktop with two secondary/compact session windows: - Profile A, Session A: `20260812_190824_3b1b48` - Profile B, Session B: `20260813_104421_d30d11` 2. Keep both long-running sessions active so that context compression is triggered. Another Profile B session was also active concurrently. 3. An interrupted-turn marker existed for Session A. 4. Send a prompt from the Profile A window while the sessions are active/compacting. 5. Hermes displays: `No reply: the turn was stopped because session storage was busy (another Hermes process was writing to the state database).` 6. Observe that the prompt appears and is answered in Session B. 7. Inspect sanitized logs and native session exports: the Profile B backend auto-continued Session A and the same prompt was persisted once in Session A and twice in Session B. ## Expected Behavior - A backend serving Profile B must never resume, auto-continue, compress, or submit a prompt into a session owned by Profile A unless the Desktop connection has been atomically re-homed to that owning profile. - Queued prompts and interrupted-turn markers must remain bound to one immutable `(profile, stored session ID, runtime session ID)` tuple. - A prompt must execute and persist exactly once in its intended session. - Secondary windows must remain isolated even while multiple sessions are running or compressing. ## Actual Behavior - The Profile B backend processed both Session B and the unrelated Profile A Session A. - The prompt was executed in both conversation contexts. - The prompt was durably persisted three times across the two sessions. - Profile B's backend held Profile A's compression lease for approximately 207 seconds. - Profile A's own backend then encountered the foreign lease and emitted the generic storage-busy warning. ## Affected Component - Other: Hermes Desktop secondary-window and session routing - Agent Core: interrupted-turn auto-continue and context compression - Gateway/session dispatch ## Messaging Platform (if gateway-related) N/A — Hermes Desktop local session windows. ## Debug Report Not uploaded. `hermes debug share` would upload local configuration and logs containing private conversation data. Sanitized forensic evidence is included below. Raw logs can be further redacted if maintainers specify the exact fields required. ## Operating System Microsoft Windows 11 Pro, version 10.0.26200, build 26200 ## Python Version - Hermes runtime: Python 3.11.15 - System Python: 3.14.6 ## Hermes Version Hermes Agent v0.20.0 (2026.8.3) - Installed build: `5e09333d5753fc2b97b9e246c916509c8e4628fd` - Build branch: `local/update-live-20260811` - Install method: git - The installation has six carried local commits. - Current main was not installed during diagnosis in order to preserve the active sessions and incident evidence. The affected build already contains: - Fix commit `8830f22` from PR #73875 - Merge commit `c9de69c` from PR #74581 Both are verified ancestors of the installed build. ## Additional Logs / Traceback (sanitized) All timestamps are 2026-08-13, Europe/Berlin. ```text 13:54:47.922 Profile B backend: conversation turn started in Session B prompt prefix matches the duplicated prompt hash 13:55:01.237 Profile B backend: auto-continue scheduled for Session A attempt=1, interrupted=761s ago 13:55:04.140 Profile B backend: conversation turn started in Profile A's Session A prompt prefix matches the same duplicated prompt hash 13:55:04.207 Profile B backend: compression started for Session A messages=228, estimated tokens=408773 13:55:20.150 Profile A backend: conversation turn started in Session A same prompt prefix 13:55:20 Profile A backend: compression skipped because holder=pid=33840 PID 33840 is the Profile B backend 13:58:31.793 Profile B backend: compression committed for Session A messages 228 -> 50 13:58:33.556 Profile B backend: another conversation turn started in Session B same prompt prefix ``` The storage errors were compression-lease conflicts, not ordinary SQLite locks: ```text Session DB append_message failed: Session '20260812_190824_3b1b48' is being compressed by another writer ``` The UI currently maps this condition to the generic message: ```text No reply: the turn was stopped because session storage was busy (another Hermes process was writing to the state database). ``` Persisted-message evidence: ```text Prompt SHA-256: 5f7ba2df307e81a88e1cbd3ceda58189805e549ef7b5ec5a73a7521a70a163a5 Profile B / Session B: 13:58:03.393 Profile A / Session A: 13:58:31.746 Profile B / Session B: 13:58:33.562 ``` The prompt content is intentionally omitted because it contains private financial context. ## Root Cause Analysis ### Verified 1. This is a durable routing failure, not just stale rendering. 2. The Profile B backend ran a turn and compression against Profile A's Session A. 3. Profile B's process owned Profile A's compression lease. 4. The same prompt was persisted in both sessions. 5. Existing queued-drain isolation fixes from #73875 and #74581 are present in the affected build. ### Probable boundary A stale or non-atomic association between the secondary window, owning profile, stored session ID, runtime session ID, queue/marker ownership, and backend connection allowed Profile A's interrupted turn to be resumed inside Profile B's backend. Secondary-window URLs carry the session ID (`?win=secondary#/<sessionId>`) but not the owning profile. The renderer resolves the profile and re-homes the gateway connection later. The logs indicate that this handoff, or the subsequent auto-continue path, permitted a cross-profile session/runtime association. Production logs do not currently record the full tuple at `session.resume`, auto-continue scheduling, and `prompt.submit`, so the exact frontend source line is not yet proven. ## Proposed Fix 1. Treat `(owning profile, stored session ID, runtime session ID)` as one immutable routing identity for: - secondary windows; - queued prompts; - interrupted-turn markers; - `session.resume`; - auto-continue; - compression. 2. Before submitting or auto-continuing, atomically verify that the gateway connection is serving the session's owning profile. Drop and re-resume on mismatch. 3. Never let a process begin compression for a session whose resolved owning profile disagrees with the connection/window routing identity. 4. Add a regression test with: - two compact windows; - two profiles; - an interrupted-turn marker; - simultaneous long compression; - one unique prompt. The oracle should require exactly one persisted prompt row and zero turns/compressions in the unrelated profile. 5. Log the sanitized routing tuple at resume/submit time: `windowId, connectionProfile, owningProfile, storedSessionId, runtimeSessionId, queueOwner, autoContinueMarkerOwner`. ## Related Issues - #46194 — related queued-follow-up/session-switch leakage, but it does not establish this cross-profile auto-continue/compression path. - #71527 — related profile-routing failures, but its reported remote WebSocket mechanism differs from this local secondary-window incident. - #84234 and #81227 — related cross-process compression-lease contention; they explain the storage warning but not why the wrong profile backend owned the session. - #59305 — earlier general cross-session content mixing. - PR #73875 — queued-drain stale runtime binding fix, already present. - PR #74581 — origin-session queue binding fix, already present. ## Workaround No verified permanent workaround. Containment only: avoid sending or resending prompts from secondary windows belonging to different profiles while any of those sessions is running or compressing. The UI's “send it again” instruction is unsafe in this failure mode because the message may already have persisted elsewhere. ## Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85294",
          "createdAt": "2026-08-13T12:57:21Z",
          "updatedAt": "2026-08-13T13:20:59Z",
          "timestamp": "2026-08-13T13:20:59Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "P2",
            "needs-repro",
            "sweeper:risk-session-state",
            "sweeper:risk-platform-windows",
            "comp/desktop",
            "platform/windows",
            "area/sessions",
            "area/profiles"
          ],
          "author": "akivavh",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:121ff8db925f7b2ba82c",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84586",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84586",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(hindsight): add local_embedded mode and full local-mode fields to config schema",
          "text": "## Problem The desktop Memory & context panel for Hindsight (issue #84572) only declares `cloud` and `local_external` modes, so Hindsight instances configured in `local_embedded` mode display incorrect cloud defaults: - **Mode** shows Cloud (fallback from invalid `local_embedded` value) - **API URL** shows the cloud default (the actual value is silently coerced) - **API key** shows \"not set\" (wrong env var: uses HINDSIGHT_API_KEY instead of HINDSIGHT_LLM_API_KEY) This happens because the wizard schema (used by `hermes memory setup`) declares all three modes plus local-mode fields, but the desktop's `config_schema.py` only knows about cloud and local_external. ## Fix Update `plugins/memory/hindsight/config_schema.py` to mirror the wizard's full schema: 1. **Add `local_embedded`** to the mode select options 2. **Add cloud-mode fields** with `when={\"mode\": \"cloud\"}` for visibility 3. **Add local_external-mode fields** with `when={\"mode\": \"local_external\"}` 4. **Add local_embedded-mode fields**: llm_provider, llm_base_url, llm_model, llm_api_key, idle_timeout, port_health_grace_timeout 5. **Add all shared fields** from the wizard: bank_id_template, bank_mission, bank_retain_mission, memory_mode, recall_prefetch_method, retain_tags, observation_scopes, retain_source, retain_user_prefix, retain_assistant_prefix, recall_tags, recall_tags_match, recall_types, auto_recall, auto_retain, retain_every_n_turns, retain_async, prefetch_waits_for_retain, prefetch_retain_drain_timeout, retain_context, recall_max_tokens, recall_max_input_chars, recall_prompt_preamble, timeout Also implement the underlying conditional visibility infrastructure: - **`ProviderField`**: new `when: dict[str, str] | None = None` attribute - **`_read_field_dep()`**: helper to read dependency values from stored config - **`_declared_provider_payload()`**: skips fields whose `when` predicate is not met This makes the desktop panel faithfully reflect whatever mode the wizard configured. ## Test Plan - [x] Lint passes (pyright) - [ ] Verify Hindsight panel shows correct values in local_embedded mode on desktop",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84586",
          "createdAt": "2026-08-12T14:19:40Z",
          "updatedAt": "2026-08-13T13:20:36Z",
          "timestamp": "2026-08-13T13:20:36Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/plugins",
            "tool/memory",
            "P3",
            "sweeper:risk-compatibility",
            "area/memory"
          ],
          "author": "blut-agent",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a0f34e9d3e287eb19e6a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85011",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85011",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cron): honest fallback-chain alerts + drift-guard alert-once",
          "text": "Cron failure alerts around the fallback chain were lying in three ways, all field-reported from an enterprise fleet running ~40 jobs: 1. Every provider failure said \"Fallback chain was exhausted or unavailable\" even when no chain was configured, sending operators to debug a fallback that never ran. 2. The scheduler's own inactivity watchdog (a stuck tool call) was reported as a \"provider timeout\" — wrong system entirely. 3. A fleet-wide config change made every unpinned job re-alert on every tick: 40 jobs, one identical drift alert each, every tick, until each was hand-pinned. ## What changed - **Cherry-picked #81579** (@georgell-ceo, authorship preserved): `_fallback_chain_phrase()` distinguishes \"no chain configured\" from genuine exhaustion, and inactivity timeouts are classified before the generic timeout match. 6 tests came with it. - **Empty-chain alert names the fix**: `hermes fallback add`, or the `cron.model` + `cron.model_provider` fleet defaults. The exhausted branch stays terse — the chain is intact there, no config command applies. - **Drift-guard skips alert once per job, not per tick**: reuses the exact alert-once shape the blocked-config preflight already established — a persisted `drift_alerted` bit, a `:silent` marker variant that suppresses re-delivery, cleared on the next successful run so a future drift re-alerts. Only the drift branch consults the bit; every other failure still alerts per tick. - **Drift alert delivered untruncated**: the generic summarizer's 180-char cap was cutting the alert off before the pin command. The drift branch formats its own delivery, so the one alert the operator gets contains the actual fix. (Found by mutation-probing the delivery path during review.) ## Verification - 12 new tests across three files, all asserting delivered content through `run_one_job` + `_deliver_result`, not helper internals. - Mutation checks: gutting the chain check fails 2 tests; removing the inactivity branch fails 1; disabling the silent suppression fails the alert-once test. - Full cron suite via `scripts/run_tests.sh tests/cron/`: 47 files, exit 0. ## Not in this PR (parked as follow-ups) - `hermes cron doctor` with bulk re-snapshot/re-pin verbs (a read-only base exists in #43729). - Setup/onboarding prompt for fallback configuration — product UX call first. - No retry machinery added: retry-with-backoff across the chain already exists in the conversation loop once a chain is configured.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85011",
          "createdAt": "2026-08-13T03:27:32Z",
          "updatedAt": "2026-08-13T13:20:35Z",
          "timestamp": "2026-08-13T13:20:35Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/cron",
            "P2",
            "sweeper:risk-message-delivery"
          ],
          "author": "victor-kyriazakos",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:472a7de4d37570873d98",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84387",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84387",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(api): stamp approval.request events with session_id",
          "text": "## Problem `/v1/runs` SSE streams emit `approval.request` events **without `session_id`** — only `run_id`. Every other event in the same stream goes through `_event_payload()`, which injects `session_id`; `_approval_notify` pushes the raw dict straight onto the queue. The Desktop renderer routes gateway events through `resolveGatewayEventSessionId()` (`apps/desktop/src/lib/gateway-events.ts`). With no explicit `session_id` on `approval.request`, the event falls into the unscoped-stream bucket and resolves to `unscopedStreamSessionId || activeSessionId`. For a remote approval that arrives outside an active `message.start`-pinned stream, that resolves to `null`, the prompt is parked under `keyFor(null) = \"\"`, and **no approval UI renders** — the agent stalls until the approval timeout and the tool fails closed with no way to approve. This is the transport half of #83443 (remote Desktop terminal approvals time out with no visible prompt). ## Fix Stamp `session_id` on the `approval.request` frame in `_approval_notify`, mirroring every other `/v1/runs` event. The frontend's explicit-session route then wins: the prompt lands on the right session and the inline/fallback approval UI appears. ## Validation - E2E on a live gateway (`/api/ws` JSON-RPC, v0.20.0): `session.create` returns `session_id=aa1038bc`; the subsequent `approval.request` frame now carries the same `session_id`, matching the frontend's `$activeSessionId` (previously the event had no session field at all). - `tests/gateway/test_api_server_runs.py`: unchanged pass/fail set vs baseline (the pre-existing failures are environment LLM/network dependent, identical with and without this change). Refs #83443",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84387",
          "createdAt": "2026-08-12T07:58:20Z",
          "updatedAt": "2026-08-13T13:20:28Z",
          "timestamp": "2026-08-13T13:20:28Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "P2",
            "sweeper:risk-message-delivery"
          ],
          "author": "yflmq001",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:3bc806aba60a4f29d21a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:44878",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:44878",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(agent): per-call output speed (tokens/sec) for desktop status bar and runtime footer",
          "text": "## Summary Stores per-API-call timing and token counts on the agent so downstream consumers (desktop status bar, gateway runtime footer, CLI status bar) can display real-time tokens-per-second. - **run_agent.py**: Initialize `last_api_duration` / `last_output_tokens` on agent - **agent/conversation_loop.py**: Persist per-call `api_duration` + `output_tokens` after each successful API response - **tui_gateway/server.py**: Compute `output_speed` in `_get_usage()` (read by desktop `session.info` events and `session.usage` RPC) - **cli.py**: Expose `output_speed` in TUI status bar when `display.show_output_speed` is enabled - **gateway/runtime_footer.py**: Add `tps` field for gateway platforms (messaging, desktop response footer) - **gateway/run.py**: Wire per-call stats into agent result + footer call site - **hermes_cli/config.py**: Add `display.show_output_speed` toggle (default off) ## Validation - Verified end-to-end: `conversation_loop.py` SETs `last_api_duration` and `last_output_tokens` (confirmed via debug log) - `tui_gateway/server.py` `_get_usage()` reads them and emits `output_speed` (confirmed 48.0 tok/s for DeepSeek v4 Pro) - Desktop frontend already has `output-speed` status bar item (needs rebuild to display) Closes #9527 Closes #26877",
          "url": "https://github.com/NousResearch/hermes-agent/pull/44878",
          "createdAt": "2026-06-12T11:50:47Z",
          "updatedAt": "2026-08-13T13:18:47Z",
          "timestamp": "2026-08-13T13:18:47Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/cli",
            "comp/gateway",
            "P3",
            "sweeper:risk-compatibility",
            "sweeper:blast-broad"
          ],
          "author": "beresfordsalmon",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:4e48fe3108915a63e099",
        "signalId": "github:NousResearch/hermes-agent:pull_request:58687",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:58687",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(update): honor configured bootstrap state",
          "text": "## What does this PR do? Update/bootstrap orchestration now respects existing configuration before running heavyweight browser dependency installs or interactive setup stages. ## Related Issue ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `hermes_cli/dep_ensure.py`: skip `install.sh --ensure browser` when the browser toolset is disabled via `agent.disabled_toolsets` or primary CLI toolset config. - `hermes_cli/main.py`: skip `hermes update` Node/browser/web refresh when the primary browser toolset is disabled, while preserving the existing Termux Python dependency path. - `scripts/install.sh`: make staged bootstrap manifests incremental by omitting setup/gateway stages when an existing config and credential file are present; non-interactive skipped stage JSON now reports the skip reason. - Tests cover disabled browser lazy ensure, update node-refresh skipping, and configured `install.sh --manifest --include-desktop` behavior. ## Shared root cause - Update/bootstrap orchestration treated optional dependency and setup stages as unconditional follow-up work after code/dependency refresh. - The shared fix is to consult the already-existing configuration surfaces before spawning heavyweight browser installers, Node/web refreshes, or interactive staged setup. ## How this fixes each issue - #37231: `hermes tools disable browser` now prevents lazy browser dependency ensure from spawning the browser installer, and `hermes update` skips Node/browser refresh when the browser toolset is disabled. - #37874: staged `install.sh --include-desktop` manifests omit setup/gateway stages for already configured installs, so desktop/bootstrap drivers do not rerun interactive setup work just to add the desktop build. - #39411: the update path keeps the existing Termux-specific Python dependency handling intact while preventing unrelated optional Node/browser stages from running unconditionally after Python dependency refresh. ## Supersedes N/A - no member PRs were provided for this spanning group. ## How to Test 1. `/opt/homebrew/bin/timeout -k 30 480 sh -c 'pytest tests/hermes_cli/test_dep_ensure.py tests/hermes_cli/test_cmd_update.py tests/test_install_sh_browser_install.py -q --timeout=60'` 2. `{ git diff --name-only --diff-filter=d origin/main; git ls-files --others --exclude-standard; } | grep -E '\\.pyi?$' | sort -u | xargs ruff check` 3. `bash -n scripts/install.sh && git diff --check` 4. `/opt/homebrew/bin/timeout -k 30 480 sh -c 'pytest tests/ -q -x --timeout=60 \"$@\"' sh` was attempted; collection aborted before changed tests because this local Python environment lacks `fastapi`, and lazy install failed under Homebrew's PEP 668 externally-managed environment. ## What platforms tested on - macOS on darwin-arm64 (local) ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched existing context/source for related update/bootstrap behavior - [x] My PR contains only changes related to this fix/feature - [ ] I've run `pytest tests/ -q` and all tests pass (attempted; blocked during collection by missing `fastapi`/PEP 668 local environment) - [x] I've added tests for my changes - [x] I've tested on my platform: macOS on darwin-arm64 ### Documentation & Housekeeping - [x] Documentation update N/A - [x] `cli-config.yaml.example` update N/A - [x] `CONTRIBUTING.md` / `AGENTS.md` update N/A - [x] Cross-platform impact considered - [x] Tool descriptions/schemas update N/A ## Screenshots / Logs Focused regression tests: 60 passed, 12 pre-existing warnings. --- _This coordinated PR bundles a fix that spans several issues. Happy to split it back into focused per-issue PRs if you'd prefer to review them separately._ Refs #37231 Refs #37874 Refs #39411 <!-- autocontrib:worker-id=pr-spanning-c406a0b5 kind=pr-open -->",
          "url": "https://github.com/NousResearch/hermes-agent/pull/58687",
          "createdAt": "2026-07-05T06:34:10Z",
          "updatedAt": "2026-08-13T13:17:20Z",
          "timestamp": "2026-08-13T13:17:20Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "area/config",
            "P3",
            "sweeper:risk-compatibility",
            "sweeper:blast-broad",
            "area/install-update"
          ],
          "author": "konsisumer",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:37b25d11a44a188f7ada",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85292",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85292",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cli): override MiniMax API-key overlay to Anthropic endpoint",
          "text": "## What does this PR do? The MiniMax **API-key** overlay advertises `anthropic_messages` but had no `base_url_override`. `get_provider()` therefore kept the models.dev OpenAI-style URL (`https://api.minimax.io/v1`). Calls then hit `/v1/messages` (or `/v1/chat/completions` if mode drifted) and 404. The OAuth sibling already overrode to `https://api.minimax.io/anthropic`. This gives the same override to the API-key overlays (`minimax` and `minimax-cn`). `MINIMAX_BASE_URL` / `MINIMAX_CN_BASE_URL` and an explicit user `model.base_url` still win. ## Related Issue Fixes #84838 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_cli/providers.py`: set `base_url_override` on `HERMES_OVERLAYS[\"minimax\"]` and `[\"minimax-cn\"]` to the Anthropic-compatible endpoints already used by `PROVIDER_REGISTRY` and `minimax-oauth`. - `tests/hermes_cli/test_api_key_providers.py`: regression that feeds a fake models.dev `/v1` catalog URL and asserts the overlay keeps `/anthropic`; runtime resolve for `minimax` asserts the same URL and `anthropic_messages`. ## How to Test 1. On `origin/main`, `HERMES_OVERLAYS[\"minimax\"].base_url_override` is empty. `get_provider(\"minimax\")` can inherit models.dev `https://api.minimax.io/v1`. 2. On this branch, `pytest tests/hermes_cli/test_api_key_providers.py::TestProviderRegistry::test_minimax_overlay_overrides_models_dev_openai_url tests/hermes_cli/test_api_key_providers.py::TestRuntimeProviderResolution::test_runtime_minimax -q` passes. 3. `MINIMAX_API_KEY=… hermes` with provider `minimax` and no custom `model.base_url` should talk to `https://api.minimax.io/anthropic`. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85292",
          "createdAt": "2026-08-13T12:55:34Z",
          "updatedAt": "2026-08-13T13:14:06Z",
          "timestamp": "2026-08-13T13:14:06Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "provider/minimax",
            "P3"
          ],
          "author": "Christopher-Schulze",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:165d485c5bd5270cb96e",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85293",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85293",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat: add Nous Blue Contrast theme",
          "text": "## Summary - add a separate `nous-blue-contrast` dashboard preset that preserves Nous Blue chrome while using a navy, high-contrast embedded terminal - add a matching built-in TUI skin with a complete audited dark palette - expose both choices in dashboard configuration and document them - keep the existing `nous-blue` theme unchanged ## Test plan - `scripts/run_tests.sh tests/hermes_cli/test_skin_engine.py tests/hermes_cli/test_skin_palettes.py` (35 passed) - `npm run check --workspace web` (200 tests passed; typecheck and lint passed) ## Notes - independent pre-commit review found no blocking, security, or privacy issues - lint reports only the existing repository warnings, with no errors",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85293",
          "createdAt": "2026-08-13T12:56:52Z",
          "updatedAt": "2026-08-13T13:14:01Z",
          "timestamp": "2026-08-13T13:14:01Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "area/config",
            "P3",
            "comp/dashboard"
          ],
          "author": "gedigi",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:239b08c3a0ed54365e03",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85296",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85296",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(delegation): wait before parent conclusions",
          "text": "## Summary - make model-facing `delegate_task` calls synchronous by default so parents receive every subagent result before concluding - preserve detached execution behind explicit `background=true` for independent work - keep orchestrator workers synchronous even if a model supplies `background=true` - update the tool schema, bundled skill reference, and delegation docs ## Why Automatic background delegation lets the parent send an answer before its evidence-gathering children finish. Their later completion then re-enters as a second turn, often producing a repetitive correction. Waiting by default gives the parent one consolidated evidence set and one conclusion. ## Validation RED: the new default-wait dispatch test failed against the previous forced-background behavior. GREEN: `scripts/run_tests.sh tests/tools/test_delegate.py tests/tools/test_async_delegation.py tests/tools/test_delegate_apiserver_background.py tests/cli/test_cli_delegate_background_notice.py -q` Result: 92 passed. ## Compatibility Explicit `background=true` keeps the current detached completion path for callers that truly want independent work. Direct Python callers already defaulted to synchronous execution.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85296",
          "createdAt": "2026-08-13T12:59:07Z",
          "updatedAt": "2026-08-13T13:13:57Z",
          "timestamp": "2026-08-13T13:13:57Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "tool/delegate",
            "P2",
            "needs-decision"
          ],
          "author": "etothej7",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:9f12536240a16ebf19c9",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85299",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85299",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(install): provision Node 26 so managed npm satisfies engines",
          "text": "## What does this PR do? `scripts/install.sh` still provisioned Hermes-managed **Node 22**, whose stock npm is **11.16.0**. Root `engines.npm` is `<11.10.0 || >=11.17.0` (npm 11.10–11.16 ignore `.npmrc` `min-release-age-exclude`, so that band is fatal under `engine-strict=true`). A fresh managed install therefore dies at the first `npm ci` with EBADENGINE — the report in #80769. Node 26 ships npm 11.17.0. Point `NODE_VERSION` at 26 so the runtime we download satisfies the floor we declare. `install.sh` already warns \"Hermes requires Node >=26\"; the pin was just stale. ## Related Issue Fixes #80769 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `scripts/install.sh`: `NODE_VERSION=\"26\"` (latest-v26.x from nodejs.org). - `tests/test_engines_satisfiable.py`: assert the stock npm bundled with the managed Node major satisfies `engines.npm`. ## How to Test 1. On `origin/main`, `NODE_VERSION` is `22`. Node 22's stock npm is 11.16.0, which `engines.npm` rejects. 2. `pytest tests/test_engines_satisfiable.py -q` on this branch — 11 passed. 3. A fresh `install.sh` managed Node now comes from `latest-v26.x`. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — or N/A - [x] I've updated `cli-config.yaml.example` — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — or N/A - [x] I've considered cross-platform impact — or N/A - [x] I've updated tool descriptions/schemas — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85299",
          "createdAt": "2026-08-13T13:03:09Z",
          "updatedAt": "2026-08-13T13:13:51Z",
          "timestamp": "2026-08-13T13:13:51Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "P1",
            "sweeper:risk-compatibility",
            "area/install-update"
          ],
          "author": "Christopher-Schulze",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:67092246dd98b83d7fc7",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84984",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84984",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(desktop): drag to reorder profile groups in All-profiles sidebar",
          "text": "## What does this PR do? The All-profiles aggregate view (sidebar grouped by \"profile\") rendered groups in a fixed order — `default` pinned first, the rest alphabetical — with no way to reorder. This adds drag-to-reorder for profile group headers, reusing the same `ReorderableList` + grab-handle machinery already used for flat session rows and project overview rows. - Profile group headers become draggable in the All-profiles view. The lead glyph becomes a grab handle (grabber on hover), the label is grab surface, and rows inside groups stay static. - `default` stays a fixture pinned to the top (mirrors the `Home` fixture in the project overview, and the rail where default is position 0). - The order persists in the existing `hermes.desktop.profileOrder` store (`$profileOrder`), shared with the profile rail — dragging a group here reorders the rail and the ⌘N hotkeys coherently. No new storage key. - Profiles absent from the stored order alphabetise at the tail (same rule the rail uses). A drag persists the full visible named-profile list, so previously unranked profiles pin too. - Source/workspace groups and every non-profile grouped view render exactly as before — the sortable path is gated on `group.mode === 'profile'` plus a wired handler. ## Related Issue Fixes #84987 Related: #77716 (profile rail grouping request) — this PR shares the same single persisted profile order the rail uses. ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ 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 - `apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts` — new `sortProfileGroups`: default fixture first, stored order next, unranked alphabetical tail; pure, unit-tested. - `apps/desktop/src/app/chat/sidebar/sessions-section.tsx` — new `onReorderGroups` prop; the groups branch wraps named profile groups in `ReorderableList` and renders `default` outside it; `SortableWorkspaceGroup` wrapper. - `apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx` — accepts the same reorder contract as `ProjectOverviewRow` (`reorderable` / `dragging` / `dragHandleProps` / `ref` / `style`) and renders a `SidebarRowGrab` lead when sortable. - `apps/desktop/src/app/chat/sidebar/index.tsx` — sorts `profileGroups` with `sortProfileGroups(…, $profileOrder)` and persists drops via `setProfileOrder`. - `apps/desktop/src/app/chat/sidebar/projects/index.ts` — exports `sortProfileGroups`. - Tests: `workspace-groups.test.ts` (6 sorting unit tests), `sessions-section.test.tsx` (4 group-branch wiring tests), new `workspace-group.test.tsx` (4 grab-handle/grab-surface component tests). - `contributors/emails/nformenton@gmail.com` — contributor mapping for the attribution check. ## How to Test 1. Open the desktop app with more than one profile, switch the sidebar to \"All profiles\" and group by \"profile\". 2. Hover a named profile group header — the glyph turns into a grabber. Drag it; the group reorders among the named profiles and `default` stays on top. 3. Restart the app — the order persists, and the profile rail (and ⌘N hotkeys) show the same sequence. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate (no PR implements profile-group reorder; nearest open PRs #70223 and #43661 reorder session rows and touch `sessions-section.tsx`/`index.tsx` in different code paths — delta: this PR only changes the profile-mode groups branch) - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass — N/A for this desktop-renderer-only change; ran `npm run test:ui` (415 files, 3,713 tests pass), `npm run typecheck`, `npm run lint` (0 errors) - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 (vitest UI suite + tsc + eslint) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs; dnd-kit + localStorage behave the same on both platforms ## Verification - `npm run test:ui`: **415 files / 3,713 tests passed**, including the 14 new tests. - `npm run typecheck`: clean (renderer + electron + e2e configs). - `npm run lint`: 0 errors on all touched files (88 pre-existing warnings repo-wide, none in touched files).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84984",
          "createdAt": "2026-08-13T02:37:01Z",
          "updatedAt": "2026-08-13T13:13:48Z",
          "timestamp": "2026-08-13T13:13:48Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "P3",
            "comp/desktop",
            "area/profiles"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:bb2e8d90d366d2d77a0e",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85300",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85300",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway): classify local EMFILE/ENFILE failures",
          "text": "## Summary - classify local POSIX `EMFILE`/`ENFILE` descriptor exhaustion in gateway error envelopes - show a static, actionable gateway-host diagnostic on chat surfaces without leaking local paths or exception details - preserve raw diagnostics for programmatic/local/API/webhook surfaces and avoid rewriting ordinary prose - add focused gateway classification tests and a bounded SessionDB load/teardown regression test ## Scope This PR contains only classification/diagnostic behavior and its regression coverage. It does **not** change connection/session lifecycle management, reconnect teardown, SessionDB pooling, or launchd resource limits; those lifecycle/resource fixes are already upstream in `origin/main` (commits `5986cdd380`, `4b06c98fe4`, `87aedbe7b6`, `0472c31aa1`, and `585cee1a42`). ## Verification - `HERMES_PYTHON=/Users/caiohenrique/.local/lib/hermes-agent/.venv/bin/python scripts/run_tests.sh tests/gateway/test_telegram_noise_filter.py tests/test_session_db_read_conn_pool.py` - 168 tests passed - `py_compile` on all three changed files - `git diff --check` No runtime files under `/Users/caiohenrique/.local/lib/hermes-agent` were modified.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85300",
          "createdAt": "2026-08-13T13:03:22Z",
          "updatedAt": "2026-08-13T13:13:46Z",
          "timestamp": "2026-08-13T13:13:46Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "P2",
            "sweeper:risk-message-delivery"
          ],
          "author": "CAIOHSR",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:cd876b17ae68c94f96e0",
        "signalId": "github:NousResearch/hermes-agent:issue:85295",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85295",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: -  ×  X  \"workspace\" failed to render  Maximum call stack size exceeded  G Retry  C Running 7: 11  13%  32.8k/1M 22255  Session 1:17  # v0.20.0 (+31 7060ac7",
          "text": "### Bug Description - <img width=\"1614\" height=\"1016\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/f60b67c5-4ca5-40e1-9b1d-911cd2fd1159\" /> × X \"workspace\" failed to render Maximum call stack size exceeded G Retry C Running 7: 11 13% 32.8k/1M 22255 Session 1:17 # v0.20.0 (+31 7060ac7 <img width=\"1614\" height=\"1016\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/49d56b24-a959-4c89-a9e2-ca8109da5fa4\" /> ### Steps to Reproduce <img width=\"1614\" height=\"1016\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/77cdb18c-374f-48aa-8636-900429204ffc\" /> ### Expected Behavior <img width=\"1614\" height=\"1016\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/0c635bd3-4dee-4c41-a217-a1f58d15b02a\" /> ### Actual Behavior <img width=\"1614\" height=\"1016\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/f7f0e8bd-f375-4c68-bfb1-3efe5d322bc6\" /> ### Affected Component CLI (interactive chat) ### Messaging Platform (if gateway-related) N/A (CLI only) ### Debug Report ```shell https://paste.rs/J5gX8 ``` ### Operating System windows 11 ### Python Version Python 3.13.0 ### Hermes Version Hermes Agent v0.20.0 (2026.8.3) · upstream fa83af3f Install directory: C:\\Users\\USER\\AppData\\Local\\hermes\\hermes-agent Install method: git Python: 3.11.15 OpenAI SDK: 2.24.0 Up to date ### Additional Logs / Traceback (optional) ```shell ``` ### Root Cause Analysis (optional) _No response_ ### Proposed Fix (optional) _No response_ ### Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85295",
          "createdAt": "2026-08-13T12:58:25Z",
          "updatedAt": "2026-08-13T13:13:34Z",
          "timestamp": "2026-08-13T13:13:34Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "P3",
            "needs-repro",
            "sweeper:risk-platform-windows",
            "comp/desktop",
            "platform/windows",
            "bug"
          ],
          "author": "chilawautoparts1-eng",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:69bef46dcbde760ba509",
        "signalId": "github:NousResearch/hermes-agent:issue:85302",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85302",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "bug(desktop): agent-created sessions don't paint as live in the sidebar",
          "text": "The sidebar derives the live/working dot from renderer-side turn activity only. Sessions created by other surfaces (CLI, TUI, gateway, cron) that are actively running on the backend appear idle until the renderer sees a turn. Expected: the backend's `active_list` / liveness stamps drive the dot for foreign (agent-created) sessions too. Implementation: #84821",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85302",
          "createdAt": "2026-08-13T13:05:46Z",
          "updatedAt": "2026-08-13T13:13:15Z",
          "timestamp": "2026-08-13T13:13:15Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "P3",
            "sweeper:risk-session-state",
            "comp/desktop",
            "area/sessions"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a74dd27e8e7a911c0dd9",
        "signalId": "github:NousResearch/hermes-agent:issue:85303",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85303",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "bug(desktop): resuming a finalized session re-lights liveness without any new activity",
          "text": "Opening (mounting) a finished session reopens its row (`ended_at` cleared), and the dot-state machine paints it as live even though no message was sent. Expected: resume is read-only; only a real first turn (real activity) reopens/re-lights a finalized session. Implementation: #84847 (stacked on #84821).",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85303",
          "createdAt": "2026-08-13T13:05:47Z",
          "updatedAt": "2026-08-13T13:13:10Z",
          "timestamp": "2026-08-13T13:13:10Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "P3",
            "sweeper:risk-session-state",
            "comp/desktop",
            "area/sessions"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ddf29245d3280707ae13",
        "signalId": "github:NousResearch/hermes-agent:issue:85304",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85304",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "feat(sessions): heal orphan non-chat session rows after 24h idle",
          "text": "Session rows created by one-shot surfaces (cli, acp, cron, subagents) that never close accumulate as open rows with no liveness. Expected: a maintenance pass closes non-chat rows idle for 24h+ (orphan_heal). Implementation: #84846.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85304",
          "createdAt": "2026-08-13T13:05:48Z",
          "updatedAt": "2026-08-13T13:13:05Z",
          "timestamp": "2026-08-13T13:13:05Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "P3",
            "sweeper:risk-session-state",
            "area/sessions"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:de3be6b30c7bd3da0c1f",
        "signalId": "github:NousResearch/hermes-agent:issue:85305",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85305",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "feat(desktop): mark a session as unread with a persisted watermark",
          "text": "The sidebar has no way to mark a session unread or track read-state across restarts. Expected: mark unread/read actions backed by the existing `last_read_at` watermark, surfaced as a persisted unread dot. Implementation: #84822.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85305",
          "createdAt": "2026-08-13T13:05:49Z",
          "updatedAt": "2026-08-13T13:13:00Z",
          "timestamp": "2026-08-13T13:13:00Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "P3",
            "sweeper:risk-session-state",
            "comp/desktop",
            "area/sessions"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:220290cf61bbd2dc6b43",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84137",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84137",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): title ladder descends on empty completions too",
          "text": "## What does this PR do? The title ladder descends on schema/format failures but returned unconditionally on empty 200 completions, leaving sessions untitled. It now descends to the next rung on empty completions too. ## Related Issue Fixes #85307 Related: #83186, #83725, #82890 — this PR is a superset of the ladder family (triage comment confirms); if one of those merges first, this diff shrinks to the delta and rebases cleanly. ## Type of Change - [x] 🐛 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 - Title ladder descends on empty completions (`agent/title_generator.py`) - Tests: `tests/agent/test_title_generator.py` - `contributors/emails/nformenton@Nicolass-MacBook-Air.local` — attribution mapping ## How to Test 1. Force a title completion that returns a 200 with an empty title. 2. The ladder proceeds to the next rung instead of returning; the session ends with a title. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `pytest tests/agent/test_title_generator.py -q`: **37 passed**.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84137",
          "createdAt": "2026-08-11T22:25:22Z",
          "updatedAt": "2026-08-13T13:13:00Z",
          "timestamp": "2026-08-13T13:13:00Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/deepseek",
            "provider/kimi",
            "P3"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:d388fe87c9f1d7cb83d4",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84822",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84822",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(desktop): mark a session as unread/read with a persisted watermark",
          "text": "## What does this PR do? The sidebar gets \"mark as unread/read\" backed by the existing `last_read_at` watermark: the backend persists read-state (recursive lineage), the renderer derives a persisted unread dot, and opening a session marks it read. ## Related Issue Fixes #85305 Related (neighboring unread/watermark PRs): #74293, #76504, #82824, #74162 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ 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 - Backend: `set_session_read` watermark + unread derivation (`hermes_state.py`, `hermes_cli/web_*`) - Renderer: persisted unread store + dot-state claim + row/menu wiring (`apps/desktop/src/store/session-unread.ts`, `session-dot-state.ts`, sidebar components) - Tests: `tests/hermes_cli/test_web_server.py`, `apps/desktop/src/store/session-unread.test.ts`, `session-dot-state.test.ts`, `session.test.ts`, `apps/desktop/src/app/chat/sidebar/sessions-section.test.tsx`, `session-row.test.tsx` - `contributors/emails/nformenton@gmail.com` — attribution mapping ## How to Test 1. Right-click a session and choose \"Mark as unread\" — an unread dot appears. 2. Open the session — the dot clears (mark read on open). 3. Restart the app — unread state persists. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `npm run test:ui` (the five desktop test files above): **83 passed**. - `pytest tests/hermes_cli/test_web_server.py -q`: **148 passed, 1 failed** — `TestThemeBootstrapCSS::test_serve_index_injects_bootstrap_for_user_theme` is pre-existing (verified failing on clean upstream/main, unrelated to this PR).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84822",
          "createdAt": "2026-08-12T21:01:45Z",
          "updatedAt": "2026-08-13T13:12:57Z",
          "timestamp": "2026-08-13T13:12:57Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "comp/gateway",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery",
            "comp/desktop",
            "area/sessions",
            "area/i18n"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:f93a56f443229c6d0e16",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84846",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84846",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(sessions): heal non-chat orphan rows after 24h idle",
          "text": "## What does this PR do? One-shot surfaces (cli, acp, cron, subagents) create session rows that never close, accumulating as open rows with no liveness. This adds a heal pass: non-chat rows idle for 24h+ get closed (`end_session('orphan_heal')`). ## Related Issue Fixes #85304 Related (same bug class, upstream PRs): #44088, #47292, #56603, #65422, #76995, #50881 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ 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 - Heal pass closing non-chat orphan rows after 24h idle (`hermes_state.py`) - Tests: `tests/test_hermes_state.py` - `contributors/emails/nformenton@gmail.com` — attribution mapping ## How to Test 1. Create a one-shot session (`hermes -z`) and leave it. 2. Run the heal pass (or wait for the scheduled run). 3. The row shows `ended_at`/`end_reason='orphan_heal'` while chat rows (desktop/TUI) are untouched. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `pytest tests/test_hermes_state.py -q`: **221 passed, 1 failed** — `TestFTS5Search::test_search_projection_skips_context_enrichment_queries` is pre-existing (verified failing on clean upstream/main, unrelated to this PR).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84846",
          "createdAt": "2026-08-12T22:12:17Z",
          "updatedAt": "2026-08-13T13:12:56Z",
          "timestamp": "2026-08-13T13:12:56Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "comp/tui",
            "P2",
            "sweeper:risk-session-state",
            "area/sessions"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e2470edd3e861a035364",
        "signalId": "github:NousResearch/hermes-agent:issue:85306",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85306",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "bug(desktop): spaced currency amounts (US$ 5M) parsed as inline math",
          "text": "remark-math parses prose amounts like `US$ 5M` / `R$ 15.4M` as inline math, corrupting message rendering. Expected: escape currency amounts so the parser leaves them as prose. Implementation: #84183.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85306",
          "createdAt": "2026-08-13T13:05:50Z",
          "updatedAt": "2026-08-13T13:12:54Z",
          "timestamp": "2026-08-13T13:12:54Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "P3",
            "comp/desktop"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:1ca50ad836dbc935c9d8",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84021",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84021",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): drop stale inflight-journal folds that duplicate answers",
          "text": "## What does this PR do? A stale inflight-journal fold can re-emit answers already present in the transcript, duplicating messages. Skip folds whose journal state is stale relative to the transcript. ## Related Issue Fixes #85308 ## Type of Change - [x] 🐛 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 - Skip stale inflight-journal folds (`apps/desktop/src/lib/inflight-turn-journal.ts`) - Tests: `apps/desktop/src/lib/inflight-turn-journal.test.ts` - `contributors/emails/nformenton@Nicolass-MacBook-Air.local` — attribution mapping ## How to Test 1. With the journal fold feature active, trigger a fold whose journal state is older than the transcript. 2. No duplicate answer is appended. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `npm run test:ui -- src/lib/inflight-turn-journal.test.ts`: **22 passed**.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84021",
          "createdAt": "2026-08-11T17:06:46Z",
          "updatedAt": "2026-08-13T13:12:53Z",
          "timestamp": "2026-08-13T13:12:53Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "P3",
            "sweeper:risk-session-state",
            "comp/desktop"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:3fa8da642c893ec87560",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84183",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84183",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): escape spaced currency amounts from remark-math",
          "text": "## What does this PR do? remark-math parses prose currency amounts like `US$ 5M` / `R$ 15.4M` as inline math, corrupting message rendering. Escape spaced currency amounts so the parser leaves them as prose. ## Related Issue Fixes #85306 ## Type of Change - [x] 🐛 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 - Escape spaced currency amounts before math parsing (`apps/desktop/src/components/assistant-ui/markdown-text.ts`) - Tests: `apps/desktop/src/components/assistant-ui/markdown-text.test.ts` - `contributors/emails/nformenton@gmail.com` — attribution mapping ## How to Test 1. Send a message containing `US$ 5M` and `R$ 15.4M`. 2. The amounts render as plain prose, not inline math. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `npm run test:ui -- src/components/assistant-ui/markdown-text.test.ts`: **41 passed**.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84183",
          "createdAt": "2026-08-12T01:06:05Z",
          "updatedAt": "2026-08-13T13:12:51Z",
          "timestamp": "2026-08-13T13:12:51Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "P3",
            "comp/desktop"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:0fa83f357a7ddb8bfe97",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84821",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84821",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(desktop): paint agent-created sessions as live in sidebar",
          "text": "## What does this PR do? Sessions created by other surfaces (CLI, TUI, gateway, cron) that are actively running on the backend paint as idle in the desktop sidebar, because the live dot only reacts to renderer-side turn activity. This PR adds backend liveness stamps and exposes foreign (agent-created) live rows via `active_list`, then lets the renderer dot/arc/caption reflect them. ## Related Issue Fixes #85302 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ 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 - Backend: liveness stamps + `active_list` foreign rows (`tui_gateway/*`, `hermes_state.py`) - Renderer: foreign-liveness dot/arc/caption (`apps/desktop/src/store/foreign-live.ts`, sidebar row components) - Tests: `tests/run_agent/test_session_activity_persist.py`, `tests/test_tui_gateway_server.py`, `apps/desktop/src/store/foreign-live.test.ts`, `apps/desktop/src/app/chat/sidebar/session-row.test.tsx`, `apps/desktop/src/app/session/hooks/use-message-stream/cross-profile-change-events.test.tsx` - `contributors/emails/nformenton@gmail.com` — attribution mapping ## How to Test 1. Start a long-running session from the CLI (`hermes`) in a second terminal. 2. In the desktop app, look at the same session in the sidebar — its dot paints live/working while the CLI session is active, without any renderer-side turn. 3. Finish the CLI session; the dot returns to idle on the next `active_list` refresh. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `pytest tests/run_agent/test_session_activity_persist.py tests/test_tui_gateway_server.py -q`: **547 passed, 2 failed** — the 2 failures (`test_load_enabled_toolsets_rejects_disabled_mcp_env`, `test_load_enabled_toolsets_falls_back_when_tui_env_invalid`) are pre-existing upstream failures, unrelated to this PR. - `npm run test:ui` (the three desktop test files above): **15 passed**.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84821",
          "createdAt": "2026-08-12T20:59:47Z",
          "updatedAt": "2026-08-13T13:12:49Z",
          "timestamp": "2026-08-13T13:12:49Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/tui",
            "P3",
            "sweeper:risk-session-state",
            "comp/desktop",
            "area/sessions"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:10c9edc45a293727c772",
        "signalId": "github:NousResearch/hermes-agent:issue:85307",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85307",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "bug(agent): empty title completions stall the title ladder",
          "text": "The title ladder descends on schema failures but returns unconditionally on empty 200 completions, so a session can end with no title. Expected: descend to the next rung on empty completions too. Implementation: #84137.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85307",
          "createdAt": "2026-08-13T13:05:51Z",
          "updatedAt": "2026-08-13T13:12:47Z",
          "timestamp": "2026-08-13T13:12:47Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "P3"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a4d65b0305e76fc1c8bb",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84847",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84847",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): resume is read-only; liveness needs real activity",
          "text": "## What does this PR do? Two stale-end fixes, stacked on #84821: 1. Resuming (mounting) a finalized session is now read-only: it no longer reopens the row (`ended_at` cleared) or re-lights the live dot. Only the first real turn reopens it (`_reopen_if_finalized`). 2. DB-derived liveness now requires real recent activity: `active_list` foreign rows need `last_activity_at` within a 90s window, matching the renderer's expectations. Depends on #84821; when #84821 merges, this diff shrinks to the delta. ## Related Issue Fixes #85303 Related: #62038, #62012 (resume/reopen behavior family) ## Type of Change - [x] 🐛 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 - `resume` is read-only; first real turn reopens an ended session (`tui_gateway/*`, `hermes_state.py`) - DB liveness requires real recent activity (90s window) (`hermes_state.py`, `active_list`) - `_reopen_if_finalized` exposed on the server namespace (rebind-bound handler) - Tests: `tests/run_agent/test_session_activity_persist.py`, `tests/test_tui_gateway_server.py`, `apps/desktop/src/store/foreign-live.test.ts`, `apps/desktop/src/app/chat/sidebar/session-row.test.tsx`, `apps/desktop/src/app/session/hooks/use-message-stream/cross-profile-change-events.test.tsx` - `contributors/emails/nformenton@gmail.com` — attribution mapping ## How to Test 1. Let a session finish (or close it from the CLI). Open it in the desktop app — the row shows as read-only history, no live dot. 2. Send a message in it — the session reopens and starts a real turn. 3. Watch `active_list` rows for a session with no activity for >90s: they drop out of the live set. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — no native APIs ## Verification - `pytest tests/run_agent/test_session_activity_persist.py tests/test_tui_gateway_server.py -q`: **549 passed, 2 failed** — the 2 failures are the same pre-existing upstream pair as in #84821. - `npm run test:ui` (the three desktop test files above): **17 passed**.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84847",
          "createdAt": "2026-08-12T22:12:32Z",
          "updatedAt": "2026-08-13T13:12:47Z",
          "timestamp": "2026-08-13T13:12:47Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "comp/tui",
            "P2",
            "sweeper:risk-session-state",
            "comp/desktop",
            "area/sessions"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e6925cffcb26cf157ec7",
        "signalId": "github:NousResearch/hermes-agent:issue:85308",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85308",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "bug(desktop): stale inflight-journal folds duplicate transcript answers",
          "text": "A stale inflight-journal fold can re-emit answers that are already in the transcript, showing duplicated messages. Expected: skip folds whose journal state is stale relative to the transcript. Implementation: #84021.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85308",
          "createdAt": "2026-08-13T13:05:52Z",
          "updatedAt": "2026-08-13T13:12:42Z",
          "timestamp": "2026-08-13T13:12:42Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "P3",
            "sweeper:risk-session-state",
            "comp/desktop",
            "area/sessions"
          ],
          "author": "Nicolas-Formenton",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:f0b3de671f74f5a61fa8",
        "signalId": "github:NousResearch/hermes-agent:issue:84744",
        "event": "changed",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:84744",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": ".",
          "text": ".",
          "url": "https://github.com/NousResearch/hermes-agent/issues/84744",
          "createdAt": "2026-08-12T19:01:27Z",
          "updatedAt": "2026-08-13T13:11:29Z",
          "timestamp": "2026-08-13T13:11:29Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/security",
            "comp/gateway",
            "P2",
            "needs-repro",
            "sweeper:risk-session-state",
            "sweeper:risk-security-boundary"
          ],
          "author": "JoaoMarcos44",
          "state": "closed",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:c1a45ca4e6308c8f1181",
        "signalId": "github:NousResearch/hermes-agent:issue:53021",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:53021",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "feat(approval): session-scoped allowlist mode (deny-by-default terminal) for unattended/untrusted agents",
          "text": "## Problem Hermes agents are increasingly run as **unattended, monetized services** (e.g. sold per-turn over [x402](https://www.x402.org) micropayments), where the *prompt comes from an untrusted buyer*. In that threat model the agent should be able to run **only its own skill scripts** (e.g. `python3 .../skills/<skill>/scripts/foo.py ...`) and nothing else — but today there's no way to express that. The terminal is **denylist-based**: `DANGEROUS_PATTERNS` + an approval gate (`approvals.mode: manual|smart|off`). Consequences for an unattended/untrusted session: - Any command that **doesn't** match a dangerous pattern runs freely. A prompt-injected buyer turn can read pod secrets, exfiltrate data, or `curl` an in-cluster signer/credential endpoint — none of which are in `DANGEROUS_PATTERNS`. - `command_allowlist` is **additive approval only** — `_command_matches_permanent_allowlist` is consulted to *approve* matching dangerous commands, never to *deny* non-matching ones. It can't be used to restrict a session to \"only these commands.\" - Disabling the `code_execution`/`terminal` toolsets entirely isn't viable, because skills legitimately shell out to their own scripts. So there is currently **no deny-by-default terminal mode** for a session. ## Proposal Generalize the deny-by-default pattern that **#20984 (`cron_mode: allowlist`, attempt PR #21034)** already establishes for cron jobs into a **session-scoped** approval mode: ```yaml approvals: mode: allowlist # deny-by-default: only command_allowlist globs run; everything else is blocked command_allowlist: - \"python3 */skills/*/scripts/*.py *\" ``` Semantics: - In `allowlist` mode, a command runs **only** if it matches a `command_allowlist` glob; any other command is blocked (not prompted), regardless of whether it's \"dangerous.\" - Reuse the existing `_command_matches_permanent_allowlist` plumbing — it already does glob matching **and** rejects compound/shell-operator commands (`;`, `&&`, `|`, `$( )`, backticks), so `python3 ok.py; rm -rf /` won't slip through. - Orthogonal to `manual`/`smart`/`off`; it's the strictest point on the same axis. This gives operators of unattended/untrusted agents a small, well-precedented switch to scope an agent to exactly its skill surface. ## Related - **#20984** / PR **#21034** — `cron_mode: allowlist`: the same deny-by-default→`command_allowlist` pattern, scoped to cron. This proposal generalizes it to sessions. - **#50072** — declarative terminal routing for mixed-trust sessions: an untrusted paid-buyer turn is exactly the \"mixed-trust\" case. - **#35358** — `[Security] No atomic shell-disable mode`: a complementary durability concern — a deny-by-default mode is only trustworthy if a later toolset/config update can't silently re-expose the shell. - **#33905** — per-tool/per-toolset approval policies: related but coarser (toolset granularity, not command granularity). Happy to put up a PR generalizing #21034's approach if maintainers are open to this direction.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/53021",
          "createdAt": "2026-06-26T10:17:47Z",
          "updatedAt": "2026-08-13T13:08:26Z",
          "timestamp": "2026-08-13T13:08:26Z",
          "metrics": {
            "reactions": 1,
            "comments": 2
          },
          "labels": [
            "type/feature",
            "comp/tools",
            "tool/terminal",
            "area/auth",
            "P3",
            "area/sessions"
          ],
          "author": "bussyjd",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ffe0f2ff13eaa0d1e676",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84589",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84589",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(tui_gateway): stop replaying live-turn user text after redirect",
          "text": "## What does this PR do? Stops the tui_gateway **server queue** from re-firing the live turn's original user prompt **P** after a mid-turn correction **Q** (Desktop `session.redirect` / busy-input interrupt redirect). That re-fire produced a second agent turn and a second active `role=user` row with the same content (#84417). **Root cause (contract layer):** while turn P was live, a text-only self-copy of P could sit in `session[\"queued_prompt\"]` / `queued_prompts` (e.g. mid-busy re-submit while redirect was not available; consecutive-text merge could also glue `P\\n\\nQ`). Successful redirect/steer of Q did not scrub that self-duplicate, so post-settle `_drain_queued_prompt` restarted P. **Approach:** 1. Scrub text-only self-duplicates of `inflight_turn.user` on successful redirect/steer; rewrite merged `{P}\\n\\n{Q}` → `Q` only. 2. Refuse admitting a text-only self-copy of the live user in `_enqueue_prompt` (scrub first so merge cannot re-glue P+Q). 3. On compression session rotation, bump `_queued_prompt_generation` so an in-flight drain claim cannot dispatch with a stale generation. If generation cancels a claim mid-drain, the claimed envelope is **restored** to the queue (legitimate follow-ups are not dropped). **Scope / residual (honest limits of this PR):** - Fixes **server** self-duplicates of the *live* turn. The independent Desktop **client** composer queue (`$queuedPromptsBySession`) is unchanged; a user-queued follow-up can still drain by design. - Image-bearing queue envelopes are left alone (chronology/ownership). - Verification is production-path unit/integration on gateway helpers (class repro), not a full interactive long-session Desktop compression e2e. ## Related Issue Fixes #84417 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✅ Tests (adding or improving test coverage) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `tui_gateway/server.py` — `_sanitize_queued_entry_vs_inflight_user`, `_drop_queued_duplicates_of_inflight_user`; scrub on successful busy redirect/steer; refuse self-dup in `_enqueue_prompt`; bump `_queued_prompt_generation` in `_sync_session_key_after_compress` on rotation; restore claimed queue envelope when generation cancels mid-drain. - `tui_gateway/methods_session.py` — scrub on successful `session.redirect` and `session.steer` (Desktop production path). - `tests/test_tui_gateway_queue_on_busy.py` — redirect scrub, hard-interrupt path, merge rewrite, drain E2E (no second P), enqueue refuse, compress generation bump / no-op, generation-cancel restores claimed head. - `tests/test_tui_gateway_server.py` — `session.redirect` RPC scrub + build-window queue of Q scrubs stale P. ## How to Test 1. **Regression suite:** ```bash scripts/run_tests.sh tests/test_tui_gateway_queue_on_busy.py -q --tb=short scripts/run_tests.sh tests/test_tui_gateway_server.py -k \"session_redirect\" -q --tb=short scripts/run_tests.sh tests/tui_gateway/test_protocol.py -k \"sync_session_key_after_compress\" -q --tb=short ``` 2. **Evidence (local runner):** `tests/test_tui_gateway_queue_on_busy.py` — 25 passed; related `session.redirect` / compress re-anchor tests passed via `scripts/run_tests.sh`. 3. **Key cases:** - successful redirect drops queued self-dup of inflight user (busy-submit + Desktop `session.redirect` RPC) - hard-interrupt + queue of Q scrubs stale P ahead of Q - build-window `session.redirect` enqueue of Q scrubs P - after redirect(Q), `_drain_queued_prompt` does **not** start a turn with P - merged `P\\n\\nQ` rewrites to Q-only - compress rotation bumps generation; unrelated follow-up text is preserved - generation cancel mid-drain does **not** drop the claimed follow-up (restored to queue) 4. **Class repro (pre/post):** P running + `queued_prompt=P` + redirect(Q) → pre-fix drain re-fired P; post-fix queue scrubbed, drain no-op. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `scripts/run_tests.sh` on the affected tests and they pass (project canonical runner) - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Linux (WSL/Ubuntu-class), via `scripts/run_tests.sh` ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — **N/A** - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — **N/A** - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — **N/A** - [x] I've considered cross-platform impact (Windows, macOS) — pure Python gateway session-dict logic; no OS-specific paths - [x] I've updated tool descriptions/schemas if I changed tool behavior — **N/A**",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84589",
          "createdAt": "2026-08-12T14:25:21Z",
          "updatedAt": "2026-08-13T13:07:02Z",
          "timestamp": "2026-08-13T13:07:02Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/tui",
            "P2",
            "sweeper:risk-session-state"
          ],
          "author": "StanleyStetson",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c1422f628514352097a5",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84202",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84202",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(onebot): add OneBot 11 platform adapter (NapCat / Lagrange / LLOneBot)",
          "text": "## Summary Adds a **OneBot 11 platform adapter** as a plugin (`plugins/platforms/onebot/`), connecting Hermes to QQ via local bridges — NapCat, Lagrange, LLOneBot, go-cqhttp — over reverse or forward WebSocket. Unlike the official QQ Bot platform (Tencent-approved app + openid), OneBot drives a regular QQ account, which is what many personal-bot and group use cases need. ### Features - **DM + group chats** with per-scope policies: `dm_policy` / `group_policy` (`open` / `allowlist` / `disabled`), `require_mention` in groups (@ or reply-to triggers the bot; fails closed when the bot id is unknown) - **CQ-code parsing**: `at` / `at all` → text, faces → emoji, replies stripped; inbound images downloaded to temp dir for the vision tool (undownloadable → `[图片]`) - **Outbound** uses OneBot **segment arrays** (NapCat rejects CQ-string payloads with a TypeError), no reply-quoting, media attached to the final chunk - **Long replies**: ≤`split_length` (100) chars single message; up to `text_image_threshold` (150) split at sentence boundaries (`。！？!?；;\\n`, never mid-sentence); beyond that rendered as a **CJK-aware text image** (720px, font fallback chain Noto CJK → WenQuanYi → Unifont, glyph-level fallback so no tofu boxes) - Authorization via `ONEBOT_ALLOWED_USERS` / `ONEBOT_ALLOW_ALL_USERS`; cron delivery via `ONEBOT_HOME_CHANNEL` - Configurable through `gateway.platforms.onebot.extra` in `gateway-config.yaml` ### Tests `tests/plugins/platforms/test_onebot_platform.py` — 21 tests: reply splitting, text-image rendering, CQ parsing, mention gating, DM/group policies, outbound segment payloads (fake WS with echo), and a live reverse-WS round trip against a fake NapCat client (ephemeral port, no network). ### Docs - `website/docs/user-guide/messaging/qq-onebot.md` (new) - sidebar + environment-variables entries Related: #38037 (shared OneBot client — complementary; this PR is the gateway platform side).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84202",
          "createdAt": "2026-08-12T01:54:11Z",
          "updatedAt": "2026-08-13T13:05:43Z",
          "timestamp": "2026-08-13T13:05:43Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/plugins",
            "platform/qqbot",
            "area/config",
            "P3"
          ],
          "author": "mario841859784",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:891bcd103a4da1463a07",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85298",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85298",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(discord): free-response channel を thread-first に保つ",
          "text": "## 概要 - Discord の `free_response_channels` を「メンション不要」に限定し、auto-thread 抑止条件から外しました。 - 親チャンネルへ直返信する制御は `no_thread_channels` のみが担当するようにしました。 - free-response / no-thread / 両方指定時の routing 契約をテストで固定しました。 ## 背景 `discord.auto_thread=true` かつ `discord.free_response_channels` 指定時に、free-response が auto-thread 抑止としても扱われるため、mentionless な親投稿へスレッドを作らず親チャンネルへ直接返信していました。 ## 実施したテスト - `uv run --extra dev pytest tests/gateway/test_discord_free_response.py`",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85298",
          "createdAt": "2026-08-13T12:59:13Z",
          "updatedAt": "2026-08-13T13:05:30Z",
          "timestamp": "2026-08-13T13:05:30Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [],
          "author": "metamon-p",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:3074b297ac20bf87abce",
        "signalId": "github:NousResearch/hermes-agent:pull_request:51466",
        "event": "changed",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:51466",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(optional-mcps): add Apple macOS MCP servers (mail, notes, numbers, photos)",
          "text": "## What does this PR do? Adds four macOS MCP servers to the `optional-mcps/` catalog so Hermes users can install them with `hermes mcp install <name>`: - **apple-mail** — read, search, send, reply, forward, and organize Apple Mail - **apple-notes** — create, search, read, update, and organize Apple Notes - **apple-numbers** — read, write, search, and format Apple Numbers (`.numbers`) spreadsheets - **apple-photos** — query, search, export, and inspect the macOS Photos library All four are published, MIT-licensed npm packages maintained under [github.com/sweetrb](https://github.com/sweetrb), with CI on `macos-latest` (Node 20 + 22). Each manifest is a plain **stdio** entry that launches the published package via `npx -y <pkg>` — no `install`/clone step (per the npm/uvx note in the n8n manifest) and `auth: none` (everything is local). They already support Hermes today via `hermes mcp add`; this just makes them one-command installable from the catalog. ## Related Issue None — new catalog entries. ## Type of Change - [x] ✨ New feature (non-breaking change that adds functionality) ## Changes Made - `optional-mcps/apple-mail/manifest.yaml` - `optional-mcps/apple-notes/manifest.yaml` - `optional-mcps/apple-numbers/manifest.yaml` - `optional-mcps/apple-photos/manifest.yaml` ## How to Test On **macOS**: 1. `hermes mcp install apple-notes` (or `apple-mail` / `apple-numbers` / `apple-photos`) 2. Start a new Hermes session. 3. The server launches via `npx -y apple-<app>-mcp`. First use prompts for macOS Automation access (AppleScript); `apple-photos` additionally needs Full Disk Access. Tools then load (e.g. `apple-notes`: `create-note`, `search-notes`, `list-notes`, …). **Platforms tested:** macOS (each server's own CI runs on `macos-latest`, Node 20 + 22). `apple-numbers` and `apple-photos` use a Python 3.11+ sidecar (`numbers-parser` / `osxphotos`) that bootstraps a local venv on first run. ## Notes - These are **macOS-only** by nature (AppleScript / the macOS Photos library), which each manifest's `post_install` calls out. - `apple-mail` exposes send/delete/move tools that act on the real mailbox; the install-time checklist lets users prune to a read-only surface. The others are read-mostly (`apple-photos` is read-only except `export`). Happy to adjust naming, descriptions, or split into separate PRs if you'd prefer.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/51466",
          "createdAt": "2026-06-23T17:36:56Z",
          "updatedAt": "2026-08-13T13:04:16Z",
          "timestamp": "2026-08-13T13:04:16Z",
          "metrics": {
            "reactions": 0,
            "comments": 16
          },
          "labels": [
            "type/feature",
            "tool/mcp",
            "P3",
            "sweeper:risk-security-boundary",
            "sweeper:risk-compatibility",
            "sweeper:blast-contained"
          ],
          "author": "sweetrb",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:d2eccc1ac8b3339f743f",
        "signalId": "github:NousResearch/hermes-agent:issue:76312",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:76312",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: Playwright Chromium installation hangs indefinitely at \"extracting archive\" on Node 26 (Playwright 1.58.2, CachyOS)",
          "text": "### Bug Description ## Bug Description Fresh installation of Hermes Agent on CachyOS consistently hangs during the Playwright Chromium installation. The Chromium ZIP downloads successfully and passes integrity verification, but the installer never progresses beyond: ``` pw:install extracting archive ``` The process remains alive indefinitely but makes no further progress. I believe this may be related to the current upstream Playwright/Node extraction issue, but I wanted to provide a fully reproducible Hermes installation report. --- ## Environment * Hermes Agent: latest `main` (as of 2026-08-01) * OS: CachyOS (Arch-based) * Filesystem: btrfs * Shell: fish * Python: 3.11.15 * Node.js: 26.4.0 (CachyOS package) * npm: 12.0.1 * Playwright: 1.58.2 * Playwright Core: 1.58.2 --- ## Installation Installed using the official installer: ```bash curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` The installer initially failed because the repository `uv.lock` required regeneration while `uv sync --locked` was being used. Running: ```bash cd ~/.hermes/hermes-agent ~/.hermes/bin/uv sync --extra all --locked ``` completed successfully. After that, the repository was fully up to date (`git pull`) and `npm install` completed successfully. --- ## Reproduction Running: ```bash npx playwright install chromium ``` always hangs after: ``` Downloading Chrome for Testing ... 100% pw:install extracting archive ``` --- ## Diagnostics ### ZIP integrity The downloaded archive validates successfully: ```bash unzip -t /tmp/playwright-download-*/playwright-download-chromium-ubuntu24.04-x64-1208.zip ``` Result: ``` No errors detected in compressed data. ``` --- ### Process state The Playwright process remains alive indefinitely. CPU usage stays around: ``` 0.1–0.2% ``` for more than 10 minutes. No meaningful CPU activity occurs. --- ### Files being accessed `lsof` shows: * ZIP file still open for reading. * Output file opened for writing: ``` WidevineCdm/_platform_specific/linux_x64/libwidevinecdm.so ``` --- ### Extraction never progresses Directory remains permanently at approximately: ``` 18 MB 14 files ``` No additional files are created. --- ### Disk space Sufficient free space exists on both: * /tmp * home filesystem Filesystem is btrfs. --- ## Additional observations Updating Hermes to the latest `main` does not change the Playwright version. Current pinned versions remain: ``` playwright 1.58.2 playwright-core 1.58.2 @playwright/test 1.58.2 ``` --- ## Possible upstream relation This appears similar to recent Playwright browser installation hangs reported with newer Node releases where the download completes but extraction never finishes. If useful, I can provide additional diagnostics or test a proposed fix. ### Steps to Reproduce ## Reproduction Running: ```bash npx playwright install chromium ``` always hangs after: ``` Downloading Chrome for Testing ... 100% pw:install extracting archive ``` ### Expected Behavior ### Expected Behaviour The Playwright browser installation should complete successfully after downloading Chromium. After running: ```bash npx playwright install chromium ``` the installer should: * Download the Chromium archive. * Extract the archive. * Install Chromium into `~/.cache/ms-playwright`. * Exit successfully, allowing Hermes browser tools to be used. ### Actual Behavior ### Actual Behaviour The Chromium archive downloads successfully to 100%, but the installation hangs indefinitely during the extraction phase. The final output is: ```text Downloading Chrome for Testing ... 100% pw:install extracting archive ``` The process remains running but makes no observable progress: * CPU usage remains approximately 0.1–0.2%. * The extracted directory remains at approximately 18 MB (14 files) and does not grow. * The downloaded ZIP passes integrity verification (`unzip -t` reports no errors). * The process continues indefinitely until manually terminated with `Ctrl+C`. ### Affected Component Setup / Installation ### Messaging Platform (if gateway-related) N/A (CLI only) ### Debug Report ```shell Debug report uploaded: Report https://paste.rs/gCUXd agent.log https://paste.rs/D1rwA ``` ### Operating System Linux 7.1.5-1-cachyos x86_64 ### Python Version 3.11.15 ### Hermes Version 0.19.1 ### Additional Logs / Traceback (optional) ```shell ``` ### Root Cause Analysis (optional) _No response_ ### Proposed Fix (optional) _No response_ ### Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
          "url": "https://github.com/NousResearch/hermes-agent/issues/76312",
          "createdAt": "2026-08-01T17:15:50Z",
          "updatedAt": "2026-08-13T13:02:34Z",
          "timestamp": "2026-08-13T13:02:34Z",
          "metrics": {
            "reactions": 2,
            "comments": 5
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "tool/browser",
            "P2",
            "area/install-update"
          ],
          "author": "InforesightConsulting",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:cf48e3dab7955150512a",
        "signalId": "github:NousResearch/hermes-agent:issue:85288",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85288",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Design] Tool-aware relevance filtering for tool output before context injection",
          "text": "## Problem `tool_output.max_bytes`, `tool_output.max_lines`, and `tool_output.max_line_length` cap tool output by **size**, not by **relevance**. Every tool result is injected into the agent context as a raw blob up to that cap. That's the wrong axis for a lot of tools. Concrete examples: - **`search_files` / grep-style tools**: a search across a codebase can return 200 matching lines. In most cases, only the first few matches and the last few (if the agent is scanning for a pattern near a boundary) actually matter. The middle 190 lines are context noise. Today they all enter context equally. - **`read_file`**: reading a 5000-line file to check the last 10 lines (e.g. \"what does the bottom of this config say?\") currently injects the whole file up to the cap. The answer is in lines 4990-5000; the other 4990 lines are wasted context. - **`patch` / `write_file`**: returns a full unified diff. The agent already knows what it asked the tool to change — the diff is informational for the human reader, not necessary context for the agent to continue. (#49316 makes this point for diffs specifically.) - **`terminal`**: a long command output where the relevant part is the tail (error at the end) or the head (banner/version at the top). The middle is frequently irrelevant. - **`search_files` with subdirectory hint injection**: can append large instruction files (AGENTS.md, CLAUDE.md) to the result (#47194 covers this specific inflation source, but the broader principle is the same — tool output shouldn't silently ingest unrelated large payloads). Related issues capture **specific instances** of this: - #47194 — subdirectory hint files inflating `search_files` output - #49316 — `patch`/`write_file` diffs persisting verbatim in agent context - #15962 — auto-scaling tool-output caps from model context (configuration side) None of them ask the general question: **why does Hermes treat all tool output as equally worth keeping verbatim, regardless of which part the tool's semantics imply is relevant?** ## Proposed Direction Add a **tool-aware relevance filter** that runs on tool results before they're injected into the agent context, per tool type. The size cap stays — this is a complement, not a replacement. The filter answers: \"for this tool, which subset of the output is the agent likely to need?\" Possible shape (design discussion, not a committed API): ### 1. Per-tool result profiles Each tool declares a result-handling profile: ``` tool_result_profiles: search_files: mode: bounded_matches # keep first N + last N matches, summarize middle first_matches: 5 last_matches: 5 middle_summary: \"N additional matches omitted\" read_file: mode: tail_or_head # when file is large relative to query intent, prefer tail/head default_tail_lines: 50 default_head_lines: 50 full_if_under_chars: 4000 # small files still pass through patch: mode: summary # agent doesn't need the diff verbatim summarize: \"patched N files: <file list>\" suppress_diff: true # aligns with #49316 write_file: mode: summary summarize: \"wrote <path>, N chars\" terminal: mode: smart_tail # prefer tail for error-detecting commands, head for version/banner default_tail_lines: 100 default_head_lines: 50 ``` This is intentionally a **configurable profile system**, not hardcoded per tool — new tools can be added without code changes, and users can override for their workflows. ### Before/after example **`search_files` returning 200 matches, query: \"find all usages of X\":** Before (current behavior): ``` <all 200 matching lines injected verbatim, each consuming context> ``` After (profile `bounded_matches`, first=5, last=5): ``` match 1: src/a.ts:42: const x = new X() match 2: src/b.ts:10: import { X } from \"./x\" ... match 5: src/e.ts:88: x.doSomething() [... 190 additional matches omitted — use narrower search to see all] match 196: tests/z.test.ts:203: expect(x).toBeDefined() ... match 200: docs/api.md:12: X is deprecated in v3 ``` The agent gets the signal (pattern exists in files a/b/e/z, docs) without 190 lines of middle noise. A narrower follow-up search retrieves the omitted matches if needed. ### 2. Size cap still applies on top The existing `tool_output.max_bytes` / `max_lines` / `max_line_length` remain as a hard backstop. The relevance filter runs first, then the size cap clips anything that still exceeds it. This is backward-compatible — tools with no profile just get the current behavior. ### 3. Metadata-only mode for hint injection For subdirectory/project-context discovery (the #47194 case), the default should be **metadata only** in the visible tool result: ``` [Subdirectory context discovered: .hermes/hermes-agent/AGENTS.md, 69199 chars, not injected] ``` Full injection, if available at all, should be opt-in and size-limited. The discovered content shouldn't silently dominate a small search result. ### 4. Explicit vs. implicit intent Some tools need the full output (e.g. a tool that returns structured JSON the agent must parse). The profile system should have a `mode: full` default for those, and the relevance filter should be **off by default for tools that declare their result as structured data the agent must consume verbatim**. ## What this is NOT - Not a replacement for #15962 (auto-scaling caps). The two are complementary: #15962 sets the right ceiling; this issue asks that we fill that ceiling intelligently rather than with raw blobs. - Not a replacement for #47194 or #49316. Those are specific instances; this is the general principle that would make those fixes part of a coherent system rather than one-off patches. - Not compression / context compaction. Compression runs when the context is already too large. This runs at tool-result-inject time, preventing the bloat from entering context in the first place. ## Non-goal for now I'm not asking for a full semantic summarizer (LLM-based) on every tool result. That's a possible future layer, but the first pass should be **rule-based and tool-aware**: each tool knows its own output shape, and the relevance filter uses that shape to pick what to keep. An LLM summarizer on top of that is a separate enhancement. ## Questions for discussion 1. Is a per-tool profile system the right abstraction, or should this be handled inside each tool's result builder (tool authors decide what to return)? 2. For `read_file`, how should Hermes infer \"the user wants the tail\" vs \"the user wants the whole file\"? Query text analysis? Always-offer-a-preview? Configurable default? 3. Should the relevance-filtered result be what the agent sees, while the full result is persisted to disk/on-demand (similar to the existing `result_persist_threshold_chars` indirection)? Or should the filter just reduce what enters context, with the full thing gone? 4. Backward compatibility: should the default be `mode: full` for all existing tools (current behavior preserved) with users opting into profiles, or should a few high-impact tools (`search_files`, `read_file`, `terminal`) default to a smarter profile? 5. Is the context savings worth the implementation complexity? A lighter-weight alternative — per-tool configurable size caps (e.g. `search_files.max_lines=10` by default) — might capture most of the benefit with far less machinery. Is a full profile system the right investment, or is that overkill for the problem? ## Environment - Hermes Agent current main - Observed on long-running project sessions where tool-output noise is a material context cost - Related to context-bloat observations in #32156 (long-session context growth from tool-output noise)",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85288",
          "createdAt": "2026-08-13T12:40:32Z",
          "updatedAt": "2026-08-13T13:01:51Z",
          "timestamp": "2026-08-13T13:01:51Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "innovation",
            "comp/agent",
            "comp/tools",
            "P3",
            "area/compression"
          ],
          "author": "RajeshShrirao",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:111cd9ecc448415618d3",
        "signalId": "github:NousResearch/hermes-agent:pull_request:82581",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:82581",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(tools): isolate subprocess Python environments (#74817)",
          "text": "## Summary Remove Hermes-owned runtime contamination while preserving user-owned `PYTHONPATH` semantics. This consolidates and builds on #78917, incorporates the follow-up correctness work from Yiipu/hermes-agent#1, and rebases the complete attributed history onto main at `f4c2c263f0672a4b1485f3071cd5f79cd32d38ab`. ## Rebase onto main at f4c2c263f (#84500 reconciliation) Rebased onto main at `f4c2c263f`; conflicts were confined to the `PYTHONPATH` composition region in `tools/code_execution_tool.py`: upstream #84500 (`fix(tools): isolate external project environments`) had reworked the exact block this PR touches. The reconciliation preserves **both** fixes — inherited-PYTHONPATH sanitization is applied to the scrubbed child environment **before** the conditional composition, which keeps the staging `tmpdir` first, re-adds the Hermes repo root only for same-Hermes-environment children, and omits it for external project interpreters. #84500's interpreter-probe machinery is untouched, and its regression suites (`test_code_execution_modes.py`, `test_code_execution.py`, `test_code_execution_windows_env.py`) pass unmodified. The Windows-native test now uses the registered `@pytest.mark.windows_only` marker (current upstream OS-native test policy) so the Windows CI lane actually selects it. ## Reproduction Hermes launchers can prepend the Hermes repository root and the Hermes Python 3.11 venv's `site-packages` to `PYTHONPATH`. An unrelated Python 3.13 child then resolves NumPy from the 3.11 tree and fails to load its C extension: ```text ModuleNotFoundError: No module named 'numpy._core._multiarray_umath' ... _multiarray_umath.cpython-311-darwin.so ``` The same contamination class affects Pillow and other ABI-sensitive packages. It also reaches non-agent cron/script subprocesses through the shared environment builders. ## Root cause Hermes-owned paths needed to bootstrap the backend are inherited by unrelated child processes. Blanket removal of `PYTHONPATH` would fix the immediate ABI failure but would also destroy legitimate user configuration. Conversely, comparing path-shaped Python versions against the backend interpreter is not sound because the environment builder does not know which interpreter a future child will run. The ownership boundary is therefore the reliable contract: remove entries proven to be injected by the Hermes runtime and preserve every other component. ## Design - Strip only the exact Hermes repository root and exact Hermes runtime `site-packages` directories. - Preserve direct/deeper repo children and `site-packages` descendants because launchers do not inject them as standalone entries. - Preserve raw non-owned components byte-for-byte, including empty components, duplicates, whitespace, spelling, and ordering. - Do not use cross-version, path-shape, or `/nix/store` heuristics. - Sanitize before removing runtime marker variables so the validated Windows runtime relationship remains available to the ownership check. ## Changes - Apply the selective filter across terminal/background, shared subprocess, cron/script, and execute-code environment construction. - Remove inherited `PYTHONHOME` together with `VIRTUAL_ENV` and `CONDA_PREFIX` on normal sanitized paths. - Keep the explicit `build_subprocess_env(scrub_secrets=False)` no-scrub escape hatch unchanged. - Add behavioral regression coverage for exact ownership, raw `PYTHONPATH` semantics, HERMES_HOME aliases, Windows base-Python runtime ownership, and `PYTHONHOME`. ## Windows runtime coverage Some Windows gateway launchers run under a base/uv `python.exe` or `pythonw.exe` while `VIRTUAL_ENV` points to the separate Hermes runtime venv. In that mode, `sys.prefix` is not the Hermes runtime owner. The sanitizer accepts `VIRTUAL_ENV` only when it is the exact `<Hermes repo>/venv` location under a known physical or configured repo alias and contains a real `pyvenv.cfg`. That validated runtime contributes the exact `Lib/site-packages` entry. An unrelated inherited `VIRTUAL_ENV` is not trusted. This contract is covered with platform-independent helper tests plus a Windows-only native-semantics test. It has not been live-tested on Windows in this PR. ## PYTHONHOME The default sanitized paths remove inherited `PYTHONHOME`, preventing it from redirecting an unrelated child interpreter (#75018). No active gateway producer was found that intentionally sets it. The explicit no-scrub escape hatch still preserves it byte-for-byte. ## Known residual limitations - Nix `extraPythonPackages` may append runtime plugin paths outside the repo/venv ownership boundary. A `/nix/store` path is not sufficient provenance because users can legitimately put Nix paths in `PYTHONPATH`; these entries are deliberately preserved. A future producer-side provenance contract should be discussed separately if needed. - `HostSupervisor` has an independent internal-child environment overwrite pattern. It does not block the user-facing #74817 subprocess path and is intentionally left for a separate issue/PR. ## Tests - `scripts/run_tests.sh tests/tools/test_local_env_blocklist.py -q` — 75 passed, 2 skipped (Windows-only) - `scripts/run_tests.sh tests/tools/test_code_execution_modes.py tests/tools/test_code_execution.py tests/tools/test_code_execution_windows_env.py -q` — 99 passed, 3 skipped (Windows-only; includes the #84500 suite, unmodified) - `scripts/run_tests.sh tests/tools/test_build_subprocess_env.py tests/tools/test_hermes_subprocess_env.py tests/tools/test_code_execution_modes.py tests/tools/test_code_execution_windows_env.py tests/cron/test_cron_script.py tests/tools/test_tts_pythonpath_fallback.py -q` — 109 passed, 4 skipped - `scripts/run_tests.sh tests/hermes_cli/test_gateway_windows.py tests/hermes_cli/test_update_gateway_launcher_refresh.py tests/gateway/test_restart_drain.py -q` — 15 passed, 5 skipped - `.venv/bin/ruff check tools/environments/local.py tools/code_execution_tool.py tests/tools/test_local_env_blocklist.py` — passed - `.venv/bin/python -m py_compile tools/environments/local.py tools/code_execution_tool.py tests/tools/test_local_env_blocklist.py` — passed - `git diff --check` — passed - `scripts/ci/list_os_marked_tests.py windows_only` — lists `tests/tools/test_local_env_blocklist.py` (Windows lane imports it; the native test is selected by the marker) - New integration test `test_execute_code_composition_strips_inherited_hermes_entries` (2 parametrized cases) drives `execute_code` to Popen with a contaminated inherited `PYTHONPATH`, proving the #84500 + #82581 composition contract: staging first, inherited Hermes site-packages stripped, repo root re-added exactly once for a same-env child / absent for an external child, user entries preserved in order. - Full `scripts/run_tests.sh` was paired against pristine `main` at `2446c8bb6` with the same wrapper/Python. A follow-up rerun of the exact 32 failing files produced the identical 96 failing node IDs on both revisions (`1017 passed, 96 failed`); there were no branch-only failures. For this rebase onto `f4c2c263f`, the #84500 intersection suites were added to the regression set and pass unmodified. - macOS arm64 real E2E: the contaminated environment makes independent Python 3.13 load the Hermes Python 3.11 NumPy tree and fail; the sanitized environment preserves `/custom/lib/python3.13/site-packages`, and Python 3.13 then imports NumPy and Pillow from its own conda environment successfully. ## Lineage / attribution This branch preserves the original Git commit authors instead of squashing or recreating their work: - `mcjoys`: the initial selective stripping commit, carried through #78917 from the earlier #61028 lineage. - `Yiipu`: repo-root correction and boundary tests from #78917. - `Xinyu Du` / `Starfie1d1272`: independent macOS reproduction, adversarial review, removal of the cross-version heuristic, user `PYTHONPATH` preservation, `PYTHONHOME`, Windows/runtime ownership hardening, current-main integration, and regression/E2E validation. No synthetic `Co-authored-by` trailers are added because the Git history already preserves authorship. ## Relationship to existing work and issues This PR consolidates and builds on #78917 by @Yiipu and its #61028 lineage while addressing the correctness gaps found during real reproduction and adversarial review. It targets #74817 and the inherited-`PYTHONHOME` portion of #75018, with related context in #65909 and #57467.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/82581",
          "createdAt": "2026-08-09T16:03:32Z",
          "updatedAt": "2026-08-13T13:00:59Z",
          "timestamp": "2026-08-13T13:00:59Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "comp/tools",
            "tool/code-exec",
            "backend/local",
            "P2",
            "sweeper:risk-compatibility"
          ],
          "author": "Starfie1d1272",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a957d149672f172b338b",
        "signalId": "github:NousResearch/hermes-agent:issue:81484",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:81484",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Desktop sidebar: 'Load more' (⋯) never appears when pinned sessions occupy the recency window — older sessions unreachable",
          "text": "## Bug Description In the desktop sidebar, the \"Load more\" (`⋯`) row **never appears** when pinned sessions happen to sit *inside* the most-recent recency window — even when dozens of older sessions exist beyond the page. Those older sessions become unreachable from the list UI entirely (FTS search still finds them, but the sidebar list cannot load them). ## Environment - Hermes Desktop (latest, 2026-08-08) + runtime at current `main` - Windows 10, single profile (`default`) - Reproduced against a live state.db ## Steps to Reproduce 1. Have more than 50 non-archived sessions (139 in my case, oldest from 2026-05-22). 2. Pin 3 sessions that are **recent enough to fall inside the most-recent-50 recency window** (my 3 pins all do). 3. Open the sidebar. Backend returns a full window of 50 rows — but 3 of them are the pinned ones. 4. The `⋯` load-more row does **not** render. Clicking is impossible; the 92 sessions older than the window (May–July) can never be reached from the list. ## Root Cause The `profiles_truncated` flag discounts pinned rows, but pinned rows still **consume LIMIT slots** inside the window: `hermes_cli/web_routers/profiles.py:334-335` (batched `/api/profiles/sessions/sidebar`): ```python unpinned_count = sum(1 for s in profile_rows if not s.get(\"pinned\")) recents_truncated[name] = unpinned_count >= recents_cap ``` Verified live: `list_sessions_rich(limit=50, min_message_count=1, order_by_last_active=True, include_pinned=True)` returns **50 rows: 3 pinned + 47 unpinned**. `47 >= 50` → `False` → `profiles_truncated = false` → frontend `hasMoreSessions=false` → the load-more row (`apps/desktop/src/app/chat/sidebar/load-more-row.tsx`, rendered only when `hasMoreSessions`) never mounts. The legacy per-slice fallback (`apps/desktop/src/hermes.ts` `profilesTruncatedFrom`) has the same discount logic, so both paths are affected. The pinned discount was introduced to stop back-filled pins *past* the LIMIT faking a full page on a short list — but discounting pins that occupy *in-window* slots hides real data instead. ## Expected Behavior \"Load more\" should appear whenever the window is full of rows, i.e. the flag should answer \"is the window full?\" rather than \"are there ≥ cap unpinned rows?\". Suggested fix: ```python recents_truncated[name] = len(profile_rows) >= recents_cap ``` (same idea as the `globalTruncated` proposal in #72494, applied per-profile in the batched endpoint). The degenerate case — window exactly full with nothing more on disk — resolves gracefully: load-more returns an empty page and the frontend already suppresses the button when no new rows arrive. ## Related - #44009 / #44015 (closed, unmerged): the *opposite* failure of the same discount logic — \"Load N more\" never disappears. Still reproducible at current `main`. - #72492 / #72494 (open): multi-profile legacy-path truncation miscount — related but distinct (batched endpoint is affected here, single profile).",
          "url": "https://github.com/NousResearch/hermes-agent/issues/81484",
          "createdAt": "2026-08-08T03:21:07Z",
          "updatedAt": "2026-08-13T13:00:52Z",
          "timestamp": "2026-08-13T13:00:52Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "P2",
            "sweeper:risk-session-state",
            "comp/desktop",
            "area/sessions",
            "area/profiles"
          ],
          "author": "kiwiwyl",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:3ee718441aa8aec4c39d",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85289",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85289",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(models): add openrouter.show_all_models opt-in to surface every live tool-capable model",
          "text": "## Summary Adds an opt-in config flag, `openrouter.show_all_models: true`, that makes the OpenRouter picker return **every live model supporting tool-calling** from `GET /v1/models` instead of only the curated subset. Default behavior is unchanged (curated list). This lets BYOK users — or anyone who wants the full list — see models that aren't in the curated catalog, e.g. brand-new models released between catalog refreshes. ## Motivation The curated catalog is a deliberate UX choice, but users who Bring-Your-Own-Key (e.g. Google Gemini keys via OpenRouter BYOK) frequently find the models they configured aren't in the curated list. Rather than expanding the curated list (which maintainers have declined for good reason), this makes the full live list available behind an opt-in flag — no curated-list changes needed. ## Changes - `hermes_cli/models.py`: in `fetch_openrouter_models()`, when `openrouter.show_all_models` is truthy in config.yaml, rebuild `preferred_ids` from every live model that passes `_openrouter_model_supports_tools()`. - `tests/hermes_cli/test_models.py`: two new tests — opt-in path surfaces uncurated tool-capable models (and still filters non-tool ones); default path keeps non-curated models hidden. ## Usage ```yaml # config.yaml openrouter: show_all_models: true ``` ## Testing `python -m pytest tests/hermes_cli/test_models.py` → 32 passed.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85289",
          "createdAt": "2026-08-13T12:44:22Z",
          "updatedAt": "2026-08-13T12:59:59Z",
          "timestamp": "2026-08-13T12:59:59Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "provider/openrouter",
            "P3",
            "sweeper:risk-compatibility"
          ],
          "author": "TCK0423",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:cb5d177e1a019683628f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85290",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85290",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(delegation): independent fallback chain for subagents",
          "text": "## What does this PR do? Adds `delegation.fallback_providers` so subagents can run on a fallback chain that differs from the parent agent's top-level `fallback_providers`. Today `_build_child_agent` unconditionally passes the parent's `_fallback_chain` as the child's `fallback_model`, so a subagent pinned to a cheap/fast provider still fails over to the main agent's fallback models instead of a subagent-appropriate backup. Concrete use case: main agent runs `openrouter/deepseek-v4-pro-0813` with `openrouter/glm-5.2` as fallback, while subagents run `openrouter/deepseek-v4-flash-0731` and should fail over to a local `ascent-dspark` endpoint — not to the main agent's fallback chain. ## Related Issue Fixes #65038 ## Type of Change - [x] ✨ New feature (non-breaking change that adds functionality) - [x] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) ## Changes Made - `hermes_cli/config_defaults.py` — new `delegation.fallback_providers: []` default (empty = inherit parent chain, backward compatible) - `tools/delegate_tool.py` — `_normalize_delegation_fallback_chain()` validates entries against the provider+model contract; `_resolve_delegation_credentials()` forwards the chain; `_build_child_agent()` accepts `override_fallback_providers` and falls back to the parent chain when unset/empty; delegate tool description mentions the knob - `tests/tools/test_delegate.py` — 3 new tests: override wins, empty list inherits parent chain, malformed entries are dropped - `website/docs/user-guide/configuration.md` + `website/docs/user-guide/features/fallback-providers.md` — document the new key",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85290",
          "createdAt": "2026-08-13T12:46:50Z",
          "updatedAt": "2026-08-13T12:59:54Z",
          "timestamp": "2026-08-13T12:59:54Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "tool/delegate",
            "area/config",
            "P3",
            "sweeper:risk-compatibility"
          ],
          "author": "motochan",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:7ff4be798ef42b9e45db",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85291",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85291",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): preserve governing outcome across compaction handoffs",
          "text": "## Summary - preserve the user's governing outcome separately from the current subtask, latest correction, and one reference-only next step in local compaction handoffs - validate the continuation schema before persistence, including deterministic fallback and no-user-provenance paths - keep micro-compaction noncanonical while preserving chronological authority across post-summary and merged-tail continuations ## Root cause The local context summarizer preserved recent task history but did not encode which user outcome remained authoritative versus a subtask, correction, or candidate next action. After compaction, a context-dependent follow-up such as \"continue\" or \"what next?\" could therefore resolve to a stale or superseded route. ## Scope This is intentionally limited to the local `ContextCompressor` handoff described in #78457. It does not change native server compaction or material-intervention/tool-lifecycle behavior. #85156 remains complementary: it preserves plaintext user messages before native compaction checkpoints, while this change strengthens the semantic handoff produced by the local summarizer. ## Validation - focused compaction matrix: 271 passed - neighboring compaction matrix: 88 passed - ghost-skill regression suite: 13 passed - compressed-summary metadata suite: 9 passed - native compaction non-interference suite: 45 passed - full `tests/agent/`: no additional failures versus a clean `origin/main` baseline; the same 18 files / 32 environment or baseline failures reproduced on both trees - `git diff --check` Addresses the compaction-handoff portion of #78457.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85291",
          "createdAt": "2026-08-13T12:50:54Z",
          "updatedAt": "2026-08-13T12:59:45Z",
          "timestamp": "2026-08-13T12:59:45Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "P2",
            "needs-decision",
            "sweeper:risk-session-state",
            "area/compression"
          ],
          "author": "akivavh",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:b57a65f9c9a1926dc11a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:68248",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:68248",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat: add needs review tracking for skills and update status output",
          "text": "## Summary Adds recent outcome telemetry for skills and surfaces a `needs_review` signal in curator reporting. ## Why The curator's automatic transitions (active → stale → archived) are driven purely by recency. A skill that is invoked constantly and fails constantly looks identical to one that works fine. This PR adds an outcome dimension so unreliable skills can be flagged. ## What changed ### `tools/skill_usage.py` - Added `bump_outcome(skill_name, success)` to record recent success/failure events - Introduced bounded `recent_outcomes` (last 20), `needs_review`, and `needs_review_since` - `needs_review` flips on at ≥50% failure rate with minimum 4 samples; flips off on recovery - Added `failure_rate()` read-only helper - `set_state(..., STATE_ARCHIVED)` clears `needs_review` ### `agent/curator.py` - Counts `needs_review` candidates during automatic transitions - Includes `needs_review` in curator run summaries ### CLI reporting - Shows `needs_review` skill list in `hermes curator status` - Prints `needs_review=&lt;count&gt;` in `hermes curator run` summaries ### Tests - `test_skill_usage.py`: window capping, threshold crossing, min-sample floor, recovery, archiving clears flag - `test_curator_status.py`: needs_review counting and reporting ## Validation ```bash python -m pytest test_skill_usage.py test_curator_status.py -q",
          "url": "https://github.com/NousResearch/hermes-agent/pull/68248",
          "createdAt": "2026-07-20T22:23:29Z",
          "updatedAt": "2026-08-13T12:58:48Z",
          "timestamp": "2026-08-13T12:58:48Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/cli",
            "tool/skills",
            "P3"
          ],
          "author": "Gabrielnkl",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:075cb1a15ec8c55bb6b8",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84925",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84925",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway): honor per-platform session isolation overrides",
          "text": "## Summary - honor per-platform `group_sessions_per_user` and `thread_sessions_per_user` session-key overrides - preserve nested `<platform>.extra` values while loading config - keep sender attribution aligned with the same effective per-platform isolation policy ## Test plan - `pytest tests/gateway/test_session.py tests/gateway/test_config.py tests/gateway/test_whatsapp_text_batching.py tests/gateway/test_whatsapp_group_gating.py -q` - `ruff check gateway/config.py gateway/run.py gateway/session.py tests/gateway/test_config.py tests/gateway/test_session.py` Adapted from #81208 while preserving original authorship in the first two commits.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84925",
          "createdAt": "2026-08-13T00:53:25Z",
          "updatedAt": "2026-08-13T12:58:29Z",
          "timestamp": "2026-08-13T12:58:29Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "area/config",
            "P2",
            "sweeper:risk-session-state",
            "sweeper:risk-compatibility"
          ],
          "author": "Diaspar4u",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:1e3f6f34cce72ac74091",
        "signalId": "github:NousResearch/hermes-agent:issue:64231",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:64231",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "chore(plugins): lifecycle-event catalog, hook taxonomy, and batch disposition of pending hook PRs",
          "text": "## Summary Define a coherent lifecycle-event catalog and hook-acceptance standard, then triage the entire pending observer-hook PR cluster against it in one pass — instead of merging (or letting rot) a dozen unrelated one-off `VALID_HOOKS` additions. Output: a documented taxonomy + a batch salvage/disposition of the queued hook PRs. Part of the plugin-interface expansion tracking issue #64182. Added in round-2 review. ## Motivation The PR queue holds a large cluster of small, mostly-reasonable observer-hook proposals, each adding one event: - compression lifecycle — PR #53806 (@ledfoot631) - memory write hooks — PR #55765 (@alienfrenZyNo1) - goal lifecycle — PR #27790 (@clicheman) - session mode change (ACP) — PR #53087 (@alanjds) - session title — PR #41752 (@draplater) - gateway startup — PR #23771 (@im-kris) - slash-command pre/post — PR #46581 (@nuffin) - cron run lifecycle — PR #41833 (@mathmonstergo), issue #57971 - curator run — PR #43780 (@Sahil-SS9) - turn_failed — PR #56720 (@brandonedley) - cli_queue_drained — PR #54307 (@0xAdamFortuna) - kanban dispatch tick — PR #56066 (@laboratoiresonore); kanban observability RFC #58548 (@thebizfixer) - classify_api_error — PR #58524 (@webdevtodayjason) - session context propagation — PR #42416 (@Gerkinfeltser) Merging these piecemeal grows `VALID_HOOKS` without design coherence (naming drift, inconsistent payloads, no privacy review per event). Rejecting them piecemeal wastes exactly the contributor energy this expansion is meant to capture. ## Proposed work ### 1. Hook taxonomy + acceptance checklist (docs page, enforced in review) - **Naming grammar:** `<subsystem>_<noun>_<verb-past>` for observers (`session_title_changed`, `cron_run_finished`); `transform_*` for output transforms; middleware stays its own registry. - **Payload rules:** keyword-only, schema-versioned (extend `telemetry_schema_version`), fields documented at the VALID_HOOKS entry, additive-only evolution (per #64179). - **Classification required at proposal time:** observer / transform / middleware — observers get the low bar, anything mutating needs the middleware contract. - **Per-event privacy note:** what user content the payload carries, and any config gate needed (the reasoning-delta precedent from #64161). - **Cost rule:** `has_hook()` short-circuit mandatory at hot-path call sites. - **Bus relationship:** which core events also mirror onto the inter-plugin event bus (#64164) as `hermes:`-namespaced events, so plugins get one subscription surface. ### 2. Catalog v1 Table of every existing hook + the accepted additions from the cluster: name, fire point (file:site), payload schema, classification, privacy note. Lives in docs; VALID_HOOKS comments point at it. ### 3. Batch disposition of the cluster Review each PR above against the taxonomy: salvage-with-credit (rename/reshape payload to the grammar as follow-up commits on top of the contributor's work), fold into an already-filed sub-issue (e.g. cron hooks vs the cron section of the catalog), or close-with-credit where superseded. Every disposition recorded in a table on this issue. ## Backward compatibility Existing hook names/payloads are grandfathered verbatim — the grammar applies to new additions. No renames of shipped hooks. ## Dependencies - #64179 (payload versioning rules), #64164 (bus mirroring decision per event). ## Acceptance criteria - Taxonomy doc merged; VALID_HOOKS docstring references it. - Catalog covers 100% of shipped hooks. - Every PR in the cluster above has a recorded disposition and a comment; salvaged ones merged with contributor authorship preserved.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/64231",
          "createdAt": "2026-07-14T06:38:45Z",
          "updatedAt": "2026-08-13T12:58:01Z",
          "timestamp": "2026-08-13T12:58:01Z",
          "metrics": {
            "reactions": 0,
            "comments": 26
          },
          "labels": [
            "type/feature",
            "comp/plugins",
            "P3",
            "needs-decision"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:9f312b3eab49e02acd5b",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84529",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84529",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway): call write_eof() on SSE error paths (connection leak fix)",
          "text": "## Problem When SSE streams encounter errors, the HTTP response is not properly closed. This causes connections to leak and exhaust the connection pool, causing subsequent requests to hang or fail. ## Fix Add `write_eof()` calls on all 4 SSE error paths in `gateway/platforms/api_server.py`: - `_handle_session_sse`: write_eof on session stream error - `_handle_agent_sse`: write_eof on successful stream end - `_handle_agent_sse`: write_eof on error chunk path - `_handle_agent_sse`: write_eof on agent crash path ## Files Changed - `gateway/platforms/api_server.py` — 4 lines added (write_eof calls) ## Split Plan This is PR 2 of 3. See the comment on the original PR #78467 for the full split plan. - **PR 1 (SSE keepalive):** `agent/conversation_loop.py` — stream_delta_callback fix - **PR 3 (relay recovery):** `agent/relay_runtime.py` — scope stack rewrite",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84529",
          "createdAt": "2026-08-12T12:39:41Z",
          "updatedAt": "2026-08-13T12:56:35Z",
          "timestamp": "2026-08-13T12:56:35Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "P2",
            "sweeper:risk-message-delivery",
            "area/streaming"
          ],
          "author": "gottabstrong",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:f66327c911e4174f3f1e",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84531",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84531",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(relay): recover from scope stack out-of-sync errors (nemo_relay bug workaround)",
          "text": "## Problem When a session closes with errors, the nemo_relay scope stack gets out of sync, causing scope.pop() to raise RuntimeError: 'scope handle is not at the top of the stack'. This is a bug in nemo_relay's native Rust extension. Stale handles block future sessions from starting. ## Fix Adds `_attempt_stack_recovery()` which: 1. Peeks at the scope stack to find the target handle's position 2. Pops everything above the target handle 3. Pops the target handle itself 4. Falls back to clearing the entire stack if recovery fails Called from `end_turn()` when a RuntimeError is caught during scope.pop(), preventing stale handles from blocking future sessions. ## Files Changed - `agent/relay_runtime.py` — 76 lines added (new method + error handler) ## Split Plan This is PR 3 of 3. See the comment on the original PR #78467 for the full split plan. - **PR 1 (SSE keepalive):** https://github.com/NousResearch/hermes-agent/pull/84528 - **PR 2 (write_eof fix):** https://github.com/NousResearch/hermes-agent/pull/84529 **Dropped from original PR:** - API server 0.0.0.0 default binding (security concern, PR #83546 handles this) - Query-string API key fallback (security concern, PR #83546 handles this) - Non-TTY auto-replace (converts explicit contract to automatic)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84531",
          "createdAt": "2026-08-12T12:43:20Z",
          "updatedAt": "2026-08-13T12:56:30Z",
          "timestamp": "2026-08-13T12:56:30Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "P3",
            "telemetry",
            "sweeper:risk-session-state"
          ],
          "author": "gottabstrong",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:22cc61f824585016df60",
        "signalId": "github:NousResearch/hermes-agent:pull_request:80353",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:80353",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(api): surface agent context-window occupancy to thin clients",
          "text": "## What does this PR do? Surface the agent's live context-window occupancy as persisted session fields, so thin clients can display meaningful context usage without computing it from token sums or probing `model_config`. Currently the API server's session response exposes cumulative lifetime counters (`input_tokens` through `reasoning_tokens`) but nothing that tells a client \"how full is the agent's context window right now.\" Thin clients that want a context ring either sum the cumulative counters (fabricating readings like 1.9M/128K, see issue #50421) or attempt to parse `model_config` (not exposed by the server for security). This PR closes that gap by having the agent persist its compressor's measured occupancy onto the session row at each token-count update, and exposing it in the session detail endpoint. This is purely additive — existing callers and code paths are untouched. The two new columns default to `0` (meaning \"unknown\"), and `COALESCE` semantics prevent stale readings from being clobbered when a later update carries no context info. ## Changes Made 1. **`hermes_state_common.py`**: Added `context_window`/`context_used` columns to `sessions` table schema (`INTEGER NOT NULL DEFAULT 0`). Auto-added to existing DBs by `_reconcile_columns()`. 2. **`hermes_state.py`** (`update_token_counts`): Both absolute and incremental SQL branches persist context with `COALESCE` no-clobber semantics. 3. **`agent/context_breakdown.py`**: New `context_window_usage(agent)` helper — reads the agent's compressor state, mirrors the desktop TUI's sentinel logic. 4. **`agent/codex_runtime.py`**, **`agent/conversation_loop.py`**: Both token-count update paths now pass `**context_window_usage(agent)`. 5. **`gateway/platforms/api_server.py`**: Exposes `context_window`/`context_used` in the session detail payload. 6. **Tests**: 10 new tests across `tests/agent/test_context_breakdown.py` (5) and `tests/test_hermes_state.py` (5). ## Type of Change - [x] ✨ New feature (non-breaking change that adds functionality) ## How to Test ```bash # Run the new tests pytest tests/agent/test_context_breakdown.py -k \"context_window_usage\" pytest tests/test_hermes_state.py::TestContextWindowPersistence Or manually: 1. Start a session 2. `GET /api/sessions/{id}` → verify `context_window` and `context_used` are present (nonzero after one turn) 3. Send a follow-up message → verify fields update ## Checklist - [x] My commit message follows Conventional Commits - [x] My PR contains only changes related to this feature - [x] Tests pass: 10/10 - [x] Tested on: Windows 11 - [x] No new config keys, env vars, or core tools - [x] Cross-platform impact: none (no OS-specific code) --- **Commit summary:** 8 files, +173/-5 on current `origin/main` (HEAD `069551d19`). Single atomic commit with feature + tests. Ready to PR.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/80353",
          "createdAt": "2026-08-06T13:25:37Z",
          "updatedAt": "2026-08-13T12:56:03Z",
          "timestamp": "2026-08-13T12:56:03Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/gateway",
            "P3",
            "sweeper:risk-session-state",
            "area/usage-cost"
          ],
          "author": "mr-september",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:752e862034a672084661",
        "signalId": "github:NousResearch/hermes-agent:issue:84997",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:84997",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: Desktop — switching into an actively-streaming session lands the transcript on old history (scroll jitter + sticky-bottom disengage)",
          "text": "## Bug Description **Hermes Desktop**: switching into a session whose agent is **still running/streaming** makes the transcript bounce up and down for a few frames, then the viewport settles on an **older part of the transcript** while the reply keeps streaming below the fold. The user loses sight of the live turn and must scroll down manually. This is a distinct trigger of the same scroll-disengage bug family as #78486 (\"chat view jumps to a historical message block while the assistant is still responding\") — same mechanism, stronger repro path: the session-switch path adds a settle loop racing the streaming growth, and the final state (sticky-bottom disengaged, view parked on history) is identical. A full mechanism walkthrough with line numbers has been posted as a comment on #78486. ## Steps to Reproduce 1. Open Hermes Desktop. Start a conversation in session A; send a prompt that produces a long, tool-heavy streaming reply (long answers with tool calls make it most reliable). 2. While the reply is still streaming, switch to session B, then switch back to A. 3. Watch the transcript: it lurches up and down several times, then parks on an **earlier message block** (\"Show earlier\" button becomes visible at the top of the viewport). The stream continues below the fold. 4. The view never returns to the bottom on its own; the floating jump-to-bottom button appears (sticky-bottom has disengaged). ## Expected Behavior Switching into a running session should land the viewport on the **latest message** (or at least re-follow the stream once the switch settles), exactly like switching into an idle session does. The brief backfill of older turns should not visibly lurch the viewport, and the budget cut should never be interpreted as a user scroll-up. ## Actual Behavior - Viewport bounces up and down several times during the switch (settle loop vs. stepped backfill racing the streaming height growth). - Viewport ends up parked on a **historical message block** — precisely at the render-budget cut boundary (\"Show earlier\" visible at top). - Sticky-bottom is permanently disengaged: `isAtBottom=false`, composer dims, jump button shows, nothing re-pins (runStart already fired in the background; the settle loop handed back control). ## Root Cause Analysis All paths verified against source at `7113a6924` (`apps/desktop` package 0.17.0), running against desktop-runtime 0.20.0. **Phase 1 — the bounce.** `apps/desktop/src/components/assistant-ui/thread/list.tsx`: - The session-switch settle loop (L569-621) glues `scrollTop = scrollHeight` every frame until the height is stable for 2 frames (or the 15-frame cap). For a **streaming** target session, `scrollHeight` changes every frame, so the loop always runs the full 15-frame cap. - Simultaneously the backfill effect (L426-447) steps `renderBudget` 20 → paneBudget in +60-unit rAF steps, each step **prepending** older turns (growing height). The settle loop's per-frame re-pin fights that growth → the visible up/down bounce. **Phase 2 — parked on history.** Still in `list.tsx`: - `weightSignature` ticks on every streamed token (L326-328) → `weightedGroups` recompute → `firstVisibleGroupIndex()` (L468-472, RENDER_BUDGET=600) grows `hiddenCount` as the streaming turn gains weight → turns above the viewport are **unmounted** → `scrollHeight` shrinks → the browser **clamps `scrollTop`**. - `use-stick-to-bottom@1.1.6`'s `handleScroll` escapes the lock on `scrollTop < lastScrollTop` (dist/useStickToBottom.js L265-274). The clamp is not a programmatic write through the library's `state.scrollTop` setter, so `ignoreScrollToTop` is never set → the library reads the clamp as an intentional user scroll-up → `escapedFromLock=true`, `isAtBottom=false`. - The library's negative-resize rescue (L344-353) only re-locks when within 70px of the bottom; after a large budget cut the view sits far above it, so it never re-engages. - Nothing re-pins afterwards: `thread.runStart` → `scrollToBottom()` (L551) fired in the background before the switch and does not re-fire; the settle loop already handed back control (`loadSettledRef = true`). Note: this is **not** the per-session scroll-restore feature — PR #69554 (per-session `scrollTop` persistence for #45562) is unmerged and no such code exists in the tree. ## Proposed Fix (options, in increasing invasiveness) 1. Exempt the streaming turn's group from budget cuts while a run is active — extend the existing `liveTailStart` exemption (L225-250) to `firstVisibleGroupIndex` (L168-181). Smallest change, kills Phase 2. 2. Before a budget cut, capture distance-from-bottom and restore it in the same commit (same pattern as `anchorBeforePrepend`, L405-409) so the clamp never produces a synthetic scroll-up. 3. For Phase 1: make the settle loop treat a streaming session as \"unstable by definition\" — skip the per-frame glue and go straight to `scrollToBottom('instant')` + backfill, or gate the settle on the run state. The analysis and proposed fix directions above are provided for maintainers or any contributor who picks this up. ## Related Issues - #78486 — original report of the same disengage mechanism (main thread; mechanism walkthrough is there) - #83151 — latest prompt/answer appearing mid-thread (same code area, Mode B branch is this bug) - #59882 — PR \"anchor streaming replies on run start\" (open, unmerged; direction conflicts with this bug) - #81355 — feature request: always-follow-typing toggle (wants the inverse of the disengage) - #73990 — unconditional runStart scrollToBottom yanks readers of history - #65714 — feature: disable auto-scroll during streaming - #70618 — viewport doesn't auto-scroll after task completion - #70447 / #76030 / #73766 — related scroll-failure reports in the same component ## Environment - **Product**: Hermes Desktop (packaged portable edition) - **Desktop app**: `apps/desktop` package 0.17.0 (desktop-runtime 0.20.0) - **Source inspected**: `~/.hermes/hermes-agent` @ `7113a6924` (current `main`) - **OS**: Windows 11 Pro - **Mode**: Desktop chat UI, multi-session tabs",
          "url": "https://github.com/NousResearch/hermes-agent/issues/84997",
          "createdAt": "2026-08-13T02:55:14Z",
          "updatedAt": "2026-08-13T12:55:13Z",
          "timestamp": "2026-08-13T12:55:13Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "duplicate",
            "P3",
            "comp/desktop"
          ],
          "author": "licat2023",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:52137c0c5dc3456b60b3",
        "signalId": "github:NousResearch/hermes-agent:pull_request:82236",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:82236",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix: ignore glob-shaped skill support paths",
          "text": "## What does this PR do? Direct URL skill installation scans SKILL.md for referenced support files. Prose such as ``references/*.md`` currently matches the local-path parser, causing Hermes to request a literal file named `*.md` and reject otherwise valid skill bundles. This change ignores glob-shaped path candidates (`*`, `?`, `[` or `]`) while continuing to fetch explicit concrete references and preserving valid query/fragment suffixes, including extensionless paths such as `references/LICENSE?download`. It also URL-encodes decoded support-file paths at both direct-URL and GitHub Contents API fetch boundaries, so concrete filenames containing encoded delimiters (for example `%23`) are requested as files rather than mistaken for URL fragments. ## Related Issue Related: https://github.com/Panniantong/Agent-Reach/issues/584 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✅ Tests (adding or improving test coverage) ## Changes Made - Updated `tools/skills_hub.py::_referenced_support_paths` to skip glob-shaped pseudo-paths. - Preserved legitimate query/fragment suffixes, including valueless queries on extensionless support files. - Added fetch-safe encoding for decoded support paths in `UrlSource` and `GitHubSource`. - Added regression coverage proving concrete links are preserved while `references/*.md` prose is ignored. ## How to Test 1. Run `uv run --with pytest python -m pytest tests/tools/test_skills_hub.py -q -o 'addopts='`. 2. Install an Agent Reach `SKILL.md` URL containing the former prose reference. 3. Confirm Hermes installs `SKILL.md` and its seven explicit reference files without requesting a literal `references/*.md` path. ## Checklist ### Code - [x] I've read the Contributing Guide. - [x] My commit messages follow Conventional Commits. - [x] I searched existing PRs; PR #30633 covers install exit status but not support-path parsing. - [x] My PR contains only changes related to this parser fix. - [ ] I've established the complete `pytest tests/ -q` suite as green; an initial run lacked optional dependencies, and a broader-extra run encountered environment/integration failures outside the changed modules before completion. The two changed modules pass independently; their combined order-dependent failure reproduces unchanged on current `main`. - [x] I've added tests for the bug. - [x] Tested on macOS 14.1. ### Documentation & Housekeeping - [x] Documentation update: N/A; behavior is internal and covered by tests. - [x] `cli-config.yaml.example`: N/A. - [x] `CONTRIBUTING.md` / `AGENTS.md`: N/A. - [x] Cross-platform impact considered; the check is platform-independent string parsing. - [x] Tool descriptions/schemas: N/A. ## Verification - `tests/tools/test_skills_hub.py` — **98 passed** - `tests/tools/test_skill_bundle_provenance.py` — **6 passed** - Ruff — passed - `compileall` — passed - Isolated direct-URL Agent Reach installation — installed `SKILL.md` plus all seven explicit references successfully",
          "url": "https://github.com/NousResearch/hermes-agent/pull/82236",
          "createdAt": "2026-08-09T03:57:12Z",
          "updatedAt": "2026-08-13T12:54:14Z",
          "timestamp": "2026-08-13T12:54:14Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "tool/skills",
            "P2"
          ],
          "author": "fifeli",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:d7f8ef4a50f6f282a7c1",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85212",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85212",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(skills): skip glob and directory refs in support-path extraction (#85172)",
          "text": "## What does this PR do? `hermes skills install <url>` fails with a misleading `Could not fetch '<url>' from any source` for large SKILL.md files that mention glob patterns or directory paths under allowed support dirs. Root cause: `_referenced_support_paths()` in `tools/skills_hub.py` treats any `references|templates|scripts|assets|examples/...` token (matched by `_LOCAL_LINK_RE`) as a downloadable support file. `UrlSource.fetch()` then tries to download each referenced path — glob patterns like `scripts/*` and directory references like `scripts/lib/` 404 on raw.githubusercontent.com, which makes the whole fetch return `None` and surface as \"Could not fetch\". `hermes skills inspect` works on the same URL because it only reads the SKILL.md itself. Fix: skip glob patterns (containing `*`, `?`, `[`, `]`) and directory references (ending in `/`) when extracting support paths. Real files are still fetched, and the existing path-traversal guard (`_SUSPICIOUS_LOCAL_REF_RE` → `None`) is unchanged. ## Related Issue Fixes #85172 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `tools/skills_hub.py`: `_referenced_support_paths()` now skips glob patterns and directory references. - `tests/tools/test_skills_hub.py`: new `TestReferencedSupportPaths` covering glob/dir filtering, traversal rejection, and empty input. ## How to Test 1. `hermes skills inspect https://raw.githubusercontent.com/mvanhorn/last30days-skill/main/skills/last30days/SKILL.md` → succeeds (unchanged). 2. Before fix: `hermes skills install <same URL> --yes` → `Error: Could not fetch ...` (repro from #85172). 3. After fix: install proceeds; only real referenced files (`scripts/last30days.py`, `scripts/lib/categories.py`, `references/save-html-brief.md`) are fetched, glob/dir refs are skipped. 4. Unit: `python -m pytest tests/tools/test_skills_hub.py -k ReferencedSupportPaths` (verified standalone since the repo's full test env isn't available here). ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass (no local pytest; new logic verified standalone, existing behavior unchanged) - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS (logic-level verification with the repro file from #85172) ### Documentation & Housekeeping - [x] N/A — no docs, config-keys, or architecture changes.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85212",
          "createdAt": "2026-08-13T09:59:12Z",
          "updatedAt": "2026-08-13T12:54:12Z",
          "timestamp": "2026-08-13T12:54:12Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "duplicate",
            "comp/gateway",
            "tool/skills",
            "P2"
          ],
          "author": "SeashoreShi",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:8281b9e9509137242025",
        "signalId": "github:NousResearch/hermes-agent:pull_request:81910",
        "event": "discovered",
        "observedAt": "2026-08-13T13:48:00.446149Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:81910",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "perf(desktop): keep built-in theme fonts local",
          "text": "## Summary Keep the built-in Nous and Midnight theme font stacks fully local. - Midnight now uses the JetBrains Mono WOFF2 faces already shipped in the Desktop bundle instead of requesting the same family from Google Fonts. - The default Nous stack uses native monospace faces first and bundled JetBrains Mono as its cross-platform fallback, removing an unused Courier Prime request. - Ember retains its IBM Plex Mono URL because that remote face is an explicit theme choice rather than an unused fallback. ## Root cause Midnight declared a Google Fonts stylesheet for JetBrains Mono even though regular, bold, and italic faces are bundled by `styles.css`. There was also a less obvious inheritance issue: typography is merged with the Nous defaults, so removing only Midnight's URL made it inherit the Nous Courier Prime URL. On macOS that stylesheet was fetched even though Menlo, earlier in the stack, was the rendered face. ## Measured impact Runtime verification used Chromium performance resource entries on the packaged macOS arm64 app with Midnight active. | Startup resource | Before | After | |---|---:|---:| | Google Fonts requests | 2 | **0** | | Active external font stylesheets | 1 | **0** | The post-change renderer reported the local JetBrains Mono stack and no `fonts.googleapis.com` or `fonts.gstatic.com` resources. This is primarily a startup/network/privacy cleanup; no battery-life percentage is claimed. ## Validation - 12 theme preset tests passed - Desktop typecheck passed - Desktop lint passed - `git diff --check` passed - Packaged and runtime-verified on macOS arm64",
          "url": "https://github.com/NousResearch/hermes-agent/pull/81910",
          "createdAt": "2026-08-08T18:01:17Z",
          "updatedAt": "2026-08-13T12:52:30Z",
          "timestamp": "2026-08-13T12:52:30Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/perf",
            "P3",
            "comp/desktop"
          ],
          "author": "alexdev03",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:cac9bdc4d687ec48adad",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85421",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85421",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(telegram): rebind TypeHandler in the deferred SDK import (fixes #85272)",
          "text": "Root cause for #85272, plus a three-line fix. ## What happens `check_telegram_requirements()` re-imports python-telegram-bot after a lazy install and rebinds the module-level aliases that the top-level `except ImportError` block set to `typing.Any`. **`TypeHandler` is missing from all three places**: the `global` declaration, the `from telegram.ext import (...)` list, and the assignment block. It is set to `Any` on line 265 along with everything else, so it is the one alias never restored. Whenever the top-level import fails and the deferred path runs, every other alias is restored and `TELEGRAM_AVAILABLE` flips to `True` — while `TypeHandler` stays `Any`. The adapter then instantiates it while registering handlers and raises `TypeError: Any cannot be instantiated`: ``` WARNING [Telegram] Discovering Telegram API fallback IPs via DNS-over-HTTPS… ERROR [Telegram] Failed to connect to Telegram: Any cannot be instantiated WARNING Gateway started with no connected platforms — 1 platform(s) queued for retry ``` The 22.6 → 22.8 pin bump named in #85272 is the trigger, not the defect: it makes the top-level import fail, which is what sends the module down the deferred path where the missing rebinding has always been. ## Repro macOS 15.5 (Apple Silicon), Hermes v0.20.0, python-telegram-bot 22.8, CPython 3.11. 1. Configure a Telegram gateway with a valid bot token 2. `hermes gateway run` Network and credentials are not involved — `getMe` against the same token returns `ok: true`, and `api.telegram.org` resolves and connects from the same host. With the patch applied the gateway connects and long-polls normally; Telegram then reports `Conflict: terminated by other getUpdates request` to any second poller, confirming the adapter holds the connection. ## Note The three sibling aliases in the same block (`Update`, `Bot`, `Message`, `Application`, `HTTPXRequest`, …) are all handled correctly — this is an omission for one name rather than a design problem, so the patch just completes the existing pattern.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85421",
          "createdAt": "2026-08-13T16:19:30Z",
          "updatedAt": "2026-08-13T16:19:30Z",
          "timestamp": "2026-08-13T16:19:30Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "lesyuk",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:2d5bbd5af4073903e593",
        "signalId": "github:NousResearch/hermes-agent:issue:66616",
        "event": "changed",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:66616",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[skills-index-watchdog] Skills index is stale or degraded (degraded)",
          "text": "Automated freshness probe failed. **Status:** `degraded` **Detail:** Index is 29.8h old (limit 26h) The Skills Hub at /docs/skills depends on `/docs/api/skills-index.json`. The unified index is rebuilt by `.github/workflows/skills-index.yml` (cron 6/18 UTC) and `.github/workflows/deploy-site.yml` (on every push affecting website/skills). If this issue keeps reopening, check the latest runs: - https://github.com/NousResearch/hermes-agent/actions/workflows/skills-index.yml - https://github.com/NousResearch/hermes-agent/actions/workflows/deploy-site.yml This issue was opened by `.github/workflows/skills-index-freshness.yml`. Close it once the underlying problem is fixed; the next probe will reopen if it's still broken.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/66616",
          "createdAt": "2026-07-18T00:32:23Z",
          "updatedAt": "2026-08-13T16:18:53Z",
          "timestamp": "2026-08-13T16:18:53Z",
          "metrics": {
            "reactions": 0,
            "comments": 23
          },
          "labels": [
            "type/bug",
            "tool/skills",
            "P3",
            "sweeper:risk-automation"
          ],
          "author": "nousbot-eng",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:3ecea3850e46fc93b2f6",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85293",
        "event": "changed",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [
          "updatedAt",
          "state"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85293",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat: add Nous Blue Contrast theme",
          "text": "## Summary - add a separate `nous-blue-contrast` dashboard preset that preserves Nous Blue chrome while using a navy, high-contrast embedded terminal - add a matching built-in TUI skin with a complete audited dark palette - expose both choices in dashboard configuration and document them - keep the existing `nous-blue` theme unchanged ## Test plan - `scripts/run_tests.sh tests/hermes_cli/test_skin_engine.py tests/hermes_cli/test_skin_palettes.py` (35 passed) - `npm run check --workspace web` (200 tests passed; typecheck and lint passed) ## Notes - independent pre-commit review found no blocking, security, or privacy issues - lint reports only the existing repository warnings, with no errors",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85293",
          "createdAt": "2026-08-13T12:56:52Z",
          "updatedAt": "2026-08-13T16:18:40Z",
          "timestamp": "2026-08-13T16:18:40Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "area/config",
            "P3",
            "comp/dashboard"
          ],
          "author": "gedigi",
          "state": "closed",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:489bd58c32269bbe3074",
        "signalId": "github:NousResearch/hermes-agent:issue:85420",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85420",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "BUG: Auto-Update Breaks Multi-Profile Setups — Gateways Not Restarted",
          "text": "## Summary After every Hermes auto-update, all profile gateways remain stopped. The sidebar shows a blank list with zero feedback to the user. Recovery requires manually running `gateway start` for each profile via CLI — an impossible task for non-technical users. ## Environment | Item | Value | |------|-------| | **OS** | Windows 10 | | **Hermes Version** | 0.20.0 | | **Profiles** | 6 (default, law, finance, calculator, lark, history) | | **Backend** | Python 3.11 + Node.js | | **Update Channel** | main (auto-update enabled) | ## Reproduction Steps 1. Ensure 3+ profiles have running gateways (`hermes --profile <name> gateway status` → ✓) 2. Trigger auto-update via the desktop app notification or `hermes update` 3. Wait for the update to finish — desktop app restarts automatically 4. Open the sidebar → **blank, no sessions visible** ## Expected Behavior - All gateways should be restarted automatically after update, OR - Desktop should detect missing gateways and auto-start them, OR - At minimum, show a clear error banner: \"Gateways offline — click to restore\" ## Actual Behavior - Sidebar renders completely empty - No error message, no loading indicator, no recovery prompt - User must discover via CLI that gateways are stopped ## Evidence ### 1. Update log confirms gateways are NOT restarted ``` [hermes] [updates] restart: Updating Hermes — this window will close and the updater will open. [hermes] [updates] launched repo hand-off script: ...\\scripts\\desktop-update.ps1; exiting desktop [hermes] [updates] update finished; proceeding with backend start [hermes] HERMES_BACKEND_READY port=65100 ← Only desktop own serve backend ``` After this line, **no gateway start commands are issued**. The `desktop-update.ps1` script terminates all Hermes processes (including gateways) but only restarts the desktop app. ### 2. Desktop fails to connect to profile backends ``` [hermes] [boot] could not read served dashboard token (Hermes backend for profile \"law\"): 404 [hermes] [boot] could not read served dashboard token (Hermes backend for profile \"finance\"): 404 [hermes] [boot] could not read served dashboard token (Hermes backend for profile \"calculator\"): 404 [hermes] [boot] could not read served dashboard token (Hermes backend for profile \"lark\"): 404 [hermes] [boot] could not read served dashboard token (Hermes backend for profile \"history\"): 404 ``` This error appears **693+ times** in a single log file — once per boot attempt per profile, every startup, because the gateways never came back after update. ### 3. Port conflict during desktop boot (ECONNRESET cascade) ``` [hermes] HERMES_BACKEND_READY port=64347 ← First attempt, port taken [hermes] HERMES_BACKEND_READY port=65100 ← Second attempt [hermes] HERMES_BACKEND_READY port=49432 ← Third attempt [hermes] HERMES_BACKEND_READY port=49464 ← Fourth attempt [hermes] HERMES_BACKEND_READY port=49522 ← ...keeps trying random ports ``` Port number keeps changing = desktop serve backend cannot bind its intended port because old zombie gateway processes still hold it. ### 4. UI-level symptom Renderer console shows: ``` Error invoking remote method \"hermes:api\": Error: read ECONNRESET Timed out connecting to Hermes backend after 60000ms ``` → Result: **Sidebar renders zero rows. User sees nothing.** ## Root Cause ``` Hermes Auto-Update Flow (BROKEN) ───────────────────────────────── 1. Detect update available 2. Run desktop-update.ps1 3. taskkill /F /IM \"Hermes.exe\" + child processes ✗ All gateway processes killed 4. Apply update to venv + source files 5. Restart desktop app ONLY ✗ No gateway restart logic 6. Desktop starts, tries to fetch sessions ✗ All gateways offline → ECONNRESET 7. Sidebar blank, no error shown ``` **The update script (`desktop-update.ps1`) only restarts the desktop app. It does not restart profile gateways.** ## Workaround (Temporary) After every update, manually: ```bash hermes --profile default gateway start hermes --profile law gateway start hermes --profile finance gateway start hermes --profile calculator gateway start hermes --profile lark gateway start hermes --profile history gateway start ``` This is **not acceptable** for normal users and defeats the purpose of auto-update. ## Suggested Fix (Pick Any) **Option A — Fix in update script** (recommended) `desktop-update.ps1` should, after restarting desktop, enumerate profiles and start each gateway: ```powershell # After restarting desktop foreach ($profile in (hermes profiles list --json | ConvertFrom-Json).name) { Start-Process hermes -ArgumentList \"--profile\",$profile,\"gateway\",\"start\" -WindowStyle Hidden } ``` **Option B — Fix in desktop app** On startup, desktop detects gateway state per profile and auto-starts missing ones. **Option C — At minimum, show error** If gateways are unreachable, show a visible banner: \"Gateways offline after update — [Restore All]\" ## Frequency **100%** — Reproducible on every single auto-update since multi-profile setup was configured. ## Severity **Medium-High** — Core UI breaks after standard software maintenance. Requires CLI recovery. No in-app indication of the problem.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85420",
          "createdAt": "2026-08-13T16:18:02Z",
          "updatedAt": "2026-08-13T16:18:02Z",
          "timestamp": "2026-08-13T16:18:02Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "willpeng2025",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:72fae4c4e902cc0ffd20",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85419",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85419",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(auth): harden shared Codex OAuth rotation",
          "text": "## Summary - Canonical root lock-held OAuth refresh/writeback with a random rotation-invariant grant identity. - Stale aliases converge; terminal failures quarantine matching aliases without touching independent grants/providers. - Native-Codex implementation/review preflight is strictly read-only, fail-closed, and runs before claim/spawn. - Compatibility is preserved for ordinary credential pool loading. ## Verification - Credential pool: 59 passed. - OAuth/Kanban: 57 passed + 1 Windows-only skip. - py_compile/diff-check: passed. No credential values or local/private paths are included.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85419",
          "createdAt": "2026-08-13T16:17:55Z",
          "updatedAt": "2026-08-13T16:17:55Z",
          "timestamp": "2026-08-13T16:17:55Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "fardoche6",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:8cd9dcf619afc23050b0",
        "signalId": "github:NousResearch/hermes-agent:issue:85418",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85418",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Memory provider proposal: local-first, zero-dependency agent memory benchmarked against Honcho, built on Hermes",
          "text": "Hi @DavidMetcalfe. You helped us out on #52954 back in July, the Chrome DevTools blank-profile issue. We've been running `--autoConnect` since and it fixed it for good. Thank you again for that. While running Hermes we ended up building a memory layer that we think fits Hermes' own philosophy, and we'd love your eyes on it. **What it is:** [MAYA Memory Lane](https://github.com/MAYA-Platform/MAYA-Memory-Lane) is a local-first, tamper-evident memory library. Every session seals a SHA-256-chained block, six blocks fold into a shelf, and a resume phrase is the only key that crosses sessions. Zero dependencies, just Node's built-in runtime, no npm install, no vector DB, no embedding API for the core. Files are the source of truth. The interface is just a window over them. **The part that surprised us:** we pre-registered a benchmark protocol (LongMemEval oracle, 500 instances, identical conditions, each system running its real pipeline) and measured: | Metric | Memory Lane | Honcho | LangMem | Mem0 | |---|---|---|---|---| | recall_all@5 | 61.1% | 40.0% | 72.2% | 68.1% | | recall_all@10 | 73.2% | 47.9% | 81.8% | 77.8% | | ndcg_any@5 | 62.0% | 28.5% | 35.9% | 33.0% | Plainly stated: LangMem and Mem0 out-retrieve us on raw recall. But we beat Honcho outright, lead on rank-aware ndcg@5, and do it with zero LLM calls, zero embeddings, zero recurring cost. We publish our own losses in the README because that's the point of a pre-registered protocol. **Why it might interest you:** the core runs on anything Node runs. We benchmarked it on a 2018 6-core desktop with a 4GB GPU that isn't even used, at $0 runtime. A hosted semantic memory API (Honcho) at 40% recall versus 61.1% for a deterministic SQLite FTS5 store with no model in the loop is a result we found worth reporting honestly. The full writeup is in the repo, at `benchmarks/SIDE_BY_SIDE_REPORT.md`. The protocol is at `benchmarks/BENCHMARK_PROTOCOL.md`, and the exact runners live in `benchmarks/harness/`. Every figure traces to a logged run, nothing hand-typed. No ask beyond a look. If you see a place this could slot into the Hermes memory story, or a reason it shouldn't, we'd genuinely value the honest take. Thanks either way, and again for the #52954 fix.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85418",
          "createdAt": "2026-08-13T16:17:40Z",
          "updatedAt": "2026-08-13T16:17:40Z",
          "timestamp": "2026-08-13T16:17:40Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "2ndNatureAI",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:b763ec96c913b87aef39",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85274",
        "event": "changed",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85274",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): bound large composer image previews",
          "text": "## What does this PR do? Fixes #41169. Supersedes #68744 while preserving David Metcalfe's original authorship on its two commits, replayed onto current `main`. Follow-up commits harden the fix for the reproduced 72-image failure and the async races exposed during independent review. The Desktop composer previously retained full-resolution image data URLs and rendered them as attachment thumbnails. Large images forced Chromium to decode and rasterize full-size sources on renderer display surfaces. With 72 images, the renderer emitted: ```text Failed to serialize op in 16777152 bytes webContents became unresponsive render-process-gone reason=crashed exitCode=133 ``` The `Failed to serialize op` message comes from Chromium's raster/PaintOp path, not Electron IPC. The parent Electron process, backend, and gateway remained alive; Electron replaced the failed renderer. The original PR's 2048×2048 thumbnail ceiling can itself decode to 16,777,216 RGBA bytes — 64 bytes above the logged 16,777,152-byte PaintOp serialization failure — so the hardened ceiling is intentionally 512px rather than 2048px. This PR bounds the complete composer-preview pipeline rather than only the final `<img>` dimensions: - serializes local/gateway reads and thumbnail creation so only one full image is read/decoded at a time; - creates display thumbnails with `createImageBitmap` + `OffscreenCanvas`, with a 512px longest edge; - closes decoded `ImageBitmap` resources and fails closed to a 1×1 placeholder instead of rendering the original when conversion is unavailable or fails; - retains only the bounded `thumbnailUrl` in new composer attachment state, rather than dozens of multi-megabyte full-resolution data URLs; - reads the original path only when the lightbox is opened, then releases that data URL when the lightbox closes; - keeps model input full resolution: submit/upload reads the authoritative on-disk original independently of the display thumbnail; - binds async preview completion to a renderer-lifetime per-occurrence UUID that survives session-draft cloning, so both A → B → A switching and remove + same-path reattach remain race-safe; - retains local-first reads with gateway fallback for local and remote project paths; - after cross-filesystem staging rewrites `path`, falls back to the preserved original host path in `detail` for lightbox/download; - uses the bounded thumbnail for the optimistic in-flight message bubble too, and never falls back to rendering `@image:<path>` while a queued thumbnail is still pending. No new custom protocol, URL allowlist, or navigation permission is introduced. The existing `MEDIA:` / transcript image path tracked by #42109 remains out of scope. ## Related Issue Fixes #41169 Supersedes #68744. A maintainer can close the conflicted original after accepting this attributed replacement. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [x] ♻️ Refactor (no intended external behavior change beyond fixing the failure) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `apps/desktop/src/lib/image-resize.ts` - serial one-at-a-time resize queue; - 512px default longest edge; - explicit bitmap cleanup and fail-closed fallback. - `apps/desktop/src/app/chat/hooks/use-composer-actions.ts` - serializes the full read + resize pipeline; - stores only the bounded thumbnail for new image chips; - updates only the exact optimistic attachment occurrence captured before async preview work, preserving identity across draft clones while preventing same-path replacement races. - `apps/desktop/src/lib/desktop-fs.ts` - centralizes local-first image reads with active-gateway fallback. - `apps/desktop/src/app/chat/composer/attachments.tsx` - renders bounded thumbnails; - keys each pill by occurrence identity so a removed occurrence's deferred lightbox read cannot populate a same-path replacement; - loads the full image on demand for lightbox/download, falls back from a staged backend path to the preserved host path, and releases it on close. - `apps/desktop/src/lib/chat-runtime.ts` - uses the bounded thumbnail in the optimistic in-flight bubble. - `apps/desktop/src/store/composer.ts` - documents thumbnail versus legacy/on-demand full-source semantics; - provides UUID-backed occurrence-identity conditional patching shared by main and tile composers; - patches the main live atom and inactive per-session draft stashes while merging into the latest occurrence state, so thumbnail and staging completions cannot overwrite each other. - `apps/desktop/src/app/chat/session-tile-actions.ts` - applies the same occurrence-aware staging merge in independently mounted session-tile composers, rejecting stale upload completion after remove + same-path reattach. - `apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts` - merges cross-filesystem staging metadata into the current occurrence instead of replacing concurrent preview state. Regression coverage verifies: - 72 image reads run one at a time; - 72 bitmap decodes run one at a time and every generated canvas is 512×512 or smaller; - composer state contains 72 bounded thumbnails and no full-resolution preview data; - removing an attachment during queued resize does not resurrect it; - removing and reattaching the same path cannot receive the removed occurrence's late thumbnail; - a delayed thumbnail resolving while another session is active updates only the original session's stashed draft and appears after returning; - thumbnail generation and submit-time staging may finish in either order in both the main and session-tile composers without dropping thumbnail, staged path, session ownership, or upload state; - remove + same-path reattach rejects stale staging completion in both composer scopes; - an old occurrence's deferred full-image read cannot open or populate a same-path replacement lightbox; - lightbox full-source data is read only on open and released on close; - a split-filesystem image remains previewable after its `path` is rewritten to a gateway-staged path; - local-first and gateway-fallback paths remain supported; - optimistic messages prefer the bounded thumbnail and render no path-backed image while resize is pending; - resize failures and unsupported APIs fail closed. ## How to Test 1. Attach or paste one large screenshot and verify the composer remains responsive. 2. Attach many large images (the reproduced report used 72) and verify the renderer remains responsive. 3. Verify attachment pills and the optimistic user bubble render bounded thumbnails. 4. Open/download an attachment and verify the original full-resolution image is used. 5. Send the prompt and verify the model receives the original image, not the thumbnail. 6. Remove a chip while thumbnails are still being created and verify it does not reappear. Automated verification on CachyOS Linux with Node 22.23.2: ```bash LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 TZ=UTC NODE_ENV=test \\ NODE_OPTIONS='--max-old-space-size=8192 --localstorage-file=/tmp/hermes-pr68744-full-ui-en-localstorage.json' \\ npm --workspace apps/desktop run test:ui # 423 files passed; 3,809 tests passed NODE_ENV=test NODE_OPTIONS='--max-old-space-size=8192 --localstorage-file=/tmp/hermes-pr68744-exact-localstorage.json' \\ npm --workspace apps/desktop exec -- vitest run --project ui \\ src/lib/image-resize.test.ts \\ src/app/chat/hooks/use-composer-actions.test.ts \\ src/lib/chat-runtime.test.ts \\ src/app/chat/composer/attachments.test.tsx \\ src/lib/desktop-fs.test.ts \\ src/store/composer.test.ts \\ src/app/chat/composer/hooks/use-composer-draft.test.tsx \\ src/app/session/hooks/use-prompt-actions/index.test.tsx \\ src/app/chat/session-tile-attachments.test.tsx # 9 files passed; 206 tests passed NODE_ENV=test npm --workspace apps/desktop run typecheck # passed NODE_ENV=test npm --workspace apps/desktop run lint # 0 errors; repository baseline warnings only, none in changed files NODE_ENV=production npm --workspace apps/desktop run build # production renderer and Electron bundles built; assert-dist-built passed git diff --check # passed ``` Running the full UI suite without forcing an English locale initially produced four locale-sensitive assertion failures (`1 234 567`/`25 USD` formatting versus en-US expectations). Re-running under the CI locale passed all 3,809 tests. Mutation proof: in an isolated worktree of the exact candidate, bypassing the end-to-end preview queue made the 72-image regression fail with `maxActiveReads = 72` versus the required `1`. Restoring the candidate returned the focused suite to 74/74. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for existing PRs; this is an attributed current-main replacement for conflicted #68744 - [x] My PR contains only changes related to this fix - [x] I've run the relevant Desktop automated tests - [x] I've added regression tests for the reported 72-image case - [x] I've tested on CachyOS Linux with Node 22.23.2 and a production Electron build ### Documentation & Housekeeping - [x] User documentation — N/A; no user-facing command or configuration changed - [x] `cli-config.yaml.example` — N/A; no config keys changed - [x] `CONTRIBUTING.md` / `AGENTS.md` — N/A; no workflow contract changed - [x] Cross-platform impact considered — uses Chromium/Electron-standard APIs with a safe fallback - [x] Tool descriptions/schemas — N/A; no agent tool changed ## For New Skills N/A — this PR does not add a skill.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85274",
          "createdAt": "2026-08-13T12:11:02Z",
          "updatedAt": "2026-08-13T16:17:24Z",
          "timestamp": "2026-08-13T16:17:24Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "P3",
            "comp/desktop"
          ],
          "author": "frizikk",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:2be3bfa5e709c378939a",
        "signalId": "github:NousResearch/hermes-agent:issue:85417",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85417",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "provider: ollama-cloud silently drops /v1 from base_url, request hits ollama.com homepage instead of API (404)",
          "text": "## Bug Setting `provider: ollama-cloud` in `~/.hermes/config.yaml` fails every request with HTTP 404, even though both the native provider profile *and* an explicit `model.base_url` override in config.yaml correctly specify `https://ollama.com/v1`. The actual outbound request silently loses the `/v1` segment and hits `https://ollama.com` (the marketing homepage), not the API. ## Steps to reproduce ```yaml # ~/.hermes/config.yaml model: api_key: '' base_url: https://ollama.com/v1 # explicit override — still has no effect default: deepseek-v4-pro provider: ollama-cloud ``` With `OLLAMA_API_KEY` set correctly (verified valid via a direct `curl https://ollama.com/v1/chat/completions` call, which succeeds). ## Observed ``` WARNING agent.conversation_loop: API call failed (attempt 1/3) error_type=NotFoundError provider=ollama-cloud base_url=https://ollama.com model=deepseek-v4-pro summary=HTTP 404 — Ollama ``` Response body is the actual `ollama.com` marketing page HTML (`<title>Ollama</title>`), confirming the request landed on the bare domain root, not `/v1/chat/completions`. ## Expected Request should go to `https://ollama.com/v1/chat/completions`, matching `plugins/model-providers/ollama-cloud/__init__.py`'s own hardcoded `base_url=\"https://ollama.com/v1\"`. ## Notes - Explicitly setting `model.base_url: https://ollama.com/v1` in config.yaml did **not** change the outcome — the logged `base_url` was still the bare `https://ollama.com` in both cases, suggesting the native provider profile's own resolved value is what's actually used (config.yaml's `base_url` field appears to only take effect for `provider: custom`, not for a named native provider like `ollama-cloud`). - Workaround that *does* work: setting `provider: custom` with `base_url: https://ollama.com/v1` and an explicit `api_key` (bypassing the native provider profile's own resolution entirely). - This may share root cause with #7516's finding that `model_metadata.py:detect_local_server_type()` strips `/v1` for local-server detection — if that stripped value is ever reused downstream as the actual request `base_url` rather than staying scoped to detection, it would produce exactly this symptom. Possibly related to #57246 / #57255 (`provider: ollama` falling through to `provider=custom` based on `/v1` suffix) — same URL-construction subsystem, different trigger direction (there: unwanted `/v1` causes fallback; here: a correct `/v1` gets dropped for a *different* named provider, `ollama-cloud`). ## Environment - Provider: `ollama-cloud` (native, not `provider: ollama` / not a custom local endpoint) - Auth: `OLLAMA_API_KEY` env var, independently verified valid",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85417",
          "createdAt": "2026-08-13T16:15:47Z",
          "updatedAt": "2026-08-13T16:15:47Z",
          "timestamp": "2026-08-13T16:15:47Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "mverzal",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:1f53a643b12bc25512d0",
        "signalId": "github:NousResearch/hermes-agent:pull_request:83051",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:83051",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(desktop): add tab close controls with focus recovery",
          "text": "## Summary - Add accessible direct close controls to closeable desktop pane and session tabs while preserving middle-click, modifier-click, and context-menu behavior. - Route pointer, keyboard, menu, global, terminal, confirmation, visibility, and bulk-close paths through a shared focus-recovery lifecycle. - Keep close targeting aligned with the tab the renderer exposes as selected, including hidden raw successors and layout-edit mode. - Preserve focused xterm identity independently from the selected terminal rail tab, and keep focus in the originating split group or terminal rail across pane removal and delayed initialization. - Add vertical-tab ARIA relationships, roving terminal-rail focus, and broad unit plus real Electron interaction coverage. ## Motivation Tab closing has several asynchronous entry points. Without a shared completion lifecycle, focus can remain on disappearing controls, jump to the wrong split group, target an invisible raw active pane, or be stolen by a delayed terminal mount. This change makes direct close controls durable and keyboard-accessible across those paths. ## Changes - Add horizontal pane/session close controls with separate tab and close-button semantics. - Serialize aggregate session closes around busy-session confirmation and settle focus only after the close result is known. - Preserve source-group identity and choose an actually visible, focusable survivor before application-level fallback. - Share the renderer's shown/selected-pane projection with global close routing so normal, narrow, and layout-edit modes cannot disagree. - Wrap the complete visual session tab in its context-menu trigger so right-clicking the sibling close glyph opens the session menu rather than a dead zone or the strip menu. - Stamp persistent terminal panels with terminal identity, synchronize selection on focus entry, and route global close through the focused xterm panel before falling back to the selected rail tab. - Suppress automatic xterm focus while the rail or pending tree recovery owns focus, without changing intentional drag/drop focus. - Implement terminal rail tablist/tab/tabpanel linkage, selected state, and roving keyboard navigation. ## Test Plan - [x] `npm run check` on clean committed HEAD `bb6de431a5525e724840a23b89d5ee43548eb2df` — typecheck and lint (0 errors, 89 existing warnings), 3,756 UI tests, 1,035 Electron-platform tests with 2 skipped, production build, and macOS DMG packaging stamped to `bb6de431a552` - [x] Focused integration set — 62/62 close, confirmation, tree-selection, terminal-rail, and workspace tests - [x] `npx playwright test e2e/pane-tab-close.spec.ts e2e/right-pane.spec.ts --reporter=list --timeout=180000 --workers=1` against that freshly built clean package — 6/6 real macOS Electron tests, including close-glyph context menu and focused real-xterm global close - [x] `git diff --check origin/main...HEAD` clean; added-line credential scan found no matches - [x] Synced with current upstream `main`; conflict resolution preserves `ContribRender`, split-share memory, deferred close results, and tab-panel semantics - [ ] Two fresh strict read-only auditors are reviewing exact commit `bb6de431a5525e724840a23b89d5ee43548eb2df` after remediation of all three prior findings ## Review History Two strict reviewers of the previous snapshot independently identified three reachable interaction defects: 1. global close could disagree with the selected tab in layout-edit mode; 2. right-clicking a session tab's close glyph did not open its session context menu; 3. a focused xterm could lose close affinity if terminal selection drifted. Each issue now has a red-before/green-after regression. The exact remediation snapshot is under fresh hash-bound semantic and interaction/accessibility/security review. ## Notes for Reviewers - Supersedes #69392, whose branch conflicts with current `main`. - Retains Austin Pickett attribution for the original hover-close feature while adding current-main accessibility, lifecycle, focus-recovery, terminal, and regression work. - The close control renders only for closeable horizontal tabs; minimized vertical rails use explicit focus restoration instead.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/83051",
          "createdAt": "2026-08-10T08:16:11Z",
          "updatedAt": "2026-08-13T16:14:16Z",
          "timestamp": "2026-08-13T16:14:16Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "P3",
            "comp/desktop"
          ],
          "author": "nicolasdmolina",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:fc1cb82a43eb30a0b144",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85340",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85340",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cron): relay-fronted Slack delivery — synthetic creation-thread capture + preflight fronted-platform blindness",
          "text": "## Summary Two interlocking cron-delivery defects on relay-fronted Slack deployments (gateway runs the relay platform only; a connector owns the Slack app). Reported from a live deployment: `/sethome` correctly set the home conversation, yet cron jobs created from Slack delivered into the ephemeral thread spawned around the creation message — and the explicit-target escape hatch `deliver: \"slack:<chat_id>\"` was rejected at preflight with \"delivery platform 'slack' has no gateway credentials configured (not connected)\". ### Bug 1 — synthetic session-keying thread captured as durable routing Relay-fronted Slack in thread-per-message mode stamps each top-level message's own id as `source.thread_id` (`gateway/relay/adapter.py::_stamp_slack_session_thread`, native `thread_ts = event.thread_ts or ts` parity). That stamp exists for **session keying** — its own docstring says so — but cron origin capture (`tools/cronjob_tools.py::_origin_from_env`) persisted it as `origin.thread_id`, and fire-time resolution replayed it unconditionally (`cron/scheduler.py::_resolve_single_delivery_target`), with a Slack origin-affinity block re-attaching it even onto explicit `slack:<chat_id>` targets. **Fix (source + repair):** - Capture: `_origin_from_env` drops a Slack thread id equal to the creation message's own id (`HERMES_SESSION_MESSAGE_ID`). A genuine in-thread creation (thread id = parent's id ≠ this message's id) keeps its thread. Slack-scoped: Telegram forum topics legitimately reuse ids and are untouched. - Fire time (repairs already-persisted jobs): `_origin_thread_is_stale` treats a Slack origin thread as stale when the origin chat IS the configured Slack home chat — delivery goes top-level (or to the home target's own configured thread). Non-home chats keep their threads; the explicit-target re-attach is gated on the same predicate. ### Bug 2 — preflight blind to relay-fronted platforms `_preflight_check_delivery` and `cron_delivery_targets` validated deliver prefixes against `get_connected_platforms()`, which only sees **natively configured** platforms — `{relay}` on a relay-only deployment, so `slack:<chat>` was refused before any LLM spend. Fire-time routing already handles this correctly (`gateway/delivery.py::resolve_delivery_transport` → `RelayAdapter.fronts_platform`; the delivery loop even bypasses the native enabled-gate for relay transports). Validation and routing were asymmetric. **Fix:** new `gateway.relay.relay_fronted_platforms()` — a thin wrapper over the existing `relay_platform_identities()` (env-derived from `GATEWAY_RELAY_PLATFORMS`, the SAME source that seeds the live adapter's identity set, so the two paths cannot disagree; works in a standalone scheduler process with no live adapter handle). Unioned into the connected set in both validation sites, only when the relay itself is connected. Native topologies keep the strict credential check unchanged. ## Commits - `892e5e040` — both fixes + 14 tests (single commit; the two bugs share the trigger scenario and the tests cross-reference the same fixtures). ## Mechanism attribution - Bug 2's fix is exercised end-to-end by the new preflight tests; fire-time relay routing needed no change (pre-existing, covered by `tests/cron/test_relay_fronted_delivery.py`). - Bug 1's capture fix and fire-time staleness guard are **test-verified only** at PR time. Proposed live exercise: on a relay-fronted staging box, create a job from a Slack DM (`deliver` omitted → origin), confirm the persisted job has `origin.thread_id: null`, then fire it and confirm top-level delivery; separately, fire a pre-fix job carrying a stale thread and confirm the staleness guard delivers it top-level. ## Test evidence RED before fix (6 failed = the new contracts; 8 passed = negative controls): ``` FAILED tests/cron/test_cron_origin_synthetic_thread.py::TestSlackSyntheticThreadCapture::test_synthetic_slack_thread_not_captured FAILED tests/cron/test_cron_relay_delivery_guards.py::TestOriginThreadStaleGuard::test_origin_thread_dropped_when_chat_is_home FAILED tests/cron/test_cron_relay_delivery_guards.py::TestOriginThreadStaleGuard::test_home_thread_config_still_wins FAILED tests/cron/test_cron_relay_delivery_guards.py::TestOriginThreadStaleGuard::test_explicit_target_no_reattach_when_chat_is_home FAILED tests/cron/test_cron_relay_delivery_guards.py::TestPreflightRelayFronted::test_relay_fronted_slack_accepted FAILED tests/cron/test_cron_relay_delivery_guards.py::TestPreflightRelayFronted::test_delivery_targets_include_relay_fronted 6 failed, 8 passed ``` Mutation checks (fix committed first, one side stubbed via targeted edit, tests red, tree restored via git checkout): - capture guard stubbed (`and False`) → `test_synthetic_slack_thread_not_captured` FAILED (1 failed, 3 passed) - `_origin_thread_is_stale` stubbed to `return False` → 3 staleness-guard tests FAILED (3 failed, 7 passed) - fronted-union stubbed to `return set()` → 2 preflight tests FAILED (2 failed, 2 passed) Canonical runner, final committed tree: ``` === Summary: 74 files, 715 tests passed, 0 failed, 1 skipped (100% complete) in 13.9s (8 workers) === (scripts/run_tests.sh tests/cron tests/gateway/relay) ``` ## Surfaces - No new config keys, no new model-tool surface, no wire/frame changes (`relay_fronted_platforms()` reads the existing `GATEWAY_RELAY_PLATFORMS` deploy stamp). - No connector-side changes required: both defects are gateway-local (origin capture/replay and preflight validation); the connector's delivery path was already correct. - Behavior change scope: Slack origins only for bug 1; relay-connected deployments only for bug 2. ## What this does NOT do - No change to the per-message-session UX (the adapter's session-keying stamp is untouched; only its capture as delivery routing changes). - No `gateway/delivery.py` changes — fire-time routing was already correct. - No standalone-scheduler fire-path hardening (out of scope; in-gateway fire works via the existing relay transport bypass).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85340",
          "createdAt": "2026-08-13T14:02:55Z",
          "updatedAt": "2026-08-13T16:13:41Z",
          "timestamp": "2026-08-13T16:13:41Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "comp/cron",
            "platform/slack",
            "P2",
            "sweeper:risk-message-delivery"
          ],
          "author": "victor-kyriazakos",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:46a1a5d2deb4a152e0d0",
        "signalId": "github:NousResearch/hermes-agent:pull_request:83720",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:83720",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway): never reap supervised gateway + relaunch on desktop (re)start (#83683)",
          "text": "## Summary Fixes #83683 — on a desktop (re)start the backend force-killed the live messaging gateway but never relaunched it, leaving WeChat/QQ/Telegram silently offline. This is a regression: the old gateway survived restarts (reparented), but the current gateway is a separate `hermes gateway run` process that the orphan-reaper happily killed, with no successor. ### Root cause `_reap_unsupervised_gateway_orphans()` scans for any process matching `looks_like_gateway_command_line` (`gateway run`) and kills it — including a **supervised** gateway that is owned by an external supervisor (systemd / launchd / Windows `Hermes_Gateway` scheduled task). Because the reap path has no relaunch successor, the gateway stayed dead and messaging went silent. ### Fix (two parts, fully guarded) 1. **Never reap a supervised gateway.** New `_gateway_has_active_supervisor()` detects a live external supervisor (systemd unit running / macOS launchd plist loaded+running / Windows scheduled task in `Running` state or with a live gateway pid). `_reap_unsupervised_gateway_orphans()` now bails out early when one is present, and additionally excludes the explicit `gateway.pid` supervised PID from the orphan set so the legitimately-running instance always survives. 2. **Relaunch a missing supervised gateway on desktop boot.** `hermes_cli/web_server.py` gains `_ensure_desktop_gateway_running()`, a detached, best-effort routine wired into the `_lifespan` startup (only when `HERMES_DESKTOP=1`). If a supervisor is installed (user opted into \"start gateway on login/boot\") but the gateway isn't running, it clears any stale `.gateway-planned-stop.json` marker and relaunches via the platform-native path (`systemd_start` / `launchd_start` / `gateway_windows.start()`). It is fully wrapped in try/except and never raises into the lifespan, so backend boot can never wedge on gateway recovery. ### Opt-out - Config key `gateway.relaunch_gateway_on_desktop_start` (default `True`). - Env var `HERMES_DESKTOP_NO_GATEWAY_RELAUNCH=1` forces it off. Both are documented in `config_defaults.py`. ## Confirmed reproductions across platforms (issue #83683) Since the original report, the regression has been independently reproduced on **every** supervised platform, each via a distinct, reliable trigger — all sharing the same root cause (a supervised gateway reaped by the orphan-reaper with no relaunch): - **Windows — desktop restart / update hand-off** (zuowen7, tutan0558, original reporter): reopening the desktop app (or `hermes update` + reopen) reaps the gateway. On Windows the gateway can exit **either** via SIGKILL **or** via the clean planned-stop-marker path (`Received UNKNOWN as a planned gateway stop — exiting cleanly`) — so a fix that only blocks SIGKILL would still disconnect messaging. Nothing relaunches it. - **macOS (launchd) — infinite kill loop** (Catmittee): launchd `KeepAlive` respawns the gateway → `hermes serve` reaps it (SIGTERM) → launchd respawns after `ThrottleInterval` → repeat, firing a shutdown notification each cycle (~26k serve respawns observed). - **macOS (launchd) via Desktop SSH reconnect** (woriwka-ai): each *Connect via SSH* reconnect starts a remote `hermes serve --isolated` with `HERMES_DESKTOP=1`, which reaps the remote launchd-managed gateway (reap wired into `_lifespan` by `bc1223840`). - **macOS (launchd) via multi-profile backend-pool churn** (TheVisher): the Desktop app stays **open**; LRU backend-pool rotation (cap 3) repeatedly starts fresh `HERMES_DESKTOP=1` serve backends, each reaping that profile's launchd gateway. No restart/relaunch needed. **Why this PR covers all of them:** the supervisor guard (`_gateway_has_active_supervisor()`) recognizes systemd / macOS launchd / Windows `Hermes_Gateway` and short-circuits the **entire** reap *before* any signal or `.gateway-planned-stop.json` write — so both the SIGKILL and the clean-marker exit paths are prevented, on every platform. `_ensure_desktop_gateway_running()` then relaunches a genuinely-missing supervised gateway on boot, covering the \"nothing relaunches\" half. ## Test plan - `tests/hermes_cli/test_gateway_reap_supervision.py` (10): supervisor-active skips reap on every platform (incl. a new repeated-pool-churn regression for a launchd-managed gateway); supervised PID excluded while genuine orphans still reaped; no-supervisor behavior unchanged. - `tests/hermes_cli/test_desktop_gateway_recovery.py` (8): no-op when already running; relaunch per platform; no relaunch when no supervisor; env var / config opt-out; never raises. - `tests/hermes_cli/test_gateway_windows.py` (11): VBS/CMD proxy baking + direct-spawn reload (incl. live-proxy-wins-over-snapshot) and Windows supervisor detection. - Regressions green: `tests/gateway/test_replace_child_reap.py` (5). - Full affected suite: **33 passed, 3 skipped**. ## Risk / rollout notes - Default-on but harmless: on hosts with no supervisor it's a no-op; on supervised hosts it only relaunches when the gateway is genuinely down (avoids double-start because that path checks `is_task_registered()`/plist existence, and the gateway's own supervisor would already be running it). - All recovery is best-effort; any failure is logged and swallowed.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/83720",
          "createdAt": "2026-08-11T06:30:00Z",
          "updatedAt": "2026-08-13T16:12:38Z",
          "timestamp": "2026-08-13T16:12:38Z",
          "metrics": {
            "reactions": 0,
            "comments": 10
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/gateway",
            "area/config",
            "P1",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility",
            "sweeper:risk-platform-windows",
            "platform/windows"
          ],
          "author": "EvanProgramming",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:0dda934ce4f6fa2c1d1b",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85314",
        "event": "changed",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85314",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(plugins): keep lifecycle hooks aligned across session rotation surfaces",
          "text": "## What does this PR do? This fixes lifecycle plugin hooks that received different session transition and workspace payloads depending on whether `/new` ran through the CLI, TUI, or messaging gateway. Without this fix, plugins cannot reliably release old-session resources, initialize the replacement session, or associate model turns with the authoritative task workspace. ### Symptom An explicit `/new` produced incomplete or inconsistent `on_session_finalize` and `on_session_reset` keyword arguments across surfaces. `pre_llm_call` also omitted the authoritative task/session workspace, while some lifecycle paths could substitute process state for an unproven session cwd. ### Impact Native plugins that coordinate per-session state cannot distinguish the old and new session consistently across CLI, TUI, and Gateway. Workspace-aware plugins can attribute lifecycle events or model turns to the wrong directory, and TUI prewarming could emit reset before the old session was finalized. ### Bug Cause **Trigger:** `cli.py::_start_new_session`, `ui-tui/src/app/useSessionLifecycle.ts::startNewSession`, `gateway/slash_commands.py::_handle_new_session`, and `agent/turn_context.py::build_turn_context` **Causal chain:** 1. A user starts a new session or a model turn from a surface with an authoritative session/task workspace. 2. Each surface constructs plugin hook payloads independently, omitting transition IDs or cwd on some paths. The TUI also created the replacement session before associating it atomically with teardown of the old session. 3. Plugins observe surface-dependent payloads, and TUI plugins can observe reset before finalize. **Why it is wrong:** Lifecycle payloads describe one semantic session transition, so their identifiers, reason, cwd, and ordering must not depend on the client surface. A process cwd is not evidence of a session workspace. **Working sibling / contrast:** Gateway already supplied old and new session IDs for explicit rotation, but it lacked authoritative cwd. CLI, TUI, and pre-LLM paths each supplied different subsets of the same context. **Ruled out:** This is not a plugin callback compatibility issue. Hook dispatch already filters additive keyword fields for callbacks with narrow signatures; the failure was at the surface-specific payload construction and TUI transition ordering. ### Fix - Add a shared authoritative session cwd resolver that uses recorded task/session state and returns an empty value when no cwd is proven. - Add consistent `old_session_id`, `new_session_id`, `reason`, and `cwd` fields to explicit session rotation hooks across CLI, TUI, and Gateway. - Include authoritative cwd in `pre_llm_call` payloads. - Associate TUI rotations atomically and defer the replacement-session reset until old-session finalization, without fabricating transitions for ordinary startup, resume, or shutdown. ## Related Issue Closes #85262 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `agent/runtime_cwd.py` and `agent/turn_context.py` - resolve and expose authoritative session/task cwd without a process-cwd fallback. - `cli.py` - emit complete explicit session transition payloads. - `gateway/run.py` and `gateway/slash_commands.py` - propagate current session identity and authoritative cwd to model-turn and rotation hooks. - `tui_gateway/methods_session.py`, `tui_gateway/server.py`, `ui-tui/src/app/useSessionLifecycle.ts`, and `ui-tui/src/gatewayTypes.ts` - preserve old-session workspace context and enforce finalize-before-reset ordering. - `tests/` and `ui-tui/src/__tests__/useSessionLifecycle.test.ts` - cover payload parity, unknown cwd behavior, rotation ordering, and gateway/TUI races. ## How to Test 1. Register a native plugin that records `pre_llm_call`, `on_session_finalize`, and `on_session_reset` keyword arguments. 2. Start a session in an explicit workspace through CLI, TUI, or Gateway, send one turn, and run `/new`. 3. Confirm finalize precedes reset, both lifecycle hooks carry identical transition IDs, reason, and authoritative cwd, and ordinary startup does not invent a transition. 4. Run the related automated suites: ```bash scripts/run_tests.sh tests/agent/test_runtime_cwd.py tests/agent/test_api_content_sidecar.py tests/cli/test_session_boundary_hooks.py tests/test_tui_gateway_server.py tests/gateway/test_35994_reset_button_deadlock.py cd ui-tui && npm test -- --run src/__tests__/useSessionLifecycle.test.ts cd ui-tui && npm run typecheck ``` The Python suite passed 597 tests, the targeted TUI suite passed 7 tests, TypeScript typecheck passed, and the lifecycle contract was verified with a real native plugin across CLI, TUI, Gateway, and pre-LLM paths on Windows 11. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run the repository test entry on the relevant suites and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Windows 11 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - N/A, no user-facing configuration or documented API changed - [x] I've updated `cli-config.yaml.example` if I added/changed config keys - N/A, no config keys changed - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows - N/A, the existing additive plugin hook contract is unchanged - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) - [x] I've updated tool descriptions/schemas if I changed tool behavior - N/A, no model tool behavior changed ## Screenshots / Logs Not applicable. Verification results are listed in How to Test.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85314",
          "createdAt": "2026-08-13T13:21:13Z",
          "updatedAt": "2026-08-13T16:12:24Z",
          "timestamp": "2026-08-13T16:12:24Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "comp/cli",
            "comp/gateway",
            "comp/tui",
            "comp/plugins",
            "P3",
            "sweeper:risk-session-state",
            "area/sessions"
          ],
          "author": "fangliquanflq",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:7c72ebb74a8dc9dba00c",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85410",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85410",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(mailmap): jonny@nousresearch.com is yoniebans, not jquesnelle",
          "text": "## Problem `.mailmap` line 89 maps `jonny@nousresearch.com` to `jquesnelle`, so all 62 commits authored with that email (54 as `yoniebans`, 8 as `jonny`) display under the wrong name in every mailmapped view: `git shortlog`, `git log --use-mailmap`, changelog generators, contributor stats. The raw commit metadata is correct; only the display mapping is wrong. The line came from the auto-generated 75-mapping pass in #9358, which guessed the wrong GitHub handle for the email. jquesnelle's real commits use `emozilla@nousresearch.com` (already mapped on line 76) and `jquesnelle@gmail.com`. ## What this PR does One line: `.mailmap:89` `jquesnelle` → `yoniebans`. Verified with `git shortlog -sne origin/main`: all 62 commits now fold under `yoniebans <jonny@nousresearch.com>`; jquesnelle's 97 emozilla commits and 1 gmail commit are unchanged. `AUTHOR_MAP` in `scripts/release.py` already had the correct mapping, so no change needed there.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85410",
          "createdAt": "2026-08-13T16:06:58Z",
          "updatedAt": "2026-08-13T16:11:17Z",
          "timestamp": "2026-08-13T16:11:17Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [],
          "author": "yoniebans",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:12f4536079cb76d3d79e",
        "signalId": "github:NousResearch/hermes-agent:pull_request:81351",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:81351",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): keep-alive hidden panes can no longer paint through",
          "text": "## Problem Closes #79833 — with Inline Embeds set to \"Always\", an X/Twitter card that auto-loads in one session keeps painting over the whole UI after switching to another session, the Artifacts view, or Settings. It also survives a renderer reload (Ctrl+R). ## Root cause Keep-alive tabs stay **mounted** when inactive and are hidden with `visibility: hidden` (not `display: none`) so their layout box — and with it the scroll position — survives a tab round-trip (`pane-shell/tree/renderer/tree-group.tsx`). But CSS `visibility` is inherited-*and-overridable*: Twitter's embed script renders its card into a sandbox element with an **inline** `visibility: visible` (`platform.twitter.com/widgets.js`), which CSS then lets paint through the hidden pane over the active view. It returned after Ctrl+R because the tab layout and the embed mode (\"always\") are persisted, so the embed re-auto-loads. Plain-iframe embeds (YouTube, Spotify, maps) do not set inline `visibility`, which is why only the X/Twitter card was affected. ## Fix One pane-level CSS rule — every descendant of a `[data-pane-hidden]` pane follows the pane's visibility (`visibility: inherit !important`). An author `!important` rule beats the widget's inline non-important style. The fix does not require additional unmounting: the embed stays mounted and renders exactly as before when its tab is active (no re-fetch, no flicker), and scroll preservation is untouched. ## Verification - New \"hidden pane painting contract\" tests in `pane-visibility.test.ts` validate the rule against the **live stylesheet** (extracted by an anchored regex, so removal/rename fails loudly): a `visibility: visible` descendant inside a hidden pane computes `hidden`; an element outside is unaffected. - Real-Chromium (Electron 40) probes: with the rule, a widget-style card inside a hidden pane computes `hidden`; without the rule it computes `visible` (bug mechanism reproduced); cards outside hidden panes stay `visible`. - Full desktop UI suite: 4525 tests passed (480 files, 2 skipped); `tsc --noEmit` clean. ## Notes - **Open question:** a widget that set `visibility: visible` on elements *inside* a shadow root (rather than on the host, which is what Twitter's script does) would not be covered — CSS cannot select into shadow roots. No known provider (Twitter/Instagram/TikTok, all checked) does this; if one appears, the follow-up is gating widget embeds on pane visibility in `social-embed.tsx`. - **Open question:** the rule cannot win against a widget that set inline `visibility: visible !important`; today's widget scripts use plain inline styles.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/81351",
          "createdAt": "2026-08-07T22:43:07Z",
          "updatedAt": "2026-08-13T16:12:10Z",
          "timestamp": "2026-08-13T16:12:10Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "P3",
            "comp/desktop"
          ],
          "author": "DavidMetcalfe",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:1369ea66112af56ec4a9",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85416",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85416",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(macos): stable TCC anchor for uv-managed python interpreter",
          "text": "## Summary On macOS, TCC permission grants (microphone, screen recording, etc.) are keyed to the resolved client binary path. The uv-managed Python interpreter lives in `~/.local/share/uv/python/cpython-<patch>-macos-*/bin/python3.11` — every patch bump creates a new directory → orphaned grants → permission prompts return after **every** update. Symlinks don't help (TCC resolves through them). ## Change (stable anchor — \"copy to fixed path before invoking\") - `hermes_cli/macos_tcc_anchor.py` (new, +277): replaces the `venv/bin/python` symlink (which resolves into the versioned uv store) with a **real copy** of the interpreter binary. The venv path is stable across updates and, being a regular file, there's no symlink for TCC to resolve → constant client path. `pyvenv.cfg` (home → uv store) untouched, so stdlib still loads as today. **Self-healing**: detects patch bump via `.tcc-anchor-source` marker (re-copies) and re-points `python3`/`python3.11` aliases to the anchor. No-op on non-macOS and non-uv interpreters (Homebrew already has a stable path). Best-effort, never raises. - `hermes_cli/doctor.py` (+52): reports anchor state (healthy / stale / would-fix). - `hermes_cli/update_cmd.py` (+15): runs the anchor heal after update. - `tests/hermes_cli/test_macos_tcc_anchor.py` (new, +296): uv-store detection, patch-bump re-copy, symlink replacement, no-op cases (monkeypatched — real TCC behavior not testable on Linux CI). ## Verification - `tests/hermes_cli/test_macos_tcc_anchor.py`: **20 passed**. Real macOS validation left to review (CI is Linux). Closes #85345",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85416",
          "createdAt": "2026-08-13T16:10:31Z",
          "updatedAt": "2026-08-13T16:10:31Z",
          "timestamp": "2026-08-13T16:10:31Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "webtecnica",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:80315c16ff580a1d4e6a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85415",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85415",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(security): block writes to sensitive paths in managed-files API",
          "text": "## Summary The dashboard's managed-files API protected sensitive files (`.env`, `config.yaml`, credential stores) on the **read** side only — the **write** endpoints (`/api/files/upload`, `/api/files/upload-stream`, `/api/files/mkdir`, delete) would happily **overwrite** those same files. Security gap. ## Change (fail-closed at the chokepoint) - `hermes_cli/web_server.py::_resolve_managed_path()`: added `if for_write and _is_sensitive_path(resolved): raise HTTPException(403, \"Cannot write to a sensitive path\")`. The check uses the **post-symlink-resolution** path, so aliases/symlinks pointing at `.env`/`config.yaml` are also blocked. Covers upload, upload-stream, mkdir (already passed `for_write=True`). - `delete_managed_file()`: now passes `for_write=True` → destructive deletes also refuse sensitive paths (404 semantics preserved). - `_is_sensitive_path()` docstring updated (no longer claims \"read-side only\"). ## Verification - `tests/hermes_cli/test_web_server_files.py`: **10 passed** (4 new: upload .env → 403, upload-stream config.yaml → 403, mkdir sensitive → 403, symlink-to-.env → 403). Normal file ops unchanged. Closes #85387",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85415",
          "createdAt": "2026-08-13T16:10:09Z",
          "updatedAt": "2026-08-13T16:10:09Z",
          "timestamp": "2026-08-13T16:10:09Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "webtecnica",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:9343c54521fc2d1312c2",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85414",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85414",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): make get-windows optional dep so Linux build doesn't break",
          "text": "## Summary `get-windows@9.3.0` was in `dependencies` of the desktop app — on Linux its install script (`node-pre-gyp install --fallback-to-build`) fails (404 prebuilt + gyp ModuleNotFoundError in the venv), breaking `npm ci` and the whole desktop build during `hermes update`. ## Change (platform-gated) - `apps/desktop/package.json`: `get-windows` moved from `dependencies` → **`optionalDependencies`** (install failure on Linux no longer breaks `npm ci`). - `apps/desktop/scripts/stage-native-deps.mjs`: `resolveGetWindowsRoot()` try/catch → `null`; `stageGetWindows()` with injectable platform gate — Linux + package missing = skip with warning; darwin/win32 + missing = hard error (native payload required). - `apps/desktop/electron/window-below.ts`: lazy tolerant import (`import('get-windows').catch(() => null)`) — missing module is a normal state for an optional dep; enumeration degrades to the failure note. - `apps/desktop/electron/get-windows.d.ts` (new): type shim. Root `allowScripts` for get-windows kept (needed for Windows binding download; Linux install failure now absorbed by the optional dep). ## Verification - `stage-native-deps.test.mjs` (26) + `window-below.test.ts` (13): **39 passed**, `tsc` exit 0. Real npm experiment confirmed install-script failure is absorbed with the optional-dep layout. Closes #85377",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85414",
          "createdAt": "2026-08-13T16:09:42Z",
          "updatedAt": "2026-08-13T16:09:43Z",
          "timestamp": "2026-08-13T16:09:43Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "webtecnica",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:760d30b2dd955659921b",
        "signalId": "github:NousResearch/hermes-agent:pull_request:55042",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:55042",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(matrix): stream inbound media under cap",
          "text": "﻿Fixes #55041. ## Summary - stream inbound Matrix `mxc://` media through `MATRIX_MAX_MEDIA_BYTES` instead of calling `mautrix.download_media()`'s unbounded `response.read()` path - reject oversized MXC media as soon as the streamed response crosses the cap, without forwarding the HTTP fallback downstream - keep the old `download_media()` path only for test/fake clients, with a defensive post-read size check - add regression tests for under-cap streaming and over-cap stream rejection ## Relation to Existing Work Related but not a duplicate of #52354: - #52354 gates pre-auth Matrix media downloads and checks the real downloaded byte length before cache/use. - This PR closes the remaining earlier boundary: avoid buffering an oversized media response before that length check can run. This mirrors the response-boundary class from openclaw/openclaw#97855, adapted to Hermes' Python `mautrix` client path. ## Duplicate Audit Live checks before opening: - Existing #55009/#55011 covers Matrix standalone sender REST responses, not inbound MXC media downloads. - Existing #52354 covers media authorization and post-download real length checks, but still uses `download_media()`'s full-buffer read. - No open PR/issue found for inbound Matrix MXC streaming caps before #55041. ## Validation - `python -m pytest tests\\gateway\\test_matrix.py -q -k \"MediaNormalization or mxc_media\" --basetemp .pytest-tmp-matrix-inbound-media-cap` (`17 passed, 218 deselected`) - `python -m ruff check plugins\\platforms\\matrix\\adapter.py tests\\gateway\\test_matrix.py` (passed) - `git diff --check` (passed) I also ran the full `tests\\gateway\\test_matrix.py` on Windows: `233 passed, 2 failed`. The two failures are pre-existing on untouched `origin/main` in this environment: POSIX `0600` mode assertion on Windows and default-GBK decoding of `website/docs/user-guide/messaging/matrix.md`.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/55042",
          "createdAt": "2026-06-29T17:30:28Z",
          "updatedAt": "2026-08-13T16:08:10Z",
          "timestamp": "2026-08-13T16:08:10Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/security",
            "platform/matrix",
            "P2",
            "sweeper:risk-message-delivery",
            "sweeper:risk-security-boundary",
            "sweeper:blast-moderate",
            "area/streaming"
          ],
          "author": "ooiuuii",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:4e27a6b4b4dd6cc6c81c",
        "signalId": "github:NousResearch/hermes-agent:issue:85413",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85413",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: s6 boot reconciler auto-starts per-profile gateways (crash-loop, 100% CPU) when multiplex_profiles enabled via config.yaml only",
          "text": "### Bug Description The Docker container's **s6 boot orchestrator** crash-loops per-profile gateway services when `multiplex_profiles: true` is set via `config.yaml` alone, because the s6 reconciler keys its auto-start decision off the `GATEWAY_MULTIPLEX_PROFILES` **environment variable** rather than the config file the user was told to edit. Setup / repro: 1. `nousresearch/hermes-agent:latest` container, `command: gateway run`, with >=1 named profile (`profiles/<name>/` each containing `SOUL.md`). 2. `config.yaml` sets `multiplex_profiles: true` (the documented way to enable the one-gateway multiplexer). 3. **Do not** set `GATEWAY_MULTIPLEX_PROFILES` in the container environment (the default for a plain `docker compose` / `docker run` deploy). Expected: only the default multiplexing gateway starts and serves all profiles; named gateway slots are registered but left down. Actual: the named gateway slots boot, hit the \"already served by the multiplexer / double-bind\" guard, exit, and **s6 restarts them in a loop**, each boot pegging a CPU core near 100%. Observed (host: ai-tools, up 22 min): - `systemctl`/`s6` shows `gateway-hermes-work` and `gateway-hermes-personal` repeatedly up-for-1-second → crash → restart. - Each relaunch is `/opt/hermes/.venv/bin/python3 hermes -p <profile> gateway run --replace` at ~90-100% CPU. - Logs: `✗ The default gateway is running as a profile multiplexer and already serves profile 'hermes-personal'. ... Starting a separate gateway for this profile would double-bind ...`. - `RestartCount=0` on the container — the container itself is NOT restarting; the loop is internal to s6 service supervision. ### Expected Behavior `multiplex_profiles: true` in `config.yaml` and the container flag `GATEWAY_MULTIPLEX_PROFILES=true` should be treated as one setting. When either is on, the s6 reconciler should register named profile slots as **down** (available for explicit lifecycle management) rather than auto-starting them, so no double-bind crash-loop can occur from a config-only opt-in. ### Actual Behavior `reconcile_profile_gateways` in `hermes_cli/container_boot.py` decides `should_start` for named profiles from ONLY the process env: ```python multiplex_profiles = is_truthy_value(os.environ.get(\"GATEWAY_MULTIPLEX_PROFILES\")) ... should_start = (not multiplex_profiles and prior_state in _AUTOSTART_STATES) ``` It never consults `multiplex_profiles` in `config.yaml`. (`gateway.py`/`gateway/config.py` correctly read the config file — `_env_multiplex_profiles_override` env-override-then-config fallback — but the s6 reconciler does not.) So a user who turns multiplexing on exactly as documented — only editing `config.yaml` — is left with a silent, 100%-CPU crash loop and has to discover the undocumented `GATEWAY_MULTIPLEX_PROFILES=true` env var to stop it. ### Affected Component Container orchestration / s6 boot (docker/s6-overlay), Configuration (config.yaml, environment) ### Messaging Platform (if gateway-related) n/a (crash loop occurs before any platform connects) ### Debug Report n/a — reproduced on a self-hosted Docker deploy; all details above gathered from live container inspection. ### Operating System Debian/Ubuntu containerized via Docker (host is a Linux VM) ### Python Version Container's bundled venv (image `fa83af3f9a`) ### Hermes Version Image `nousresearch/hermes-agent:latest` @ revision `fa83af3f9a42790730b8966ff67e7d9fb627899f` (2026-08) ### Additional Logs / Traceback (optional) s6 service status during the loop: ``` gateway-default: up gateway-hermes-work: up (1 seconds) <- crash-looping gateway-hermes-personal: up (1 seconds) <- crash-looping ``` Per-profile log (`hermes-personal`): ``` ✗ The default gateway is running as a profile multiplexer and already serves profile 'hermes-personal'. When gateway.multiplex_profiles is on, the default gateway is the single inbound process for every profile. Starting a separate gateway for this profile would double-bind its platforms (two pollers on one bot token, port conflicts). ``` Workaround (what I applied in production): set `GATEWAY_MULTIPLEX_PROFILES=true` in the container environment, matching the existing `multiplex_profiles: true` in config.yaml. After recreate the named slots resolve to `down (not started yet)` and only the default multiplexer runs (~7% idle CPU, load 0.25). ### Root Cause Analysis (optional) `hermes_cli/container_boot.py` → `reconcile_profile_gateways` (~line 142) reads multiplex routing solely from `os.environ.get(\"GATEWAY_MULTIPLEX_PROFILES\")`. This diverges from `hermes_cli/gateway.py`/`gateway/config.py`, which implement the documented precedence (env override → config.yaml → default). Suggested fix: have the reconciler fall back to the same config resolution (`load_gateway_config().multiplex_profiles` / read `config.yaml` `multiplex_profiles` key) when the env var is unset, exactly as the runtime already does, so a config-only opt-in is honored at boot. Related but distinct from #50051 (closed 2026-07-06): #50051 was about the gateway **process mis-loading the nested flag and the api_server env leak into secondary profiles**; that was fixed (e.g. `d3602e6308`). This issue is the separate **boot-time s6 orchestrator** deciding to start named slots based on env instead of config — the failure you hit when multiplex is correctly enabled through `config.yaml`. ### Are you willing to submit a PR for this? - [ ] I'd like to fix this myself and submit a PR",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85413",
          "createdAt": "2026-08-13T16:08:02Z",
          "updatedAt": "2026-08-13T16:08:02Z",
          "timestamp": "2026-08-13T16:08:02Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "Christophe999s",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:032c2adc29da6a5aef44",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85412",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85412",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(tools): use POSIX path in vision container exec-read on Windows hosts",
          "text": "## What does this PR do? Fixes `vision_analyze` failing on Windows hosts under the Docker terminal backend for any local path that routes to the in-container exec-read (sandbox paths like `/workspace/...`, container cache paths). The resolver built the read command from `str(Path(...))`, which on Windows yields backslash separators; the Linux container cannot resolve `\\workspace\\x.png`, so the read returns no bytes and the image fails magic-byte sniffing (`\"not a recognized image\"` / `\"Only base64 data is allowed\"`). The fix quotes `p.as_posix()` — a no-op on POSIX hosts (where `Path` is already `PosixPath`) and the correct form inside the container everywhere. ## Related Issue Fixes #85406 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `tools/image_source.py` — `_resolve_container_fallback`: quote the POSIX path form (`shlex.quote(p.as_posix())` instead of `shlex.quote(str(p))`), with a comment explaining why. - `tests/tools/test_image_source_posix_paths.py` — new regression test. `PureWindowsPath` is instantiable on every platform and produces the same backslash `str()` shape as a Windows-host `Path`, so the Windows-host condition is covered on Linux CI. ## How to Test Reproduction (Windows host + `terminal.backend: docker`): call `vision_analyze` on any sandbox-side image path, e.g. `/workspace/shot.png` → fails with `sandbox returned non-image data for '\\workspace\\...': Only base64 data is allowed`. Test: 1. `pytest tests/tools/test_image_source_posix_paths.py` — fails on current `main` (command contains `'\\workspace\\shot.png'`), passes with the fix (`/workspace/shot.png`). 2. `pytest tests/tools/test_image_source.py` — existing resolver tests still pass (183 vision/image tests passed locally). Host-side confirmation (Windows): `python -c \"from pathlib import Path; print(Path('/workspace/x.png').as_posix())\"` → `/workspace/x.png`. ## Checklist - [x] Ran tests: regression test + existing `test_image_source.py` suite pass - [x] Cross-platform: `as_posix()` is a no-op on POSIX hosts, so Linux/macOS behavior is unchanged - [x] Two unrelated failures in `tests/tools/test_image_generation.py` are pre-existing (reproduced via `git stash`, identical without this change)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85412",
          "createdAt": "2026-08-13T16:07:57Z",
          "updatedAt": "2026-08-13T16:07:57Z",
          "timestamp": "2026-08-13T16:07:57Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "maqifrnswa",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:d635f32617eafd9fe77c",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85411",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85411",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(vision): normalize sandbox-side paths to POSIX for container exec-read",
          "text": "## Summary On a Windows host with `terminal.backend: docker`, `vision_analyze` failed for every local image path routing to the in-container exec-read: `Path()` on Windows converts POSIX separators into backslashes, so `shlex.quote(str(p))` injected `\\workspace\\...\\vision_test.png` into the Linux container command → \"Only base64 data is allowed\". ## Change (same bug class, 3 sites + 1) - `tools/image_source.py`: `shlex.quote(str(p))` → `shlex.quote(p.as_posix())` in `_resolve_container_fallback` (no-op on Linux); `_permitted_host_read_target` + error messages echo `as_posix()`. - `tools/credential_files.py`: `Path(container_path)` → `PurePosixPath(container_path)` in `from_agent_visible_cache_path` (container paths are POSIX by definition; `Path()` would mangle mount-relative comparison on Windows). Host-side paths unchanged. - Tests: 3 new (POSIX passthrough, Windows-style backslash input, cache-path comparison). ## Verification - `tests/tools/test_image_source.py` + `tests/tools/test_credential_files.py`: **64 passed** (2.0s). Root cause confirmed with a real experiment (WindowsPath `str()` mangling). Docker integration test not run (requires daemon; bug not reproducible on Linux CI). Closes #85406",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85411",
          "createdAt": "2026-08-13T16:07:47Z",
          "updatedAt": "2026-08-13T16:07:47Z",
          "timestamp": "2026-08-13T16:07:47Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "webtecnica",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c9d07d35a77e625b2a2e",
        "signalId": "github:NousResearch/hermes-agent:pull_request:55149",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:55149",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway): restrict write approval toggles to admins",
          "text": "## Summary Fixes #55147. Gateway `/memory` and `/skills` can be allowlisted for non-admin slash users so they can review pending writes, but `approval on|off` mutates global `memory.write_approval` / `skills.write_approval` in `config.yaml`. This patch adds a shared gateway-side subcommand guard: - `/memory approval` and `/skills approval` with no value still show current state - `/memory approval on|off` and `/skills approval on|off` require slash admin status when slash access gating is enabled - legacy installs without `allow_admin_from` keep the previous unrestricted behavior - CLI behavior is unchanged because local CLI use is already operator-side ## Testing - `python -m pytest tests\\gateway\\test_slash_access_dispatch.py -q -k \"approval_toggle or whoami_non_admin_lists_runnable_commands\"` (`5 passed`) - `python -m pytest tests\\gateway\\test_slash_access_dispatch.py -q -k \"not quick_command\"` (`22 passed, 3 deselected`; the deselected quick-command tests use POSIX `printf`, which is an existing Windows-only test portability issue) - `python -m pytest tests\\gateway\\test_slash_access.py -q` (`21 passed`) - `ruff check gateway\\slash_commands.py tests\\gateway\\test_slash_access_dispatch.py` - `git diff --check`",
          "url": "https://github.com/NousResearch/hermes-agent/pull/55149",
          "createdAt": "2026-06-29T20:14:59Z",
          "updatedAt": "2026-08-13T16:07:01Z",
          "timestamp": "2026-08-13T16:07:01Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/security",
            "comp/gateway",
            "area/auth",
            "P2",
            "sweeper:risk-security-boundary",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "ooiuuii",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:1dedeb9f36c5284aa0dc",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85330",
        "event": "changed",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [
          "updatedAt",
          "metrics",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85330",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cua): make governed capture failures explicit",
          "text": "## What changed - Add an explicit failed capture result with `ok:false`, `success:false`, `status:\"failed\"`, a bounded error code/phase/repair hint, and target-presence metadata. - Preserve that contract through addon pre-tool governance blocks instead of returning a generic error string. - Reject half-pairs and invalid native capture targets explicitly; keep raw app names, IDs, titles, and native detail out of model-facing results. - Document the explicit-app and exact-pair capture contract and add focused backend, hook, and serialization tests. ## Why The previous failure path returned a normal `0x0` capture or a generic blocked-tool error, allowing downstream callers to treat a rejected capture as completed. The producer now has one bounded failure shape for backend and governance rejection paths. ## Validation - Hermes computer-use focused contract tests — 6 passed - Hermes model-tools focused contract tests — 2 passed - `git diff --check` — passed Related: AGC-390, AGC-388, AGC-391, AGC-281. No release, carrier install, or deployment is included.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85330",
          "createdAt": "2026-08-13T13:47:09Z",
          "updatedAt": "2026-08-13T16:06:40Z",
          "timestamp": "2026-08-13T16:06:40Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "comp/tools",
            "P2"
          ],
          "author": "leonagente",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:3d1e98c512e8c60d133a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85409",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85409",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(memory): extend embedded Hindsight install timeout",
          "text": "## What Give embedded Hindsight package installs a 600-second budget while keeping the existing 120-second timeout for lightweight memory-provider SDKs. This applies consistently to: - `hermes memory setup` for `local_embedded` - active-provider restoration during `hermes update` - the Intel macOS slim embedded stack proposed in #81530 ## Why Hermes already treats Hindsight as a supported shipped memory provider. For `local_embedded`, setup installs the full runtime and `hermes update` restores it after rebuilding the managed venv. That supported repair path currently passes the full Hindsight stack to `install_specs(..., timeout=120)`. A real managed update on Apple Silicon exceeded that generic budget, logged a warning, continued successfully, and left the configured embedded provider absent. The same forced dependency restoration completes when allowed a realistic multi-minute budget. This follows up the provider-healing work merged in #72363. It also addresses the 120-second embedded install timeout reported during #7718 without moving Hindsight to an external service or adding private dependency pins. ## Scope - Detect package names from versioned/extra-qualified specs. - Use 600 seconds only when the dependency set includes `hindsight-all`, `hindsight-all-slim`, or `hindsight-api-slim`. - Preserve 120 seconds for `hindsight-client` cloud/external mode and other providers. - Reuse the same timeout selector at both setup and update restoration call sites. This PR deliberately does not address the stale `hindsight-client==0.6.1` runtime pin. That known issue is already covered by #80390 and the broader existing fix in #80517. ## Verification - Focused timeout/update tests: 5 passed. - Ruff: clean on all four changed files. - `git diff --check`: clean. - `uv.lock`: unchanged. - Sabotage test: restoring the historical fixed 120-second selector makes all three embedded-runtime regression assertions fail; restoring this fix makes them pass. - Live operational acceptance on the affected installation after dependency restoration: embedded daemon healthy, PostgreSQL connected, retain accepted, exact nonce recalled on first structured recall, disposable bank deleted, client closed. ## Related - Follow-up to #72363 - Related to #7718 - Compatible with #81530",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85409",
          "createdAt": "2026-08-13T16:06:29Z",
          "updatedAt": "2026-08-13T16:06:29Z",
          "timestamp": "2026-08-13T16:06:29Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "neilinger",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:de628f765e9bd6189a03",
        "signalId": "github:NousResearch/hermes-agent:pull_request:59162",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:59162",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "Redact secrets in global log records",
          "text": "## Summary - Sanitize `LogRecord.msg` and `LogRecord.args` in Hermes' global record factory using the existing `agent.redact.redact_sensitive_text` path. - Walk string values inside tuple/list/dict logging args while leaving numeric values intact for `%d` and similar formatting. - Add plain `logging.Formatter` coverage for non-Hermes handlers attached to non-propagating loggers. Fixes #59061 ## Tests - `scripts/run_tests.sh tests/test_hermes_logging.py tests/agent/test_redact.py -q` - `uv run ruff check hermes_logging.py tests/test_hermes_logging.py`",
          "url": "https://github.com/NousResearch/hermes-agent/pull/59162",
          "createdAt": "2026-07-05T21:21:16Z",
          "updatedAt": "2026-08-13T16:06:13Z",
          "timestamp": "2026-08-13T16:06:13Z",
          "metrics": {
            "reactions": 0,
            "comments": 6
          },
          "labels": [
            "type/security",
            "comp/agent",
            "area/auth",
            "P2",
            "sweeper:risk-security-boundary",
            "sweeper:risk-compatibility",
            "sweeper:blast-broad"
          ],
          "author": "doncazper",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:46c4f2d3e79d59ca1f59",
        "signalId": "github:NousResearch/hermes-agent:pull_request:66163",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:66163",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(slack): configurable slash-command namespace prefix",
          "text": "## What does this PR do? Slack slash commands are **workspace-global and not namespaced**. When two gateway apps live in the same workspace — or Hermes sits alongside any other app using generic names like `/model`, `/new`, `/help` — Slack routes each shared command to whichever app was installed most recently, silently breaking the others. There is no per-app namespace in Slack's model, so the only fix is to make the registered names themselves distinct. This adds an **opt-in namespace prefix**: set `platforms.slack.extra.command_prefix: \"myorg-\"` and Hermes registers `/myorg-model`, `/myorg-hermes`, … instead. The prefix is prepended when generating the app manifest and stripped again on the receive side, so the two sides always agree and dispatch behaviour is unchanged. **Why this approach:** it keeps all ~50 commands as first-class native slashes rather than collapsing them behind a single catch-all, so nothing is lost from the Slack command picker. Default is `\"\"` — a no-op for every existing install. ## Related Issue Fixes #66156 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ 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 - **`hermes_cli/commands.py`** - `slack_command_prefix(extra=None)` — resolves the prefix from `platforms.slack.extra.command_prefix`, passed in by the adapter or read from `config.yaml` when generating the manifest. Defaults to `\"\"`. - `_sanitize_slack_prefix()` — same charset as `_sanitize_slack_name` (a-z, 0-9, `-`, `_`, 32 chars) but preserves a trailing `-`/`_` so `myorg-` yields `/myorg-model`, not `/myorgmodel`. A prefix that sanitizes to empty logs a warning instead of silently disabling namespacing. - `slack_native_slashes(prefix=\"\")` — the Slack built-in reserved-name check now runs on the **full registered name**, so `/myorg-status` is no longer treated as colliding with Slack's `/status` and gains a native slot. - `slack_app_manifest()` — prepends the prefix; names that exceed Slack's 32-char limit are skipped so the manifest stays valid, with a warning naming them. - **`plugins/platforms/slack/adapter.py`** — the prefix is baked into the slash-routing regex and stripped from `slash_name` before the `{\"hermes\", \"\"}` check and registry dispatch, so `/myorg-model` → `model` and `/myorg-hermes` → the legacy catch-all. The in-thread `!` fallback strips it too. - **`hermes_cli/slack_cli.py`** — manifest generation threads the prefix through. - **`cli-config.yaml.example`** — documents the new `platforms.slack.extra.command_prefix` key. - **`website/docs/user-guide/messaging/slack.md`** — user-facing setup docs. - **`tests/gateway/test_slack.py`, `tests/hermes_cli/test_slack_cli.py`** — new `TestSlackCommandPrefix` coverage. ## How to Test **Default is unchanged (regression check)** 1. Leave `command_prefix` unset. Run `hermes slack manifest`. 2. Confirm the output is identical to before this PR: 50 commands, `/hermes` first, no prefixes. **Namespaced mode** 1. In `~/.hermes/config.yaml`: ```yaml platforms: slack: extra: command_prefix: \"myorg-\" ``` 2. `hermes slack manifest > slack-manifest.json` — every `command` is now `/myorg-…`. 3. Apply the manifest to your Slack app (App settings → App Manifest → paste → Save) and reinstall to the workspace. 4. In Slack, run `/myorg-model` — it resolves to the same handler `/model` used to. `/myorg-hermes help` reaches the catch-all. Unprefixed `/model` no longer routes to this app, which is the point: another app may now own it. 5. Sanity-check the edges: `command_prefix: \"!!!\"` (sanitizes to empty) logs a warning and leaves commands unprefixed; a very long prefix logs which commands were dropped for exceeding Slack's 32-char limit. **Automated** ```bash scripts/run_tests.sh tests/gateway/test_slack.py tests/hermes_cli/test_slack_cli.py tests/hermes_cli/test_commands.py # => 3 files, 649 tests passed, 0 failed ``` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run the test suite via `scripts/run_tests.sh` — see *Test results and pre-existing noise* below for exactly what was run and what was compared against clean `main` - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin 24.6.0), Python 3.13 — automated suite and `hermes slack manifest` output verified locally ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A (no architecture or workflow change) - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — the change is string/regex/config only, with no file I/O, process management, or terminal handling - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A (no tool behaviour change) ## Notes for reviewers ### Sync with `main` Rebased onto `202140db5`, cleanly, still a single commit. Exactly one upstream commit since the last sync touches a file this PR modifies: `f9cd57791` *feat(approvals)*, which adds `/approvals` to `COMMAND_REGISTRY` and — to stay under Slack's 50-command cap — demotes `update` into `_SLACK_VIA_HERMES_ONLY`. Since that lands on the manifest surface this PR reworks, the interaction points were re-verified on the rebased tree: - **Manifest exclusion list.** `_SLACK_VIA_HERMES_ONLY` is now `{topup, moa, debug, egress, init, version, diff, update}`. This still composes cleanly: the exclusion check runs on the *registry* name (unprefixed), while the Slack built-in reserved-name check runs on the *full registered* name (`prefix + name`). The two are deliberately at different altitudes and the rebase preserved that. - **50-command cap.** Verified with and without a prefix: the registry sits at exactly 50 in both, `/hermes` is first in both, `/approvals` keeps its native slot in both, and no generated name is duplicated or exceeds 32 chars. - **Which commands get clamped.** The prefix un-reserves Slack built-ins, so `status` and `topic` gain native slots and clamp off `insights` and `platform` (still reachable via `/<prefix>hermes <command>`). This trade-off is inherent to the feature, not new to the rebase — only *which* commands sit at the margin shifted, because `update` is now demoted upstream. - **Prefix routing.** Registry-driven and unchanged, so `/approvals` and its `manual|smart|off` subcommands are namespaced automatically with no code change here. ### Review history - **2026-07-21:** the `HERMES_SLACK_COMMAND_PREFIX` environment-variable override was **removed** per review feedback — `AGENTS.md` reserves `HERMES_*` env vars for secrets, and behavioral settings belong in `config.yaml`. `platforms.slack.extra.command_prefix` is now the sole configuration surface. ### Test results and pre-existing noise Run with the canonical runner (`scripts/run_tests.sh`, per-file subprocess isolation) on macOS 15 / Python 3.13: | Scope | Result | |---|---| | `test_slack.py` + `test_slack_cli.py` + `test_commands.py` | **649 passed, 0 failed** | | `tests/gateway` + `tests/hermes_cli` (the scopes this PR touches) | 1 failure — `test_background_command.py::TestRunBackgroundTask::test_media_files_routed_by_type` | That single failure is unrelated and **pre-existing**: it fails identically on a clean `upstream/main` worktree under the same command, so it is not introduced here. For transparency: the repo-wide `scripts/run_tests.sh` run also surfaces unrelated failures outside this PR's scope (e.g. under `tests/agent/`). Those areas are untouched by this diff — no file in `agent/` or the tool layer is modified — and I have not attempted to triage them here. ### Manifest output Default (`command_prefix` unset) — unchanged from `main`: ``` /hermes /btw /bg /start ... (50 commands total) ``` With `command_prefix: \"myorg-\"`: ``` /myorg-hermes /myorg-btw /myorg-bg /myorg-start ... (50 commands total) ``` ### Relationship to existing proposals This is the \"namespace **every** command\" approach — all ~50 commands stay first-class native slashes, just prefixed. It's an alternative to (not a conflict with): - #29081 / #11836 — rename the top-level catch-all only. - #48642 — configurable catch-all name + single-command mode (one command per instance). Since this area is marked `needs-decision`, happy to adjust or align with whichever direction maintainers prefer. ## Infographic <img width=\"1024\" height=\"768\" alt=\"image\" src=\"https://github.com/user-attachments/assets/cc4a46fb-6761-446f-bdaa-e5dc5b7d25db\" />",
          "url": "https://github.com/NousResearch/hermes-agent/pull/66163",
          "createdAt": "2026-07-17T08:02:09Z",
          "updatedAt": "2026-08-13T16:04:37Z",
          "timestamp": "2026-08-13T16:04:37Z",
          "metrics": {
            "reactions": 0,
            "comments": 10
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "comp/plugins",
            "platform/slack",
            "area/config",
            "P3",
            "needs-decision",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "chenwei791129",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c5bbdacccbe44da02c25",
        "signalId": "github:NousResearch/hermes-agent:issue:85406",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85406",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Windows host + Docker terminal] `vision_analyze` fails for sandbox-side local paths — host-side `Path()` mangles POSIX separators into backslashes before the container exec-read",
          "text": "## Description On a **Windows host** with `terminal.backend: docker`, `vision_analyze` fails for every local image path that routes to the in-container exec-read. The error is 100% reproducible: ``` Error analyzing image: sandbox returned non-image data for '\\workspace\\research\\...\\vision_test.png': Only base64 data is allowed ``` Note the **backslashes in the echoed path** — the caller passed forward slashes (`/workspace/...`). The same failure affects container-style cache paths (`/root/.hermes/cache/...`). Host-form desktop upload paths (`C:\\Users\\<u>\\AppData\\Local\\hermes\\images\\...`) work correctly (host-read via #69575). ## Environment - Hermes Agent **v0.20.0** (2026-08-03; install checkout `fa83af3f9a4`, shallow) - Host: Windows 11, Docker Desktop (WSL2 engine) - `terminal.backend: docker` (image `nikolaik/python-nodejs:python3.11-nodejs20`) - Main model: text-only (deepseek-v4); auxiliary vision: `mimo-v2.5` via OpenCode Zen - Reproduces on every attempt (verified 2/2 with a 70-byte PNG) ## Steps to reproduce 1. Inside the sandbox, create any valid PNG at a non-cache path: ```bash python3 -c \"import base64; open('/workspace/research/x.png','wb').write(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='))\" ``` 2. Ask the agent: `vision_analyze(image_url=\"/workspace/research/x.png\")` 3. Observe the error above (backslash-echoed path + `Only base64 data is allowed`). **Control (works):** a direct API call to the auxiliary vision provider with the same file base64-encoded returns a correct description; `vision_analyze` on a host-form desktop upload path (`C:\\Users\\<u>\\AppData\\Local\\hermes\\images\\upload_*.png`) also succeeds. ## Root cause 1. **`tools/image_source.py`, `_resolve_container_fallback` (~lines 353–354):** ```python qp = shlex.quote(str(p)) # p = Path(...) built host-side cmd = f\"head -c {_MAX_INGEST_BYTES + 1} < {qp} | base64 | tr -d '\\\\n'\" ``` On a Windows host, `Path('/workspace/x.png')` is a `WindowsPath` whose `str()` yields `\\workspace\\x.png`. `shlex.quote` passes those literal backslashes into the **Linux** container, where the filename does not exist. The command's failure output then fails `base64.b64decode(..., validate=True)` at line ~376, producing `\"Only base64 data is allowed\"` (the tool's stderr/pipe handling folds the failure text into the output). 2. **`tools/credential_files.py`, `from_agent_visible_cache_path` (~line 485):** ```python path = Path(container_path) # POSIX '/root/.hermes/...' -> WindowsPath rel = path.relative_to(mount[\"container_path\"]) # POSIX mount path ``` The same Windows-path mangling breaks container→host cache-path translation (POSIX `relative_to` against a WindowsPath), so container-style cache paths fall through to the broken exec-read. **Why CI missed it:** `tests/integration/test_vision_docker_resolve.py` runs on Linux hosts, where `Path()` preserves POSIX separators and the same code paths work. **Host-side confirmation** (any Windows box): ```powershell python -c \"from pathlib import Path; print(str(Path('/workspace/foo.png')))\" # -> \\workspace\\foo.png ``` ## Suggested fix Build the container command from the POSIX form of the path, e.g. `shlex.quote(p.as_posix())` (or `PurePosixPath`), and use `PurePosixPath(container_path)` for the mount-relative comparison in `from_agent_visible_cache_path`. Add a Windows-host integration test alongside `test_vision_docker_resolve.py`. ## Related - #32709 — original bug (fixed by #57890, Linux-only in practice) - #69575 — desktop uploads permitted host-read (works, verified) - #76566 — cold-start retry + stderr surfacing (post-dates v0.20.0; doesn't address path mangling) ## Impact An agent with the Docker terminal backend on Windows cannot *see* any sandbox-produced image (container screenshots, files the user drops into shared mounts) — a common workflow. Workaround in the field: read the file in-container and call the vision provider directly (bypasses `vision_analyze`).",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85406",
          "createdAt": "2026-08-13T15:55:02Z",
          "updatedAt": "2026-08-13T16:02:15Z",
          "timestamp": "2026-08-13T16:02:15Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [],
          "author": "maqifrnswa",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:1be00cabe008a9d5cfa9",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85408",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85408",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(kanban): stop workers after lifecycle handoff",
          "text": "## What does this PR do? Stops a Kanban worker immediately after it successfully hands custody off through `complete`, `block`, `request-review`, or `request-changes`, and binds the actual runtime provider/model/API mode/session to the corresponding durable run receipt. This closes two concrete failure modes observed in a bounded native-Kanban campaign: - a stale worker continued writing after its lifecycle handoff; and - task receipts could describe requested profile configuration without proving the runtime route that actually executed the worker. The lifecycle latch is deliberately narrow: it requires both a real `HERMES_KANBAN_TASK` and a dispatcher-owned worker context. Ordinary orchestrators, delegated children, inherited cron contexts, and failed lifecycle calls do not stop. ## Related Issue Related to #82591. This is a bounded lifecycle/receipt hardening slice, not closure of that epic. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - Propagate trusted runtime identity outside model-controlled tool arguments. - Stamp provider, model, API mode, session, and source on all four custody-ending Kanban lifecycle paths. - Persist metadata for blocked and changes-requested runs. - Latch only successful dispatcher-owned worker handoffs. - Cancel later same-batch calls, including calls in later execution segments. - End the worker conversation loop without another provider iteration. - Cover spoof resistance, all four lifecycle outcomes, context boundaries, failed calls, same-segment cancellation, and later-segment cancellation. ## How to Test 1. `scripts/run_tests.sh $(python3 -c 'import glob; print(\" \".join(sorted(glob.glob(\"tests/**/*kanban*.py\", recursive=True))))')` 2. `scripts/run_tests.sh tests/run_agent/test_run_agent.py::TestConcurrentToolExecution::test_invoke_tool_dispatches_to_handle_function_call tests/tools/test_kanban_runtime_receipts.py tests/run_agent/test_tool_batch_segmentation.py` 3. `python3 -m compileall -q agent hermes_cli model_tools.py tools/kanban_tools.py` 4. `uv run --with ruff ruff check agent/agent_init.py agent/agent_runtime_helpers.py agent/conversation_loop.py agent/tool_executor.py hermes_cli/kanban_db.py model_tools.py tools/kanban_tools.py tests/run_agent/test_run_agent.py tests/run_agent/test_tool_batch_segmentation.py tests/tools/test_kanban_runtime_receipts.py` 5. `git diff --check origin/main...HEAD` Local results on macOS 15.7.8 / Python 3.11.15: - Kanban suite: **396 passed, 2 skipped** - Targeted lifecycle/receipt suite: **40 passed, 1 skipped** - Adjacent dispatch contract: **passed** - Ruff, compileall, and diff checks: **passed** ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains only changes related to this fix - [x] I've run the repository's canonical focused suites - [x] I've added tests for my changes - [x] I've tested on macOS 15.7.8 ### Documentation & Housekeeping - [x] Documentation updates are N/A; behavior and trust boundary are covered by code comments and tests - [x] `cli-config.yaml.example` update is N/A; no config keys changed - [x] `CONTRIBUTING.md` / `AGENTS.md` update is N/A - [x] Cross-platform impact considered; Windows-specific segmented-dispatch coverage remains in CI - [x] Tool schema changes are N/A; trusted runtime identity is internal middleware data, not a model-visible argument ## Screenshots / Logs No UI change. The PR is covered by canonical tests and an independently reviewed live-campaign evidence packet. No credentials, external source-system writes, or production mutations are part of this patch.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85408",
          "createdAt": "2026-08-13T16:01:28Z",
          "updatedAt": "2026-08-13T16:05:31Z",
          "timestamp": "2026-08-13T16:05:31Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [],
          "author": "ataraxiaone",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:7970659a0934256000c1",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85407",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85407",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cli): keep Windows spinner updates on one row",
          "text": "## What does this PR do? The classic CLI spinner now remains on one physical terminal row instead of pushing every timed repaint into Windows Terminal scrollback. The widget clips long status text before the terminal autowrap cell, preserving live progress while keeping the prompt stable. ### Symptom On Windows 10 with Windows Terminal and PowerShell, a long classic CLI spinner adds a new line on every update rather than refreshing in place. The input tail is repeatedly copied into scrollback with the spinner frames. ### Impact Classic CLI users on the reported Windows terminal path accumulate repeated spinner and prompt rows throughout a tool call, obscuring conversation output and making the active input area unstable. ### Bug Cause **Trigger:** `cli.py` / `_spinner_widget_height()` and the classic CLI `spinner_widget` **Causal chain:** 1. Live token-flow status makes the spinner text long enough to exceed the available terminal width. 2. The spinner widget allows wrapping and dynamically advertises multiple rows. 3. In prompt_toolkit's non-full-screen Windows output path, timed layout repaints push those changing rows into scrollback instead of updating one row in place. **Why it is wrong:** A transient status widget must have stable geometry. Allowing its height to change during timed repaints turns terminal autowrap into persistent output. **Working sibling / contrast:** Short spinner text stays within one row and therefore does not trigger the dynamic-height path. **Ruled out:** The raw `KawaiiSpinner` writer is not responsible because the classic CLI supplies a thinking callback and renders this prompt_toolkit widget; the reported screenshot also repeats the adjacent input tail, showing that the whole bottom layout moves. ### Fix Keep the spinner widget at one row, disable line wrapping, and trim rendered status text to `terminal width - 1` so prompt_toolkit never writes the terminal's final autowrap cell. The regression test uses wide characters to assert both stable height and display-width-safe clipping. ## Related Issue Closes #85380 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `cli.py` - keep the classic CLI spinner on one row and clip text before the autowrap column. - `tests/cli/test_cli_status_bar.py` - cover stable height and display-width-safe clipping with wide text. ## How to Test 1. In Windows Terminal with PowerShell, run the classic CLI and start a turn whose live spinner/status exceeds the terminal width. 2. Leave the status repainting for several seconds and verify that it stays on one clipped row with the input stable below it. 3. Run the targeted automated suite: ```bash scripts/run_tests.sh tests/cli/test_cli_status_bar.py ``` Verified locally: 20 passed. The Windows Terminal/PowerShell comparison reproduced four wrapped rows on the base revision and one clipped row across the same 60-frame, 12-second sequence on this branch. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Windows 11 with Windows Terminal and Windows PowerShell ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - N/A; behavior is internal and covered by a method docstring and regression test - [x] I've updated `cli-config.yaml.example` if I added/changed config keys - N/A; no config keys changed - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows - N/A; no architecture or workflow changed - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) - [x] I've updated tool descriptions/schemas if I changed tool behavior - N/A; no model tool changed ## Screenshots / Logs The real Windows terminal verification used the same timed repaint sequence on the detached base and this branch: the base occupied four physical rows, while this branch remained on one clipped row with the input stable below it.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85407",
          "createdAt": "2026-08-13T16:00:07Z",
          "updatedAt": "2026-08-13T16:00:07Z",
          "timestamp": "2026-08-13T16:00:07Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "fangliquanflq",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:9d2f470a99c54b99ae64",
        "signalId": "github:NousResearch/hermes-agent:pull_request:75312",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:75312",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(slack): detect <@UID> mentions in mrkdwn blocks and legacy attachments",
          "text": "## What does this PR do? `#52387` fixed Block-Kit-only `@mention` detection for the one carrier Slack's WYSIWYG composer produces — a `rich_text` tree with a structured `user` element. Two other carriers were still dropped, so a bot that *explicitly* `@`-mentions the gateway stayed invisible to the `allow_bots: mentions` gate and to `is_mentioned` routing: 1. **Hand-built blocks.** An app that builds Block Kit by hand emits no `user` element — it writes the raw `<@UID>` token into a `section`/`header`/`context` block's `text` or `fields` string. `_collect_slack_block_mentions` recursed only through `(\"elements\", \"element\")` and appended only for `type == \"user\"` nodes, so neither the subtree nor the token was ever reached. 2. **Legacy `attachments`.** Detection returned early when `event[\"blocks\"]` was falsy and never consulted `attachments` at all. `#69316` had already established that Alertmanager, Grafana, PagerDuty and CI bots post with an empty top-level `text` and the real content inside `attachments` — but applied that understanding to *display* only, never to *detection*. Both gaps close inside the existing helpers. The walker now also descends `\"text\"`/`\"fields\"` and harvests raw tokens from string values; a new `_collect_slack_attachment_mentions` covers the legacy carrier including attachment-nested blocks; a shared `_SLACK_USER_MENTION_RE` normalizes the labelled `<@U123|alice>` form to the bare token the gates compare against. **Why this approach.** Two design decisions are worth calling out, because the obvious implementation of each is wrong: - **Recovered mentions are returned as a list, not spliced into the routing text.** `_slack_recovered_mentions` hands the gates a token list; the routing text stays byte-identical to `event[\"text\"]`. Appending recovered tokens would corrupt the two other consumers of that string. `_slack_message_addressed_to_other_user` reads its *first* token — and with an empty top-level text (precisely the alert-bot shape this fixes) the appended tail *becomes* that token, so the message would be dropped as \"addressed to someone else\". And user-configured wake-word patterns are matched with `.search`, so an anchored pattern like `^hey hermes$` would stop matching the moment a tail is appended. The gates now consume `_slack_event_mentions_bot` / `_slack_mention_gate_inputs` instead. - **The `#52390` carve-out is generalized, not merely preserved.** Widening what gets scanned adds carriers the structured `rich_text_quote` node check cannot see, and each is closed explicitly: mrkdwn blockquote markers, `is_msg_unfurl`/`is_share` attachments, and `fallback`. The underlying rule is not \"quoting\" but *verbatim content* — text the author is displaying rather than speaking — so code also counts: a `<@UID>` inside `rich_text_preformatted`, on a `style.code` element, or inside an mrkdwn triple-backtick fence or inline backtick span is not an address. Slack does not linkify mrkdwn inside code, so such a token notifies nobody; waking on it would be the same spurious trigger, reached through a different carrier. Without all of this, widening the scan would have re-opened the agent-agent re-trigger loop that `allow_bots` exists to prevent. ## Related Issue Fixes #75286 ## Type of Change - [x] 🐛 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 All production changes are in `plugins/platforms/slack/adapter.py`; all test changes in `tests/gateway/test_slack_mention.py`. 9 hunks in a 9232-line file — the diff is confined to the mention-detection helpers and their call sites, with no reformatting churn. **Detection helpers** - `_SLACK_USER_MENTION_RE` — one module-level pattern for `<@UID>` / `<@UID|label>`, capturing only the ID. The ID class is deliberately permissive: the gates substring-compare against whatever `auth.test` returned, so a narrower class would silently drop the very mention this recovers. - `_extract_mention_tokens()` — the single place a mrkdwn string is scanned. Both carriers call it, so the carve-outs below apply to all of them automatically instead of having to be re-added per collector. - `_collect_slack_block_mentions()` — `_walk` now descends `\"text\"` and `\"fields\"` too, harvesting tokens when the value is a string and the node is not verbatim. The flag (renamed from `quoted`, which no longer described what it carries) is set by `rich_text_quote`, `rich_text_preformatted` and `style.code`, and propagates down the subtree. - `_collect_slack_attachment_mentions()` — new; covers `pretext`/`title`/`text`, `fields[].title`/`value`, and attachment-nested `blocks` via the existing block walker. - `_slack_recovered_mentions()` — unions both sources and dedupes (one mention commonly appears in several carriers). - `_slack_mention_detection_text()` — **removed.** It existed to hand a substring-testable string to the thread-parent wake check; that caller now takes a boolean (below), leaving the helper with no consumer. Keeping it would mean shipping a helper whose docstring warns against every remaining use of it. **Carve-outs preserving the `#52390` contract** - Lines opening with a mrkdwn blockquote marker (`>`, and the escaped `&gt;` form Slack actually sends) are skipped — mrkdwn-level quoting is invisible to the `rich_text_quote` node check. - Code content is skipped in every carrier it has: the `rich_text_preformatted` node, `style.code` elements, and triple-backtick fences / inline backtick spans inside mrkdwn strings. This is the one carve-out that is genuinely new rather than extended — `#52390` covered quoting only, so a mention a human formatted as code, or one an app emitted inside a payload dump or a relayed log line, still summoned the bot. That last shape lands squarely on the `allow_bots: mentions` path this PR serves. A human *typing* a literal token is unaffected either way, because Slack escapes it to `&lt;@U123&gt;`; that bound is now pinned by a test so relaxing `_SLACK_USER_MENTION_RE` cannot silently regress it. - Attachments flagged `is_msg_unfurl` or `is_share` are skipped, mirroring the skip the agent-text path already performs at `adapter.py:5483`. Without this, pasting a permalink to an old `<@BOT> deploy prod` message would summon the bot — and, because the text path strips the unfurl, summon it with the body missing from its input. - `fallback` is not scanned. Slack never renders it, so a mention living only there is invisible in the channel and notifies nobody. **Defensive handling** - The `try/except` is now per-attachment rather than around the whole loop. Previously one malformed sibling discarded the genuine mentions already collected from earlier attachments — reintroducing the exact silent drop this PR fixes. **Call sites** - `allow_bots: mentions` gate and `is_mentioned` routing now go through `_slack_event_mentions_bot()` / `_slack_mention_gate_inputs()`. - The `#24848` thread-parent wake check is migrated too — its cached branch returned the raw `msg[\"text\"]`, empty for app-authored parents, so a plain follow-up reply in an alert thread was dropped after a restart. The fix otherwise would have covered a thread's first message but not its follow-ups. Rather than filtering display text, the two concerns are split: - `_fetch_thread_parent_event()` — new; returns the raw parent payload, cache-first. - `_thread_parent_mentions_bot()` — new; decides the wake through `_slack_event_mentions_bot()`, **the same predicate the live channel gates use**, so every carve-out above applies to the parent check by construction rather than by being re-added there. - `_fetch_thread_parent_text()` — back to display-only for reply_to_text injection, with a docstring saying it must not be substring-tested. Deriving the wake from it was unsafe in both directions: `_render_message_text` deliberately *preserves* `rich_text_quote` content for the agent to read, and extracts attachment text with no `is_msg_unfurl`/`is_share` exclusion and a `fallback` fallthrough. Its `strip_bot_mention` flag existed solely for the wake caller and is gone. ## How to Test **1. Reproduce on `main`** — both helpers are pure and module-level, so no gateway, credentials or config are needed. Run the snippet from #75286 against a plain checkout of `main`; all six shapes print `DROPPED`. **2. Verify the fix.** On this branch the same snippet reports every carrier as `seen`. **3. Verify the carve-outs did not regress** (these are the cases a naive fix breaks): ````python from plugins.platforms.slack.adapter import _slack_recovered_mentions as rec BOT = \"U0BOTID\" sect = lambda t: {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": t}} # Pasted permalink / forwarded share must NOT wake the bot print(rec({\"text\": \"look at this\", \"attachments\": [{\"is_msg_unfurl\": True, \"text\": f\"<@{BOT}> deploy prod\"}]})) # [] # mrkdwn blockquote must NOT wake the bot print(rec({\"text\": \"\", \"blocks\": [sect(f\"&gt; <@{BOT}> old ping\\nstatus: green\")]})) # [] # A malformed sibling must not discard a real mention print(rec({\"text\": \"\", \"attachments\": [{\"text\": f\"<@{BOT}> disk 91%\"}, {\"fields\": 3}]})) # ['<@U0BOTID>'] # Code content must NOT wake the bot, in any carrier rt = lambda *e: {\"type\": \"rich_text\", \"elements\": list(e)} print(rec({\"text\": \"\", \"blocks\": [rt({\"type\": \"rich_text_preformatted\", \"elements\": [{\"type\": \"user\", \"user_id\": BOT}]})]})) # [] print(rec({\"text\": \"\", \"blocks\": [sect(f\"```\\n notify <@{BOT}>\\n```\")]})) # [] print(rec({\"text\": \"\", \"blocks\": [sect(f\"the field holds `<@{BOT}>` verbatim\")]})) # [] # ...but the fence carve-out must end at the closing fence print(rec({\"text\": \"\", \"blocks\": [sect(f\"```\\nlog\\n```\\n<@{BOT}> look\")]})) # ['<@U0BOTID>'] ```` **3b. Verify the thread-parent wake check obeys the same carve-outs.** Both the cold and the cached parent path, since they are separate code paths that must agree: ```python # adapter = any SlackAdapter instance; parent = a thread-root payload await adapter._thread_parent_mentions_bot( channel_id=\"C1\", thread_ts=PARENT_TS, bot_uid=BOT) # False for a parent whose mention lives in a quote / is_share / is_msg_unfurl / # fallback / preformatted carrier; True for flat text, a section block, or an # attachment field. ``` **4. Verify the routing text stays clean** — the two regressions the list-not-string design prevents: ```python adapter = ... # any SlackAdapter instance # Attachment-only alert naming a human must not look like \"addressed to someone else\" routing_text, is_mentioned = adapter._slack_mention_gate_inputs( {\"text\": \"\", \"attachments\": [{\"fields\": [{\"title\": \"owner\", \"value\": \"<@U_ONCALL>\"}]}]}, BOT) assert routing_text == \"\" and adapter._slack_message_addressed_to_other_user(routing_text, {BOT}) is False # An anchored wake word must still match when an attachment is present ``` Both are covered by `test_gate_leaves_routing_text_free_of_recovered_mentions` and `test_gate_wake_word_pattern_still_matches_with_an_attachment`. **5. Run the suite:** ```bash pytest tests/gateway/test_slack_mention.py -q # 87 passed (27 on main) pytest tests/gateway/test_slack*.py -q # 369 passed across 27 files ``` Of the 60 tests added to `test_slack_mention.py`, 53 fail on a plain `main` (verified by running the new file against the unmodified adapter); the other 7 are non-regression bounds that must pass both before and after — escaped tokens, text after a *closed* fence, a mention adjacent to an inline code span, an unpaired backtick, and `rich_text_list` carrying `style` as a plain string rather than a dict. **6. End-to-end.** With `allow_bots: mentions`, have any app post a message with an empty top-level `text` and `<@YOUR_BOT_UID>` inside a `section` block or an attachment field. On `main` the gateway never replies; on this branch it does. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) — single commit, two files, no `ruff format` churn - [x] I've run `pytest tests/ -q` and all tests pass — **with one caveat, see note below** - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15.7.7 (Apple Silicon), Python 3.13.3 > **Note on `pytest tests/ -q`:** `tests/acp/test_entry.py` fails to collect on a plain checkout (`ModuleNotFoundError: No module named 'acp'`), so I ran `tests/gateway tests/plugins` instead: **5550 passed, 3 failed**. The 3 are pre-existing and unrelated — `test_systemd_notify.py` (1) and `test_wecom_callback.py` (2). I confirmed that by stashing this change and re-running those two files on the unmodified tree: the same 3 fail. Happy to rebase if they are fixed on `main` first. ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — docstrings on every touched helper; no user-facing docs affected - [x] N/A — no config keys added or changed - [x] N/A — no architecture or workflow change; the fix stays inside the existing Slack adapter helpers - [x] I've considered cross-platform impact (Windows, macOS) — pure string/dict handling, no platform-dependent code paths - [x] N/A — no tool descriptions or schemas changed ## Screenshots / Logs The failure is silent by design; the only trace on `main` is the existing debug line, which this PR also corrects (it claimed to cover \"flat text or blocks\" while attachments were never consulted): ``` # before [Slack] Dropping bot message under allow_bots=mentions: no <@%s> mention in flat text or blocks # after [Slack] Dropping bot message under allow_bots=mentions: no <@%s> mention in flat text, blocks or attachments ```",
          "url": "https://github.com/NousResearch/hermes-agent/pull/75312",
          "createdAt": "2026-07-31T08:10:16Z",
          "updatedAt": "2026-08-13T15:59:11Z",
          "timestamp": "2026-08-13T15:59:11Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "platform/slack",
            "P3",
            "sweeper:risk-message-delivery",
            "sweeper:risk-security-boundary",
            "sweeper:blast-moderate"
          ],
          "author": "chenwei791129",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e427f5962c40d28c4ce2",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85374",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85374",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(gateway): expose Kanban wake metadata",
          "text": "## Summary - identify each direct, user-visible Kanban terminal notification with a versioned `hermes_kanban_notification` envelope - preserve existing subscription chat/thread routing metadata while keeping the stored subscription mapping unchanged - retain the distinct `hermes_kanban_wake` envelope on the later synthetic internal wake, including deterministic aggregate event ordering - preserve localized text, retry/cursor behavior, artifact delivery, ordinary sends, and the stateless API self-post shape ## Delivery-plane contracts Direct push notifications receive `metadata[\"hermes_kanban_notification\"]` version 1 with only: - `board` - `task_id` - the direct notification's `event_kind` - the claimed batch `cursor` The later push-capable synthetic `MessageEvent` receives `event.metadata[\"hermes_kanban_wake\"]` version 1 with only: - `board` - `task_id` - deterministically ordered aggregate `event_kinds` - the same claimed batch `cursor` The names are intentionally distinct: one direct notification represents one event, while one internal wake can aggregate several events from the claimed batch. ## Compatibility and privacy Existing subscription routing metadata remains alongside the direct-notification envelope and is copied before augmentation. Neither the caller mapping nor the stored subscription metadata is mutated. Calling `deliver_wake` without metadata still produces the existing internal text `MessageEvent` with an empty metadata mapping; supplied wake metadata is deep-copied. Both Kanban envelopes are explicit privacy allowlists. They exclude task content, summaries/results/reasons, arbitrary event payloads, profile/assignee identity, local paths, credentials, secret-like fields, and subscription delivery metadata. Localized visible text and ordinary adapter sends are unchanged. Stateless API self-post wakes retain their existing request shape and receive neither adapter-only envelope. Non-push delivery still relies on the existing self-post path. ## Overlap reconciliation A current open-PR scan before repair found adjacent Kanban notifier, routing, and wake work, but no open PR providing both direct per-event notification identity and the separate aggregate synthetic-wake contract. This PR remains the single candidate lineage. ## Verification - `scripts/run_tests.sh tests/gateway/test_kanban_notifier.py tests/gateway/test_kanban_notifier_apiserver_wake.py tests/gateway/test_kanban_notifier_watcher_dispatch_gate.py tests/gateway/test_kanban_notifier_zero_sub_gate.py tests/gateway/test_kanban_watchers_mixin.py tests/gateway/test_wake_delivery.py tests/hermes_cli/test_kanban_notify.py tests/tui_gateway/test_kanban_notify_poller.py -q` — 42 passed - `ruff check gateway/kanban_watchers.py gateway/wake.py tests/gateway/test_kanban_notifier.py tests/gateway/test_wake_delivery.py` — passed - `python -m py_compile gateway/kanban_watchers.py gateway/wake.py tests/gateway/test_kanban_notifier.py tests/gateway/test_wake_delivery.py` — passed - `git diff --check` — passed ## Residual risk Push notification text remains the delivery boundary before the best-effort synthetic wake, matching existing behavior. The shared claimed cursor identifies retries and aggregate batches; it is not a per-event database ID.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85374",
          "createdAt": "2026-08-13T14:56:56Z",
          "updatedAt": "2026-08-13T15:59:04Z",
          "timestamp": "2026-08-13T15:59:04Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/gateway",
            "comp/cron",
            "P3",
            "sweeper:risk-message-delivery"
          ],
          "author": "rmacbot",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:945f36136fe92732b684",
        "signalId": "github:NousResearch/hermes-agent:pull_request:80533",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:80533",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(gateway): add webhook completion scripts",
          "text": "## What does this PR do? Adds an optional route-level `completion_script` for static, agent-backed webhook routes. Webhook requests continue to return `202 Accepted` immediately. After the asynchronous agent run reaches a terminal `success` or `failure`, Hermes invokes the configured script with a versioned JSON envelope on stdin. This gives external orchestrators a generic completion signal without coupling the webhook adapter to a specific provider or delivery target. The implementation intentionally remains best effort: script failures do not replace the original processing outcome or block one-shot session cleanup, and expected cancellation does not invoke the finalizer. ## Related Issue Fixes #80531 ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [x] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - Add `completion_script` dispatch snapshots and terminal lifecycle handling in `gateway/platforms/webhook.py`. - Reuse the confined webhook script runner in `gateway/platforms/webhook_filters.py`, with JSON stdin, profile-relative script resolution, timeout handling, and output suppression for finalizers. - Prevent duplicate completion invocations when the base lifecycle reports more than one outcome for the same event. - Keep expected cancellation and `deliver_only` routes outside the completion lifecycle. - Add real processing-path tests for success, failure, cancellation, nonzero exit, timeout, session cleanup, and dispatch-time route snapshots. - Document the route key, envelope contract, limitations, and configuration example. ## How to Test 1. Configure a static webhook route with `completion_script: finalize.py`, and place the script under the active profile's `~/.hermes/scripts/` directory. 2. POST a signed webhook and verify Hermes returns `202` while the script later receives the documented JSON envelope on stdin. 3. Run: ```bash scripts/run_tests.sh \\ tests/gateway/test_webhook_session_close.py \\ tests/gateway/test_webhook_adapter.py \\ tests/gateway/test_webhook_integration.py \\ tests/gateway/test_webhook_dynamic_routes.py \\ tests/gateway/test_webhook_deliver_only.py \\ tests/gateway/test_webhook_signature_rate_limit.py -q ``` Result: 56 passed. 4. Run focused static checks: ```bash uv run --python 3.11 ruff check gateway/platforms/webhook.py gateway/platforms/webhook_filters.py tests/gateway/test_webhook_adapter.py tests/gateway/test_webhook_session_close.py uv run --python 3.11 ty check gateway/platforms/webhook.py gateway/platforms/webhook_filters.py tests/gateway/test_webhook_adapter.py tests/gateway/test_webhook_session_close.py ``` Both pass. The full local suite was also started, but this checkout discovers an unrelated external deployment repository whose source-anchor patcher tests fail when upstream `webhook.py` changes. The Hermes-focused tests above pass. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - Not checked because the full local suite is not green in this checkout: test discovery includes an unrelated external deployment repository whose source-anchor patcher tests fail when upstream `webhook.py` changes. The focused Hermes webhook suite passes (56 tests), as do Ruff and `ty`; see **How to Test** above. - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15.6 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A (no architecture or workflow change required) - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## Screenshots / Logs Not applicable; this is a configuration and lifecycle feature covered by automated tests. ## Infographic <img width=\"1024\" height=\"768\" alt=\"圖片\" src=\"https://github.com/user-attachments/assets/c989c915-b3f0-4f23-8beb-617820bfbd18\" />",
          "url": "https://github.com/NousResearch/hermes-agent/pull/80533",
          "createdAt": "2026-08-06T18:54:24Z",
          "updatedAt": "2026-08-13T15:57:46Z",
          "timestamp": "2026-08-13T15:57:46Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/gateway",
            "platform/webhook",
            "P3",
            "sweeper:risk-message-delivery"
          ],
          "author": "chenwei791129",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c30e64bfde684a8b2cf8",
        "signalId": "github:NousResearch/hermes-agent:pull_request:21321",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:21321",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "Fix/tui focus redraw",
          "text": "## What does this PR do? <!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? --> ## Related Issue <!-- Link the issue this PR addresses. If no issue exists, consider creating one first. --> Fixes # ## Type of Change <!-- Check the one that applies. --> - [ ] 🐛 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 <!-- List the specific changes. Include file paths for code changes. --> - ## How to Test <!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. --> 1. 2. 3. ## Checklist <!-- Complete these before requesting review. --> ### Code - [ ] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [ ] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) 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: <!-- e.g. Ubuntu 24.04, macOS 15.2, Windows 11 --> ### Documentation & Housekeeping <!-- Check all that apply. It's OK to check \"N/A\" if a category doesn't apply to your change. --> - [ ] 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](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [ ] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## For New Skills <!-- Only fill this out if you're adding a skill. Delete this section otherwise. --> - [ ] This skill is **broadly useful** to most users (if bundled) — see [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#should-the-skill-be-bundled) - [ ] SKILL.md follows the [standard format](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#skillmd-format) (frontmatter, trigger conditions, steps, pitfalls) - [ ] No external dependencies that aren't already available (prefer stdlib, curl, existing Hermes tools) - [ ] I've tested the skill end-to-end: `hermes --toolsets skills -q \"Use the X skill to do Y\"` ## Screenshots / Logs <!-- If applicable, add screenshots or log output showing the fix/feature in action. -->",
          "url": "https://github.com/NousResearch/hermes-agent/pull/21321",
          "createdAt": "2026-05-07T14:05:04Z",
          "updatedAt": "2026-08-13T15:56:52Z",
          "timestamp": "2026-08-13T15:56:52Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/tui",
            "P2",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility",
            "sweeper:risk-platform-windows",
            "sweeper:blast-broad"
          ],
          "author": "PaytonBurbank",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:78ee511f73326ad25782",
        "signalId": "github:NousResearch/hermes-agent:issue:75992",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:75992",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": ".update-incomplete left after every update: uv exclude-newer filters packages with no upload date (setuptools/pillow/mcp)",
          "text": "**Summary** Every `hermes update` (including the desktop in-app update path) leaves `~/.hermes/hermes-agent/.update-incomplete` behind. The next launch prints: > A previous `hermes update` was interrupted mid-install — finishing dependency installation now... and then fails with `✗ Could not auto-recover the interrupted install.` The marker never clears because startup recovery runs the same filtered resolution and hits the same wall. **Root cause** `pyproject.toml` (`[tool.uv]`) sets `exclude-newer = \"14 days\"`. During dependency install, uv filters packages whose PyPI metadata is missing an upload date (or whose upload date falls inside the window). Observed filtered packages: `setuptools-82.0.0` / `82.0.1`, `pillow 12.3.0`, `mcp-1.28.1`. The resolution can't complete → the update writes `.update-incomplete` → every later launch's recovery retries with the same filter and fails again. Log evidence (`~/.hermes/logs/desktop.log`): ``` [hermes] warning: setuptools-82.0.0.tar.gz is missing an upload date, but user provided: 2026-07-18T07:33:43.69822Z [hermes] warning: setuptools-82.0.1-py3-none-any.whl is missing an upload date, but user provided: 2026-07-18T07:33:43.69822Z [hermes] hint: `setuptools` was filtered by `exclude-newer` to only include packages uploaded before 2026-07-18T07:33:43.705738Z. Consider using `exclude-newer-package` to override the cutoff for this package. ``` **Why it recurs on every update** The filter applies on every dependency install. `exclude-newer-package` already whitelists `vercel`, `nemo-relay`, `huggingface_hub`, but not the packages the resolver currently needs (`setuptools`, `pillow`, `mcp`), so the same failure reproduces after every update. **Suggested fix** Add the affected packages to `exclude-newer-package` (uv's own hint suggests exactly this): ```toml exclude-newer-package = { vercel = false, nemo-relay = false, huggingface_hub = false, setuptools = false, pillow = false, mcp = false } ``` Alternatively pin an absolute cutoff timestamp or widen the window. **Impact** Low but noisy: the core venv is actually healthy (manual `pip install -e '.[all]'` succeeds, and the marker can then be removed); every launch prints the recovery banner + a failure. Users on the GUI/in-app update path hit this repeatedly after each update. **Environment** - macOS 27 (arm64), git install - Reproduced on `4be138eb0` and `57b1eb8c4` (v0.19.1) - Desktop in-app update path (`hermes update` + `hermes desktop --build-only`)",
          "url": "https://github.com/NousResearch/hermes-agent/issues/75992",
          "createdAt": "2026-08-01T07:36:29Z",
          "updatedAt": "2026-08-13T15:53:46Z",
          "timestamp": "2026-08-13T15:53:46Z",
          "metrics": {
            "reactions": 0,
            "comments": 5
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "area/config",
            "P2",
            "sweeper:risk-compatibility",
            "area/install-update"
          ],
          "author": "zhiang34",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a8ae11d36665d84c2053",
        "signalId": "github:NousResearch/hermes-agent:pull_request:9942",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:9942",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway,cli,tui): pass user arguments to exec quick_commands",
          "text": "## Problem The gateway, CLI, and TUI gateway `type: exec` quick_commands all silently dropped user arguments. Any slash command wrapping a CLI tool would fail to receive the query. **Root cause:** `exec_cmd` was used directly without appending user arguments. The alias type handler already forwarded args, but all three exec dispatch paths were missing the same logic. ## Real-World Example ```yaml quick_commands: poly: type: exec command: \"polymarket markets search\" ``` **User types in Telegram:** `/poly white sox` - **Before fix:** Gateway runs `polymarket markets search` (no query) — script returns usage message - **After fix:** Gateway runs `polymarket markets search white sox` — returns market results ## Changes All three exec dispatch paths now extract user args, `shlex.quote()` them for shell injection safety, and append to `exec_cmd` before spawning the subprocess. The append is gated on non-empty args so zero-arg commands are unaffected. - **gateway/run.py** (~line 10016): `event.get_command_args()` + `shlex.quote()` - **cli.py** (~line 8929): `cmd_original[len(base_cmd):]` + `shlex.quote()` - **tui_gateway/server.py** (~line 11823): `arg` from `command.dispatch` params + `shlex.quote()` ## Tests (7 new) **Gateway (`tests/cli/test_quick_commands.py`):** - Multi-word arg forwarding (`/echo hello world foo` → output contains all words) - No-args regression (no trailing space added to command) - Shell injection prevention (`; echo INJECTED` treated as literal text, not executed) **CLI (`tests/cli/test_quick_commands.py`):** - Multi-word arg forwarding - No-args regression **TUI Gateway (`tests/test_tui_gateway_server.py`):** - Multi-word arg forwarding (captures the actual command string passed to subprocess) - No-args regression ## Reviewer Feedback Addressed Per @teknium1's review on #62717: - ✅ Added `tui_gateway/server.py` exec path (was missing) - ✅ Added multi-word argument regression test - ✅ Added empty-argument regression test (confirms no regression for zero-arg commands)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/9942",
          "createdAt": "2026-04-15T00:05:02Z",
          "updatedAt": "2026-08-13T15:53:22Z",
          "timestamp": "2026-08-13T15:53:22Z",
          "metrics": {
            "reactions": 0,
            "comments": 7
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/gateway",
            "P2",
            "sweeper:risk-security-boundary",
            "sweeper:risk-compatibility",
            "sweeper:risk-platform-windows",
            "sweeper:blast-contained"
          ],
          "author": "GusBot69",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:906e3f0c39edec803072",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85405",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85405",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): passive update check must compare against the official repo, not the local origin",
          "text": "## Problem The desktop app's passive update check (\"Settings → About → Check now\", and the background auto-check) can report **\"You're on the latest version\" while the checkout is thousands of commits behind the official repo.** `checkUpdates()` in `apps/desktop/electron/main.ts` only probed the official repo when the local git origin was the **official SSH** URL (`git@github.com:NousResearch/hermes-agent.git`). For every other origin — including the official **HTTPS** remote and any **fork** — it ran `git fetch origin <branch>` and compared `HEAD` against *that* origin's branch. A fork origin is common on contributor machines. If the fork's `main` trails the local `HEAD`, the comparison reads as \"0 behind\" and the app concludes it's current — even when official `main` is far ahead. Updates are then silently never offered, and the auto-updater never recovers on its own. ### Reproduction (real install) - Local checkout: official `main` @ `01a1037d1` (2026-08-05), origin pointed at a fork (`33hodl/hermes-agent`, fork `main` @ 2026-07-04) - Local `HEAD` was **6,431 commits ahead** of the fork's `main` → `behind = 0` - App reported \"You're on the latest version\" while official `main` was **~1,500 commits ahead** of `HEAD` - The About panel's git metadata confirmed the checkout was a source install (branch/commit shown), i.e. exactly the path that uses this check ## Fix The passive check now **always probes the official repo first** via anonymous HTTPS `ls-remote` — the same read-only, no-auth, no-SSH/FIDO2-prompt mechanism the code already used for official SSH remotes (`update-remote.ts`). Only when that probe fails (offline / GitHub unreachable) does it fall back to the old behavior: - official SSH origins → fail-fast `fetch-failed` (unchanged; avoids the hardware-touch prompt) - all other origins → the previous `git fetch origin` comparison path (unchanged) So a fork or stale-mirror origin can no longer masquerade as \"latest\". The verdict is always about the official repo's branch, which is the question a user is actually asking. New testable helper `isOfficialRemote()` in `update-remote.ts` classifies SSH/HTTPS official remotes for the fallback decision. ## Why not an alternative - **Fix the origin instead** — doesn't scale: forks and mirrors are a legitimate, documented setup for contributors and organizations; the check itself should be honest regardless of origin. - **Make apply pull from the official repo too** — out of scope: active apply flows are deliberately left unchanged (installs that intentionally track a fork keep pulling from their own origin). This PR only fixes the *check's* source of truth. - **Require a release-tag comparison** — `main`-tracking installs (the default) check against the branch they track, not the last release; comparing against the official branch keeps the existing semantics, just with the correct reference. ## Validation - `npx vitest run electron/update-remote.test.ts` → **7/7 passing** (6 existing + new `isOfficialRemote` coverage) - `esbuild` parse check on the modified `main.ts` → clean - Production observation: the reproduction above — after this change, the same install compares against official `main` and reports the true behind state instead of \"latest\" No speculative surface: single-purpose diff (3 files, +62/−22), behavior preserved for every previously-correct path.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85405",
          "createdAt": "2026-08-13T15:53:13Z",
          "updatedAt": "2026-08-13T15:53:13Z",
          "timestamp": "2026-08-13T15:53:13Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "33hodl",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:35b6b0aaa76620217d11",
        "signalId": "github:NousResearch/hermes-agent:pull_request:52289",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:52289",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): classify provider memory-ceiling 400s as overloaded, not context_overflow",
          "text": "## What does this PR do? Local-inference providers (oMLX / MLX with a memory guard, and similar Metal/CUDA setups) abort a request when the **prefill memory peak** exceeds a GPU/unified-memory ceiling. Their rejection text often suggests \"reduce context length\" / \"reduce context size\", which collides with the context-overflow patterns — so Hermes classifies a *memory-ceiling* 400 as `context_overflow` and routes it into the compress → shrink-context → retry loop. Compression cannot lower a prefill memory peak (the conversation is tiny — ~5.7k tokens in the report), so it exhausts `max_compression_attempts`, the compression call itself re-hits the wedged server, and the loop ends in \"Cannot compress further\" → destructive session reset. This PR adds a `_MEMORY_CEILING_PATTERNS` check that runs **before** the context-overflow check at both classification sites (`_classify_400` and the no-status `_classify_by_message` streaming path), classifying these as `FailoverReason.overloaded` (transient, retry-with-backoff, no compression, no reset) — the same \"checked BEFORE context_overflow\" guard pattern already used for multimodal / image-too-large / request-validation 400s. `overloaded` mirrors the existing 503/529 recovery: `retryable=True`, `should_compress` defaults to `False`, and it is in the retryable set so the loop never enters the client-error abort/reset path. ## Related Issue Fixes #52261 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `agent/error_classifier.py`: add `_MEMORY_CEILING_PATTERNS`; guard both `_classify_400` and `_classify_by_message` before the context-overflow check. - `tests/agent/test_error_classifier.py`: three positive cases (the three verbatim provider wordings, across the 400-status and no-status streaming paths) + one negative invariant case proving a genuine context-window overflow still routes to `context_overflow` + compression. ## How to Test 1. `uv run --with pytest --with pytest-asyncio python3 -m pytest tests/agent/test_error_classifier.py -v` 2. Regression guard (fail-before / pass-after): with the production hunk in `error_classifier.py` reverted, the three positive tests fail (memory-ceiling 400s classify as `context_overflow`/`unknown`); with the hunk restored, all four pass. The negative case (`test_400_genuine_context_window_overflow_still_compresses`) passes in both states, proving the guard does not swallow real context overflows. 3. Full file: 165 passed. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (pure string-classification logic — platform-independent) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## Contract Protected **Invariant:** a provider **memory/resource-ceiling** rejection never enters the compress-and-shrink-context path, regardless of whether it arrives with an HTTP 400 or as a no-status streaming `APIError`. - **Known-bad inputs (now covered):** the three captured oMLX/MLX wordings — \"oMLX prefill memory guard rejected … dynamic ceiling is 13.50 GB … reduce context length\", \"process memory limit exceeded … Reduce context size or lower memory_guard_tier\", and the no-status \"Prefill context too large for available memory\". - **Future-input coverage:** `_MEMORY_CEILING_PATTERNS` keys on memory/allocation/ceiling/guard wording (OOM, llama.cpp/vLLM, Metal/CUDA), disjoint from token/window-count language. - **Negative case:** a genuine `maximum context length … reduce the length` 400 still routes to `context_overflow` + compression. > Sibling follow-up (intentionally out of scope to keep the diff small): a > dedicated `FailoverReason.resource_exhausted` reason — instead of reusing > `overloaded` — would let callers surface a clearer \"free memory / raise the > guard ceiling\" message. Happy to widen if preferred.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/52289",
          "createdAt": "2026-06-25T03:31:44Z",
          "updatedAt": "2026-08-13T15:52:50Z",
          "timestamp": "2026-08-13T15:52:50Z",
          "metrics": {
            "reactions": 1,
            "comments": 9
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "backend/local",
            "P2",
            "sweeper:risk-session-state",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate",
            "area/memory"
          ],
          "author": "briandevans",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:783d33dc5dd198a6d7b8",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85404",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85404",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "perf(desktop): bound long-running app resource use",
          "text": "- persist exact backend ownership and coordinate shutdown so failed starts and app relaunches do not leave resource-heavy servers behind - park inactive panes and use a weighted protected-session cache so hidden transcripts no longer grow renderer memory without bound - preserve active, stateful, and terminal surfaces while validating lifecycle behavior across renderer, Electron, and backend tests Tests: - Desktop renderer typecheck - ESLint on changed Desktop files - 75 focused renderer tests - 24 focused Electron lifecycle tests - 2 backend parent-watchdog tests 💘 Generated with Crush",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85404",
          "createdAt": "2026-08-13T15:52:47Z",
          "updatedAt": "2026-08-13T15:52:47Z",
          "timestamp": "2026-08-13T15:52:47Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "aleksclark",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:5a4e9b8e0d423c3fbaae",
        "signalId": "github:NousResearch/hermes-agent:pull_request:48567",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:48567",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix: restore provider-backed memory tools for cron",
          "text": "## Summary Rebased and repaired the cron external-memory fix on current `main`. ## Behavior - Cron keeps the built-in `memory` tool and local `MEMORY.md`/`USER.md` surface disabled. - A job explicitly configured with the raw `memory` toolset receives only external-provider tools (for example `hindsight_recall`, `hindsight_retain`, and `hindsight_reflect`). - `MemoryManager(mode=\"tools\")` centrally disables automatic provider prompt injection, prefetch, sync/retain, session-boundary hooks, compression hooks, built-in-memory mirroring, and delegation hooks. - An administrator `agent.disabled_toolsets: [memory]` still overrides a job opt-in. ## Verification - Focused regression suite: `133 passed` - Real isolated `AIAgent` and `cron.scheduler.run_job` integration probes confirmed tools-only Hindsight schemas are present, built-in memory is absent, local memory store is `None`, and no provider system-prompt block is injected. This refresh replaces the stale prior branch head and is based on current upstream `main` (`03fa32c92`).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/48567",
          "createdAt": "2026-06-18T18:07:50Z",
          "updatedAt": "2026-08-13T15:52:31Z",
          "timestamp": "2026-08-13T15:52:31Z",
          "metrics": {
            "reactions": 0,
            "comments": 6
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "comp/cron",
            "comp/plugins",
            "tool/memory",
            "P2",
            "sweeper:risk-session-state",
            "sweeper:risk-security-boundary",
            "sweeper:blast-moderate",
            "area/memory"
          ],
          "author": "GhastlyJack",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:7f831e37d9ef9fd9c973",
        "signalId": "github:NousResearch/hermes-agent:pull_request:57674",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:57674",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(google-chat): scope service-account env per profile",
          "text": "## What does this PR do? Routes Google Chat's profile-scoped service-account and platform env reads through `agent.secret_scope.get_secret()` so multiplex gateways do not pick up credentials or routing config from another profile's process environment. This is a sibling hardening follow-up to #56680 (Vertex `GOOGLE_APPLICATION_CREDENTIALS` isolation) and the same credential-isolation class as #57624 for cloud browser providers. In multiplex mode, `os.environ` is process-global while the active profile boundary is `agent.secret_scope`; direct env reads can therefore authenticate or route a secondary profile with another profile's Google Chat service account/project/subscription. ## Related Issue Follow-up sibling hardening for #56680. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [x] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `plugins/platforms/google_chat/adapter.py` - Uses `get_secret()` for Google Chat service-account credentials and profile-scoped platform config: - `GOOGLE_CHAT_SERVICE_ACCOUNT_JSON` - `GOOGLE_APPLICATION_CREDENTIALS` - `GOOGLE_CHAT_PROJECT_ID` / `GOOGLE_CLOUD_PROJECT` - `GOOGLE_CHAT_SUBSCRIPTION_NAME` / `GOOGLE_CHAT_SUBSCRIPTION` - `GOOGLE_CHAT_HOME_CHANNEL*` - `GOOGLE_CHAT_HTTP_EVENTS_URL` - `GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE` - `GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL` - Applies the HTTP-events scope at env enablement, registry detection, and adapter construction so no sibling path can re-read another profile's process environment. - Keeps single-profile behavior unchanged because `get_secret()` falls back to `os.environ` outside multiplex secret-scope enforcement. - Adds an ADC guard mirroring the Vertex hardening: in multiplex mode, do not call `google.auth.default()` when process-global Google Chat credentials are present but this profile has no scoped value. - `tests/plugins/platforms/google_chat/test_secret_scope.py` - Covers scoped Google Chat env enablement winning over foreign process env. - Covers fail-closed behavior for unscoped multiplex env reads. - Covers HTTP-events registry detection and adapter fallback with an empty or scoped profile boundary. - Covers `_load_sa_credentials()` using scoped inline service-account JSON over foreign process env. ## How to Test Focused regression tests: ```bash uv sync --extra dev --extra google scripts/run_tests.sh \\ tests/plugins/platforms/google_chat/test_secret_scope.py \\ tests/gateway/test_google_chat.py -q ``` Lint changed files: ```bash uv run --frozen --extra dev ruff check \\ plugins/platforms/google_chat/adapter.py \\ tests/plugins/platforms/google_chat/test_secret_scope.py ``` Type-check command run on changed files: ```bash uv run --frozen --extra dev ty check \\ plugins/platforms/google_chat/adapter.py \\ tests/plugins/platforms/google_chat/test_secret_scope.py ``` Observed locally: - Google Chat focused and related tests: 185 passed - Ruff: all checks passed - Ty: failed on pre-existing Google Chat optional-dependency / adapter typing diagnostics in `plugins/platforms/google_chat/adapter.py` (unresolved optional imports, existing override signatures, existing `Any | None` adapter fields). No new test-file diagnostic remained after updating the test instance construction. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Ubuntu 24.04 / Linux ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## Screenshots / Logs Focused tests: ```text 8 focused secret-scope tests passed 177 Google Chat gateway tests passed ``` Ruff: ```text All checks passed! ``` Duplicate search: ```text \"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON\" \"secret_scope\" \"Google Chat\" \"GOOGLE_APPLICATION_CREDENTIALS\" \"multiplex\" \"google_chat\" \"profile secret\" ``` No open duplicate PR found.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/57674",
          "createdAt": "2026-07-03T10:51:30Z",
          "updatedAt": "2026-08-13T15:51:33Z",
          "timestamp": "2026-08-13T15:51:33Z",
          "metrics": {
            "reactions": 0,
            "comments": 8
          },
          "labels": [
            "type/security",
            "comp/plugins",
            "area/auth",
            "P2",
            "sweeper:risk-message-delivery",
            "sweeper:risk-security-boundary",
            "sweeper:blast-moderate",
            "area/profiles"
          ],
          "author": "rayjun",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:6e3f12357b347106a9db",
        "signalId": "github:NousResearch/hermes-agent:issue:85391",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85391",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "WhatsApp pairing: `hermes whatsapp` wizard writes to a different session dir than the gateway reads, and a 0-byte `creds.json` counts as \"paired\"",
          "text": "# WhatsApp pairing: `hermes whatsapp` wizard writes to a different session dir than the gateway reads, and a 0-byte `creds.json` counts as \"paired\" **Version:** Hermes Agent v0.19.0 (2026.7.20) · upstream `36e41c09` **Platform:** Linux (s6 container image), `HERMES_HOME=/opt/data`, install root `/opt/hermes` Two independent bugs in the WhatsApp pairing flow. Together they produce a state that is very hard to diagnose: the user scans successfully, the phone shows a linked device, and the gateway still reports `WhatsApp enabled but not paired` / `Start failed`. Restarting the gateway can never fix it, so the user ends up in a restart loop against an unsatisfiable precondition. --- ## Bug 1 — pairing wizard bypasses `get_hermes_dir()`, so it can pair into a directory nothing reads The gateway adapter and the dashboard both resolve the session dir through the compatibility helper: - `plugins/platforms/whatsapp/adapter.py:407` → `get_hermes_dir(\"platforms/whatsapp/session\", \"whatsapp/session\")` - `hermes_cli/web_server.py:8312` → `get_hermes_dir(\"platforms/whatsapp/session\", \"whatsapp/session\")` The CLI wizard hardcodes the **legacy** path instead: - `hermes_cli/main.py:2875` → `session_dir = get_hermes_home() / \"whatsapp\" / \"session\"` `get_hermes_dir(new, old)` returns the legacy path *only when it exists and has content*, else the new consolidated path (`hermes_constants.py:239`). The docstring is explicit that an empty legacy stub must not shadow real data: > A bare empty `<old_name>/` directory does **not** count as \"the legacy install is in use\" … See #27602 for the pairing-store regression where a dormant empty `pairing/` orphaned approved-user data in `platforms/pairing/`. So the two paths agree only while `$HERMES_HOME/whatsapp/session` is populated. The moment it is emptied, they diverge — the wizard keeps writing to `whatsapp/session` while the gateway reads `platforms/whatsapp/session`. ### Reproduction 1. Pair WhatsApp normally (session lands in the legacy `$HERMES_HOME/whatsapp/session`; both paths agree). 2. Unlink the device in WhatsApp → Linked Devices, then `rm -rf \"$HERMES_HOME/whatsapp/session\"` (a natural \"start clean\" move). 3. Start the gateway. It now resolves to `platforms/whatsapp/session`, which still holds the **revoked** credentials, and replays them: ``` {\"reasonNode\":{\"tag\":\"conflict\",\"attrs\":{\"type\":\"device_removed\"}}, \"fullErrorNode\":{\"tag\":\"stream:error\",\"attrs\":{\"code\":\"401\"}, ...},\"msg\":\"stream errored out\"} ❌ Logged out. Delete session and restart to re-authenticate. ``` 4. Re-link on the phone via `hermes whatsapp`. **No effect** — the wizard writes to `whatsapp/session`, which the gateway is no longer reading. The gateway keeps replaying the revoked creds in a restart loop (5 consecutive `Logged out` cycles observed) and reports `enabled but not paired`. ### Suggested fix Have `main.py:2875` call the same resolver as the other two call sites. Emitting the resolved path during pairing would also make a divergence self-evident: ```python session_dir = get_hermes_dir(\"platforms/whatsapp/session\", \"whatsapp/session\") ``` --- ## Bug 2 — the paired check is existence-only, so a truncated `creds.json` passes `plugins/platforms/whatsapp/adapter.py:502-503`: ```python creds_path = self._session_path / \"creds.json\" if not creds_path.exists(): ``` A `creds.json` truncated mid-write is **0 bytes and still passes `.exists()`**, so a pairing that was actually lost is reported as paired. This is easy to hit because, in `--pair-only` mode, the bridge writes `creds.json` **after** emitting the `connected` event and then exits on its own — any supervising watcher that terminates the bridge on `connected` truncates the file. Observed exactly this: the scan succeeded and **826 key files** were written, yet `creds.json` was 0 bytes. ``` *** connected: {\"event\":\"connected\",\"user\":{\"id\":\"9955xxxxxxxx:2@s.whatsapp.net\",\"name\":\"...\"}} $ wc -c < creds.json 0 $ ls session | wc -l 826 ``` Because existence-only passes, downstream code proceeds on unusable credentials instead of reporting the real problem. ### Suggested fix Validate rather than stat — size > 0, parseable JSON, and ideally the presence of `noiseKey` / `signedIdentityKey`: ```python def _has_valid_creds(creds_path: Path) -> bool: try: if creds_path.stat().st_size == 0: return False data = json.loads(creds_path.read_text()) except (OSError, ValueError): return False return bool(data.get(\"noiseKey\") and data.get(\"signedIdentityKey\")) ``` A note in the pairing docs/code that the bridge must be allowed to exit on its own after `connected` (rather than being killed on the event) would prevent the truncation in the first place. --- ## Bug 3 (minor) — `node_modules` live in the `HERMES_HOME` bridge copy, but the install-root copy is what's easy to reach `resolve_whatsapp_bridge_dir()` returns `$HERMES_HOME/scripts/whatsapp-bridge` (the copy with dependencies installed). The install root also ships `/opt/hermes/scripts/whatsapp-bridge`, which has **no** `node_modules`. Running that copy fails with: ``` Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@whiskeysockets/baileys' imported from /opt/hermes/scripts/whatsapp-bridge/bridge.js ``` Confusingly, the wizard reports *\"Bridge dependencies already installed\"* — true of the resolved copy, not the install-root one. Either not shipping the dependency-less copy, or having it fail with a pointer to `resolve_whatsapp_bridge_dir()`, would save debugging time. --- ## Impact Symptom triad worth recognising: `connected` fired and named the device · hundreds of `pre-key-*.json` files present · `creds.json` 0 bytes and status still `enabled but not paired`. Since the gateway refuses to start while a platform is enabled-but-unpaired, users hit **Restart Gateway** repeatedly against a precondition no restart can satisfy. ## Workaround 1. Resolve the true session dir: `python3 -c \"from hermes_constants import get_hermes_dir; print(get_hermes_dir('platforms/whatsapp/session','whatsapp/session'))\"` 2. Back up, then wipe it, and `rmdir` any empty legacy stub so resolution is unambiguous. 3. Pair via the **dashboard** (it uses the same resolver) or drive the bridge directly with `--pair-only --pair-json`, letting it exit on its own after `connected`. 4. Confirm `creds.json` is non-zero and parses as JSON before starting the gateway.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85391",
          "createdAt": "2026-08-13T15:26:56Z",
          "updatedAt": "2026-08-13T15:50:24Z",
          "timestamp": "2026-08-13T15:50:24Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [],
          "author": "dashgin",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:22bd99ad09deddc7c715",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85403",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85403",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(whatsapp): pair into the session dir the gateway reads, and reject empty creds",
          "text": "## What Fixes two WhatsApp pairing bugs from #85391 that together strand users in a restart loop against an \"enabled but not paired\" precondition no restart can satisfy. Fixes #85391 (Bugs 1 & 2). Bug 3 in the issue is a minor packaging/UX note (dependency-less bridge copy under the install root) and is left for a follow-up. ## Bug 1 — wizard paired into a directory nothing reads The gateway adapter and the dashboard both resolve the session dir via: ```python get_hermes_dir(\"platforms/whatsapp/session\", \"whatsapp/session\") ``` but the `hermes whatsapp` wizard hard-coded the **legacy** path: ```python session_dir = get_hermes_home() / \"whatsapp\" / \"session\" ``` `get_hermes_dir(new, old)` returns the legacy path *only while it exists with content* (an empty stub does not count — see the `#27602` note in `hermes_constants.py`). So the two agree only while `whatsapp/session` is populated. The moment it's emptied — a natural \"start clean\" move — the wizard keeps writing to `whatsapp/session` while the gateway reads `platforms/whatsapp/session`. Re-pairing then has no effect: the gateway replays the old (revoked) creds and loops on `Logged out`. **Fix:** route the wizard through a shared `_whatsapp_session_path()` helper that mirrors the reader's resolver, so writer and reader can never diverge. ## Bug 2 — a 0-byte `creds.json` counted as \"paired\" The start preflight was existence-only: ```python creds_path = self._session_path / \"creds.json\" if not creds_path.exists(): ``` In `--pair-only` mode the bridge writes `creds.json` **after** emitting the `connected` event and then exits on its own, so a supervisor that terminates the bridge on `connected` truncates the file. The reporter saw exactly this: `connected` fired, **826** key files were written, yet `creds.json` was **0 bytes** — and existence-only passed, so the gateway proceeded on unusable credentials. **Fix:** add `_has_valid_creds()` — non-empty, parseable JSON, carrying the Baileys `noiseKey`/`signedIdentityKey` — and gate `start()` on it instead of bare existence. The \"not paired\" log line now says *missing, empty, or truncated*. ## Tests - `tests/gateway/test_whatsapp_creds_validation.py` — new: missing / 0-byte / truncated / non-object / keyless creds all rejected; a valid creds dict accepted. - `tests/hermes_cli/test_whatsapp_session_path.py` — new: fresh install and an **empty legacy stub** both resolve to the consolidated path; a populated legacy dir is still honored; and the wizard agrees with the adapter's default. - Existing `test_whatsapp_connect.py` / `test_whatsapp_stale_bridge.py` fixtures updated to write valid creds (and bypass the preflight where they exercise `connect()`'s downstream paths), since the preflight now validates content, not just existence. Preflight (`windows-footguns`, `ruff`, affected tests) green vs `upstream/main`. Pre-existing bare `write_text` calls in the two touched test files were given `encoding=\"utf-8\"` to satisfy the windows-footgun gate. The known arm64-fork-Docker CI job failure is unrelated to this change.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85403",
          "createdAt": "2026-08-13T15:50:08Z",
          "updatedAt": "2026-08-13T15:50:08Z",
          "timestamp": "2026-08-13T15:50:08Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "PRATHAMESH75",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:30fe30190e938e39f9a2",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85388",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85388",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(pricing): apply DeepSeek peak/off-peak rate card (#72662)",
          "text": "## Summary Implements #72662: DeepSeek's official rate card switches to peak/off-peak billing at **2026-08-16T16:00 UTC** (announced on https://api-docs.deepseek.com/quick_start/pricing): - Peak hours: **01:00–04:00 and 06:00–10:00 UTC** (all other hours off-peak) - Peak = 2× off-peak on every billing item (cache-hit input, cache-miss input, output) ## Changes ### `agent/usage_pricing.py` - **Snapshot updated to the 2026-08-16 OFF-PEAK rates**: deepseek-v4-flash $0.007 / $0.22 / $0.66 per 1M (cache-hit / cache-miss / output), deepseek-v4-pro $0.022 / $0.66 / $1.98. `pricing_version` bumped to `deepseek-pricing-2026-08-16`. The deepseek-chat / deepseek-reasoner aliases keep sharing flash's rates. - **`estimate_usage_cost` peak/off-peak selection**: before the switchover instant the legacy flat card (`deepseek-pricing-2026-07`) applies; after it, calls during peak hours bill at 2× the off-peak rates and the `CostResult` carries a \"peak-hour rate applied\" note. Peak windows are read as half-open intervals → hours 1, 2, 3 and 6, 7, 8, 9. - The legacy flat card lives in a small temporary module dict (`_DEEPSEEK_LEGACY_FLAT_RATES`) so estimates stay accurate during the transition window; it is marked for removal after the switchover. ### `tests/agent/test_usage_pricing.py` 7 new tests: - peak-hour boundary matrix (hours 0–11, 23) incl. exact window edges (00:59:59 → 01:00:00, 03:59:59 → 04:00:00, 09:59:59 → 10:00:00) - official-table amounts for both models (1M in + 1M out: flash $0.88 off-peak / $1.76 peak; pro $2.64 / $5.28) - cache-read tokens scale at peak ($0.007 → $0.014 flash) - pre-switchover legacy flat rates (incl. a would-be peak hour) - switchover instant boundary (15:59:59Z legacy, 16:00:00Z new card) - non-DeepSeek providers unaffected during peak hours - updated `test_deepseek_v4_pro_pricing_entry_exists` to the new card ## Testing - `tests/agent/test_usage_pricing.py`: 20 passed - All usage_pricing consumers (insights, model cost guard, usage command, MoA loop, context engine): 100 passed total - A large combined batch run shows ~120 failures in unrelated suites (title generator, secret-scope migration, codex transport, etc.) that pass in isolation on both main and this branch — known order-dependent batch interference in this repo, unrelated to this change. ## Notes - **Boundary semantics**: peak windows are treated as half-open [01:00, 04:00) and [06:00, 10:00) UTC, matching the 09:00–12:00 / 14:00–18:00 Beijing framing (clean 12:00–14:00 lunch gap). Hour 4 (04:00–05:00) is off-peak. - **Transition window**: rates are selected at call time (post-request), matching DeepSeek's per-request timestamp billing. Before 2026-08-16T16:00Z the legacy flat card is used, so estimates don't overstate during the transition. - Supersedes the earlier attempt #72756, whose 2×-on-current-rates approach no longer matches DeepSeek's published pricing (the official off-peak rates themselves differ from the current flat card). - **Credit**: feature request and the peak-window UTC mapping are from @dominicelayda's issue #72662; @webtecnica's earlier attempt (#72756) established the multiplier approach and surfaced the need for the official rate card. This PR is a fresh implementation against the official card. Closes #72662",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85388",
          "createdAt": "2026-08-13T15:24:17Z",
          "updatedAt": "2026-08-13T15:48:41Z",
          "timestamp": "2026-08-13T15:48:41Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [],
          "author": "DavidMetcalfe",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:6fb2e15716efe74dd0f7",
        "signalId": "github:NousResearch/hermes-agent:issue:85399",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85399",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Opened in error",
          "text": "Opened in error. Please ignore.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85399",
          "createdAt": "2026-08-13T15:44:55Z",
          "updatedAt": "2026-08-13T15:48:10Z",
          "timestamp": "2026-08-13T15:48:10Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "flong-coatue",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:fa84398f6676c61d9352",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85352",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85352",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(guardrails): carry no-progress loops across duplicate results and turns",
          "text": "## Summary Fix two gaps in the tool-loop no-progress guardrail that let repeated successful bookkeeping calls survive indefinitely: 1. Count repeated identical **tool signatures**, not repeated result hashes. - Context compression can replace a repeated tool body with `[Duplicate tool output — same content as a more recent call]`. - Some tools intentionally return a dedup payload such as `{\"status\":\"unchanged\",\"content_returned\":false}`. - Both are the same no-progress call with different result hashes, so hash equality is the wrong counter. 2. Preserve `_no_progress` across `reset_for_turn()`. - `reset_for_turn()` runs at the start of each `run_conversation`, including after context compaction / a new user message. - Clearing no-progress state there lets a repeated bookkeeping call restart its streak at `1` after compaction and never reach `no_progress_block_after`. - Stale streaks are now cleared when the world actually moves: a file mutation whose result proves the write landed via `file_mutation_result_landed()`. 3. Normalize conservative housekeeping args before signature hashing. - `todo`: ignore `merge` and sort todos by `id`. - `skill_view`: drop `file_path: null`. - `read_file`: drop default `offset: 1` / `limit: 2000`. - Non-housekeeping tools keep raw args; `terminal` is intentionally not normalized. ## Relationship to existing guardrail PRs This is intentionally narrow and complements the existing open guardrail work: - #56929 correctly identifies landed file mutations as the progress signal. This PR keeps that signal but separates it from `reset_for_turn()`, because `_no_progress` must survive turn rollover. - #57816 explicitly tests that `reset_for_turn()` clears bounded guardrail state, which is the opposite invariant needed for compaction-crossing no-progress loops. - #58221 tracks successful mutating repetitions, but still keys the streak on result-hash equality, so duplicate-output stubs / dedup payloads still reset the count. - #60661 covers an adjacent alternating-success bypass, but this PR covers cosmetic argument jitter for declarative housekeeping calls. `MUTATING_TOOL_NAMES` is not used as proof of progress here: bookkeeping tools such as `todo` live in that set but do not move the world. ## Verification Targeted tests: ```text python3 -m pytest tests/agent/test_tool_guardrails.py -q Pytest: 10 passed ``` Adjacent tests: ```text python3 -m pytest tests/agent/test_tool_guardrails.py tests/agent/test_tool_result_classification.py tests/agent/test_turn_context.py tests/cli/test_cli_interrupt_ack_race.py -q -p no:cacheprovider Pytest: 32 passed ``` Lint: ```text python -m ruff check . All checks passed! ``` Mutation checks performed locally: - restoring result-hash equality counting made the duplicate-stub regression fail - restoring the per-turn `_no_progress` wipe made the turn-boundary regression fail - neutering the landed-write clear made the turn-boundary regression fail after a real write - hashing raw canonical args instead of normalized housekeeping args made both arg-jitter regressions fail Environment note: the earlier local `pydantic_core._pydantic_core` import failure is fixed. The installed venv now imports `pydantic_core 2.46.4`, `python -m pip check` reports no broken requirements, and the guardrail/classification tests pass under that venv.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85352",
          "createdAt": "2026-08-13T14:22:07Z",
          "updatedAt": "2026-08-13T15:47:39Z",
          "timestamp": "2026-08-13T15:47:39Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "P3"
          ],
          "author": "patrykkopycinski",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:2e4c7cdf3aac1b7d8f20",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85402",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85402",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(repo-governance): add D1 repository identity validation",
          "text": "## Summary - Add D1 canonical repository identity validation. - Add Darwin exact-child repository observation. - Add policy vectors and bounded regression tests. ## Validation - 4/4 final-byte tests passed. - 10/10 targeted tests passed. - 79/79 bounded suite passed twice. ## Scope - 12 files added with 2,610 insertions. - Head commit: 52c641b9dbf2be60bfa377c225b536c74e0df7c5. ## Governance - Open as a draft; do not merge automatically. - External mutation exclusivity and same-SHA ABA absence remain unproven.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85402",
          "createdAt": "2026-08-13T15:47:10Z",
          "updatedAt": "2026-08-13T15:47:10Z",
          "timestamp": "2026-08-13T15:47:10Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "chineseclouds",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ca75f7004a98e1ee5cc0",
        "signalId": "github:NousResearch/hermes-agent:issue:83390",
        "event": "changed",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [
          "updatedAt",
          "metrics",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:83390",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Auxiliary title_generation fails on DeepSeek: HTTP 400 \"This response_format type is unavailable now\"",
          "text": "# Auxiliary title_generation fails on DeepSeek: HTTP 400 \"This response_format type is unavailable now\" ## Summary When `auxiliary.title_generation` is configured with `provider: auto` (or explicitly routed to DeepSeek as the main model), the auxiliary title generation task fails with an HTTP 400 error from the DeepSeek API: ``` This response_format type is unavailable now ``` Hermes Agent v0.20.0. Environment: WSL, DeepSeek as primary model provider. ## Root cause 1. `agent/title_generator.py` (lines ~81–93) hardcodes a `json_schema` response format for title generation: ```python _TITLE_RESPONSE_FORMAT = { \"type\": \"json_schema\", \"json_schema\": {...}, ... } ``` 2. At line ~378, `response_format` is unconditionally passed to the underlying chat completions call: ```python extra_body={\"response_format\": ...} ``` 3. The DeepSeek API does **not** support the `json_schema` response format type — it only supports `text` and `json_object`. Sending `{\"type\": \"json_schema\", ...}` returns HTTP 400 `This response_format type is unavailable now`. ## Why the existing fallback logic does not save us - The exception-handling chain in `auxiliary_client.py` only retries/degrades for: HTTP 402 (insufficient balance), transient errors, `temperature` / `max_tokens` parameter issues. - A `400 invalid_request_error` caused by the unsupported `response_format` is **not** handled there, so it propagates straight up and the title-generation task fails outright. - The \"loose JSON scan\" fallback only runs **after** a successful HTTP response; it cannot help when the request itself is rejected with 400. ## Reproduction ```bash curl https://api.deepseek.com/v1/chat/completions \\ -H \"Authorization: Bearer $DEEPSEEK_API_KEY\" \\ -H \"Content-Type: application/json\" \\ -d '{ \"model\": \"deepseek-chat\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}], \"response_format\": { \"type\": \"json_schema\", \"json_schema\": {\"name\": \"x\", \"schema\": {\"type\": \"object\", \"properties\": {}}} } }' ``` Response: `400 Bad Request` — `This response_format type is unavailable now`. ## Expected behavior / suggested fix - Detect (or allow configuration of) providers that do not support `json_schema` and degrade gracefully: - Retry the request **without** `response_format`, or - Fall back to `{\"type\": \"json_object\"}` **and** ensure the prompt contains the literal word `json` (DeepSeek requires the prompt to mention \"json\" for `json_object` mode). - Apply this capability detection/degradation to **all** auxiliary tasks that currently hardcode `json_schema` (title generation, summarization, etc.), not just title generation. - Alternatively, catch `400 invalid_request_error` mentioning `response_format` in `auxiliary_client.py`'s retry chain and retry with a degraded format. ## Environment - Hermes Agent v0.20.0 - Provider: DeepSeek (`deepseek-chat` / `deepseek-reasoner`) - OS: WSL (Windows Subsystem for Linux) Thanks for the great project!",
          "url": "https://github.com/NousResearch/hermes-agent/issues/83390",
          "createdAt": "2026-08-10T18:47:39Z",
          "updatedAt": "2026-08-13T15:47:03Z",
          "timestamp": "2026-08-13T15:47:03Z",
          "metrics": {
            "reactions": 2,
            "comments": 7
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/deepseek",
            "P3"
          ],
          "author": "scottchan60-netizen",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:cd00ee0cc3334b5e878f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85401",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85401",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(title): support deterministic-only auto titles",
          "text": "Allows automatic titles to use the deterministic path without enabling later model-based title upgrades. The default behavior remains unchanged.\\n\\nValidation: canonical scripts/run_tests.sh; 35 title tests passed.\\n\\nRelated: #85194",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85401",
          "createdAt": "2026-08-13T15:46:10Z",
          "updatedAt": "2026-08-13T15:46:10Z",
          "timestamp": "2026-08-13T15:46:10Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "lightcloud00",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:7fd4c28bccec06eb4df2",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85400",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85400",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(startup): coordinate one-shot session context",
          "text": "Adds a single bounded StartupCoordinator path before the first prompt build. Startup context is attached once to the first user sidecar, receipts are immutable and redacted, duplicate injectors are rejected, and system-prompt bytes remain stable. Cold diagnostic callers avoid loading optional plugin and MCP runtimes.\\n\\nValidation: canonical scripts/run_tests.sh; 107 focused tests passed.\\n\\nLocal activation remains separate from upstream merge.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85400",
          "createdAt": "2026-08-13T15:46:08Z",
          "updatedAt": "2026-08-13T15:46:08Z",
          "timestamp": "2026-08-13T15:46:08Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "lightcloud00",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ce684683aaf5b61a7d73",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85353",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85353",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(read_file): dedup key uses realpath to avoid cross-path false positives (#85333)",
          "text": "## Fix for #85333 ### Problem The dedup cache keyed on without canonicalizing through . When the same file content exists at two different paths (git worktrees, clones, symlinked directories), both paths could resolve to the same normalized string, causing a false-positive dedup stub on the second path — the tool returns with instead of the actual file content. ### Fix Use for the dedup key identity so physically different files at different paths never dedup against each other, even when their content is identical. Falls back to the normalized path when is unavailable (remote backends, missing files). Also updated to use the same identity so / correctly evict dedup entries created by reads through symlinked/worktree paths. ### Changes - : - now uses instead of - resolves to before matching dedup keys - : - Added regression test ### Verification - All 4 tests pass (including the new regression test) - Pre-existing test failures on Windows (device path tests, mtime granularity test) are unrelated to this change - See issue #85333 for full reproduction details",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85353",
          "createdAt": "2026-08-13T14:22:12Z",
          "updatedAt": "2026-08-13T15:45:15Z",
          "timestamp": "2026-08-13T15:45:15Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "tool/file",
            "P2"
          ],
          "author": "andrexibiza",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:569639441242b12f8197",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85351",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85351",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(browser): add authenticated extension controller",
          "text": "## What does this PR do? Adds an opt-in browser-extension controller lane so Hermes can route existing `browser_*` tools to the exact authenticated browser session that opened the conversation. The implementation has two layers: 1. A transport-neutral broker with principal/profile/session/controller/browser-profile/transport scoping, one-shot WebSocket tickets, capability allowlisting, command lifecycle, cancellation, timeout, reconnect, and owner-scoped detach. 2. Request-bound tool routing for the seven existing Browser Use registry schemas. Generic requests preserve the existing backend; once the gateway binds controller identity, the extension lane is authoritative and fails closed if that exact controller disappears or cannot execute the action. Local API and authenticated dashboard/cloud transports use the same protocol and real-action allowlist: - `browser_navigate` - `browser_snapshot` - `browser_screenshot` - `browser_click` - `browser_type` - `browser_press` - `browser_scroll` - `browser_back` - `browser_list_tabs` - `browser_activate_tab` Raw CDP, arbitrary evaluation, console, file upload, vision, and image extraction are not admitted. ### Reconnect and detach semantics Unexpected transport loss is recoverable: the controller is hidden from new dispatch while already-started commands remain pending until their original deadline. A reconnect with the same stable identity refreshes the transport and negotiated capabilities, flushes deferred cancels before new work, and can complete the original command. An authenticated `browser.controller.detach` frame/RPC remains immediately terminal. A different controller id or browser profile in the same authenticated session lane is also a hard replacement: the old controller's pending work is cancelled before the successor becomes routable. Inbound heartbeat, result, cancel, and detach frames are admitted only from the current owner. Slow but live cross-thread WebSocket writes remain in flight under the broker command deadline instead of being misclassified as failed sends. Real send failures still surface immediately. ### Existing user compatibility - `browser.extension_control.enabled` defaults to `false`. - Feature off: the broker is never queried and the current Browser Use path is unchanged. - Feature on with no server-bound controller identity: the current backend still handles the tool. - Feature on with bound controller identity: missing, ambiguous, disconnected, or incapable controllers fail closed instead of switching browsers. - Controller tools are exposed only inside the matching request/session context; availability is never cached process-wide. - Once a request is bound to the controller lane, failures do not silently jump to another browser. - Server-bound browser-control identity variables are excluded from shared shell snapshots, preventing cross-session routing metadata persistence. ### Tests A real `browser_snapshot` journey covers the aiohttp route table, Bearer-authenticated registration, one-shot subprotocol ticket, controller WebSocket command/result exchange, router serialization, and zero calls to the legacy backend. Reconnect coverage includes socket close and dashboard transport loss, same-identity capability renegotiation, deferred-cancel ordering and bounds, stale-owner rejection, explicit detach, different-identity hard replacement, timeout, late completion, and slow WebSocket send waits. The diff is 23 files with 4,873 additions; 2,553 additions are tests and 101 are docs/config. Resolves the Chrome-extension backend portion of #84000. This uses the existing authenticated API/dashboard transports instead of adding a second native-messaging server inside Hermes. ## Related issue(s) #84000 ## Checklist - [x] I have performed a self-review of my changes - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have added or updated documentation as needed - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have run code quality checks locally and they pass - [x] I have not committed sensitive data or credentials - [x] My commit messages are clear and descriptive - [x] I have credited co-authors where applicable - [x] Any generated or vendored files are reproducible and justified in the PR description ## Testing evidence - Controller/broker/API/cloud/router plus snapshot-identity and bound-route authority regressions: **95 passed, 1 POSIX-only integration skipped on Windows** - Registry/cache/model/API compatibility: **93 passed** - RED → GREEN: 6 strict admission failures → all green - RED → GREEN: real `browser_snapshot` WebSocket journey failed when the action was removed, then passed with **0 legacy-backend calls** - RED → GREEN: 4 reconnect lifecycle contracts failed on the old broker, then passed - RED → GREEN: stale-scope cancel, completed-send `TimeoutError`, and zombie-controller replacement regressions - RED → GREEN: two bound-controller authority cases failed by invoking legacy fallback, then passed fail-closed; a real schema-build → disconnect → dispatch regression proves zero fallback calls - First public CI head exposed `HERMES_BROWSER_CONTROL_*` snapshot exclusion drift; reproduced locally and fixed in the fourth commit - `ruff check` on every touched Python module/test: clean - `compileall` on every touched Python module/test: clean - Docusaurus English production build: successful (only the repo's pre-existing `/docs/llms.txt` and `/docs/llms-full.txt` root-page warnings) - Config example parses with the feature disabled - GitNexus change analysis: low risk, no affected existing process flow - Added-line secret scan and `git diff --check`: clean ## Additional context The feature is deliberately opt-in and fail-closed on identity/capability mismatch. Registration validates protocol version `1` strictly (booleans are rejected), requires at least one permitted capability, and uses a 30-second one-shot ticket carried only in `Sec-WebSocket-Protocol`. Query-string tickets are rejected. This branch is based on current `main`. The first CI head's only failing Python slice was the new browser-control ContextVars missing from shared terminal snapshot exclusions; the corrected head includes that regression fix.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85351",
          "createdAt": "2026-08-13T14:21:02Z",
          "updatedAt": "2026-08-13T15:45:08Z",
          "timestamp": "2026-08-13T15:45:08Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "comp/gateway",
            "comp/tools",
            "comp/tui",
            "tool/browser",
            "area/auth",
            "area/config",
            "P3",
            "needs-decision",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery",
            "sweeper:risk-security-boundary"
          ],
          "author": "abundantbeing",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:170967aeefd1571985a7",
        "signalId": "github:NousResearch/hermes-agent:pull_request:71849",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:71849",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(tui): add exact code block copying",
          "text": "\"Addition of CopyBlocks I got tired of manually formatting the code block commands Hermes writes. This feature preserves raw fenced content and gives exact-copy blocks in both TUI and CLI frontends. ## What it does ### TUI - Clickable copy controls on every fenced code block - Copies raw content between fences — no box-drawing, no decorations - /cc: single block copies immediately, multiple shows numbered list - Backward search through conversation history - Rejects malformed args like /cc 2abc - Excludes unclosed/streaming fences ### CLI - /copy-code and /cc commands matching TUI behavior - Backward search, numbered list preview, whitespace-accurate clipboard ## Testing - 1421 TUI tests pass, 32 Python tests pass, lint clean, typecheck clean\" ## Infrastructure acknowledgment CopyBlox was independently designed and implemented. While completing and validating this contribution, I used model-inference access provided by [[FreeInference.org](https://freeinference.org/)](https://freeinference.org/), following my report of a caching issue affecting one of its Qwen deployments. For clarity, FreeInference did not commission, direct, review, or pay me for this work. This is not a sponsored contribution or an endorsement by FreeInference, Nous Research, or the Hermes Agent project. I am acknowledging FreeInference because access to capable inference infrastructure can enable meaningful open-source work by developers and researchers who do not have the hardware or budget to run these models themselves. Organizations able to provide GPU capacity, hardware, cloud credits, research funding, or other infrastructure support should consider supporting the FreeInference project so that it can continue making these resources available for open-source development, research, and education.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/71849",
          "createdAt": "2026-07-26T08:24:39Z",
          "updatedAt": "2026-08-13T15:42:09Z",
          "timestamp": "2026-08-13T15:42:09Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "comp/tui",
            "P3",
            "sweeper:risk-compatibility",
            "sweeper:risk-platform-windows",
            "sweeper:blast-moderate"
          ],
          "author": "B-A-M-N",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:38c7f229cf96f8c7e02c",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85395",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85395",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(kanban): color-code cards by project",
          "text": "## Summary - attribute dashboard tasks to first-class projects using explicit id, unique path, then conservative metadata matching - add project badges, a blocked/clear project status strip, project filtering, and project-aware search - add focused backend and bundle regression coverage ## Verification - `python -m pytest tests/plugins/test_kanban_dashboard_plugin.py -q -k \"not test_home_channels_lists_only_platforms_with_home\"` (22 passed; excluded known environment-sensitive Photon home failure) - `python -m pytest tests/hermes_cli/test_kanban_board_project.py -q` (4 passed) - `python -m py_compile plugins/kanban/dashboard/plugin_api.py` - `node --check plugins/kanban/dashboard/dist/index.js` - independent blocker-only review: PASS",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85395",
          "createdAt": "2026-08-13T15:35:20Z",
          "updatedAt": "2026-08-13T15:41:42Z",
          "timestamp": "2026-08-13T15:41:42Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "kyan12",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:f1d814c81a10669acf30",
        "signalId": "github:NousResearch/hermes-agent:issue:85381",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85381",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: Telegram MarkdownV2 fenced-code regex over-matches inline triple backticks, corrupts content and drops rich formatting",
          "text": "### Bug Description The fenced-code protection regex in `plugins/platforms/telegram/adapter.py` (around line 8022, the `_protect_fenced` step before MarkdownV2 escaping) is too permissive: `r'(```(?:[^\\n]*\\n)?[\\s\\S]*?```)'` matches **inline** triple backticks anywhere in the text, not just standalone fenced blocks. When a message contains inline `` ``` `` sequences (e.g. the model discusses backtick syntax, paths, or inline code written as triple backticks), the matcher treats the inline span as a multi-line code block; `_protect_fenced` then re-splits it on `\\n` (which is absent), escapes the backticks/backslashes, and emits malformed MarkdownV2 entities (e.g. `\\`` escape sequences and unbalanced `` ` ``). Telegram then rejects the message with `MarkdownV2 parse failed`, and Hermes falls back to plain text — dropping all rich formatting (bold, tables, links) for the whole message. Real gateway log evidence (2026-08-13): ``` WARNING hermes_plugins.telegram_platform.adapter: [Telegram] MarkdownV2 parse failed, falling back to plain text: Can't parse entities: can't find end of pre entity at byte offset 922 WARNING hermes_plugins.telegram_platform.adapter: [Telegram] MarkdownV2 edit failed, falling back to plain text: Can't parse entities: can't find end of pre entity at byte offset 922 ``` `can't find end of pre entity` is the Telegram-side signature of a broken `<pre>` entity — exactly what the over-matching + naive `\\n` split produces. ### Steps to Reproduce 1. Configure Hermes Telegram gateway (any client; observed on Linux server + Telegram mobile/desktop). 2. Ask the agent something whose reply contains **inline** triple backticks on a single line, e.g. content like: `the syntax is ```like this``` inline` (or a path with backslashes next to inline ```). 3. Observe the gateway log: `MarkdownV2 parse failed ... can't find end of pre entity` or `character '...' is reserved`. 4. Observe the delivered message arrives as plain text with all markdown formatting stripped. Minimal regex repro (Python 3.11): ```python import re OLD = r'(```(?:[^\\n]*\\n)?[\\s\\S]*?```)' NEW = r'(?m)(^[ ]{0,3}```[^\\n]*\\n[\\s\\S]*?^[ ]{0,3}```[ \\t]*$)' text = 'code ```inline``` here' print(re.findall(OLD, text)) # ['```inline```'] <- over-match print(re.findall(NEW, text)) # [] <- correct ``` ### Expected Behavior - Only **standalone** fenced code blocks (``` on its own line, opening and closing) should be matched and protected. - Inline triple-backtick spans should be left untouched (or treated as inline code) so Telegram's MarkdownV2 parser receives valid entities and rich formatting (bold, tables, links) is preserved. ### Actual Behavior - Inline ``` spans are consumed by the fenced-block regex, then mangled by `_protect_fenced` (naive `\\n` split + `\\` / backtick escaping), producing unbalanced backticks / illegal escape sequences. - Telegram returns `MarkdownV2 parse failed`; Hermes falls back to plain text and the entire message loses formatting. ### Affected Component - Gateway (Telegram/Discord/Slack/WhatsApp) ### Messaging Platform (if gateway-related) - Telegram ### Debug Report I can provide full logs on request; key evidence is quoted above from `/root/.hermes/logs/gateway.log` (4 `MarkdownV2 parse failed` events, 2 with `can't find end of pre entity`). `hermes debug share` output can be attached if needed. ### Operating System Linux NAS 6.18.18 (Debian-based, TRIM/fnOS NAS) ### Python Version 3.11.2 ### Hermes Version Hermes Agent v0.20.0 (2026.8.3) · upstream fa83af3f ### Additional Logs / Traceback (optional) ``` 2026-08-13 17:06:40,903 WARNING hermes_plugins.telegram_platform.adapter: [Telegram] MarkdownV2 parse failed, falling back to plain text: Can't parse entities: can't find end of pre entity at byte offset 922 2026-08-13 17:06:41,580 WARNING hermes_plugins.telegram_platform.adapter: [Telegram] MarkdownV2 edit failed, falling back to plain text: Can't parse entities: can't find end of pre entity at byte offset 922 ``` ### Root Cause Analysis (optional) The bug is in `plugins/platforms/telegram/adapter.py`, the fenced-code protection step before MarkdownV2 escaping: - Regex `r'(```(?:[^\\n]*\\n)?[\\s\\S]*?```)'` matches ANY ``` pair, including single-line inline occurrences (the `(?:[^\\n]*\\n)?` prefix makes the `\\n` optional). - `_protect_fenced` assumes a multi-line block: it splits on `\\n` (`open_end = raw.index('\\n') + 1 if '\\n' in raw[3:] else 3`), then escapes `\\` and `` ` `` in the \"body\". For inline matches there is no body/close split as intended, so backticks get escaped into `\\`` and the entity becomes unbalanced. ### Proposed Fix (optional) Anchoring the regex to line starts/stops fixes it — the pattern I verified locally: ```python r'(?m)(^[ ]{0,3}```[^\\n]*\\n[\\s\\S]*?^[ ]{0,3}```[ \\t]*$)' ``` This only matches fences that start at the beginning of a line (0-3 spaces indent) and close on their own line, leaving inline triple backticks untouched. Verified against: normal multi-line code blocks (still matched), inline ``` spans (no longer matched), mixed inline+block content (no corruption). ### Are you willing to submit a PR for this? - [x] I'd like to fix this myself and submit a PR",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85381",
          "createdAt": "2026-08-13T15:11:19Z",
          "updatedAt": "2026-08-13T15:40:28Z",
          "timestamp": "2026-08-13T15:40:28Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "platform/telegram",
            "P3"
          ],
          "author": "jhdh487",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c635cda7cd523889091f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85398",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85398",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(telegram): anchor fenced-code regex so inline triple backticks aren't mangled",
          "text": "## What Anchor the Telegram MarkdownV2 fenced-code protection regex to line starts so it only matches **standalone** fenced code blocks, and stop it from swallowing **inline** triple-backtick spans. Fixes #85381. ## Why In `plugins/platforms/telegram/adapter.py`, `format_message()` protects fenced code blocks before MarkdownV2 escaping with: ```python r'(```(?:[^\\n]*\\n)?[\\s\\S]*?```)' ``` The `(?:[^\\n]*\\n)?` makes the newline after the opening fence **optional**, so the pattern also matches inline triple backticks on a single line (e.g. a reply containing *\"the syntax is ```like this``` inline\"*). `_protect_fenced` then treats that inline span as a `pre` block, splits on a `\\n` that isn't there, and escapes the backticks into an **unbalanced** entity. Telegram rejects the whole message: ``` [Telegram] MarkdownV2 parse failed, falling back to plain text: Can't parse entities: can't find end of pre entity at byte offset 922 ``` Hermes then falls back to plain text, dropping **all** rich formatting (bold, tables, links) for the entire message. ## Fix - Anchor the pattern to line starts (`(?m)`, 0–3 spaces of indent), requiring the opening fence to end with a newline and the closing fence to sit on its own line: ```python r'(?m)^([ ]{0,3}`{3}[^\\n]*\\n)([\\s\\S]*?)(^[ ]{0,3}`{3})[ \\t]*$' ``` Inline triple-backtick spans no longer match; their backticks fall through to normal escaping and render as valid literal backticks instead of a broken `pre`. - Rewrite `_protect_fenced` to use the three explicit capture groups (opening line / body / closing fence) instead of the naive `raw[:-3]` slice. As a side benefit this also fixes a **doubled closing fence** when the closing line carried trailing whitespace (the old `[:-3]` stripped the trailing spaces, not the backticks). ## Tests Added to `tests/gateway/test_telegram_format.py::TestFormatMessageCodeBlocks`: - `test_inline_triple_backticks_not_treated_as_fence` — inline `` ```…``` `` no longer emits a raw fence. - `test_fence_and_inline_backticks_mixed` — a real block is still protected while a trailing inline span in the same message is not turned into a second, unbalanced fence. - `test_fence_with_trailing_whitespace_on_close` — closing fence with trailing spaces isn't doubled. Existing fenced/inline-code tests continue to pass (45 passed). Preflight (`windows-footguns`, `ruff`, affected tests) green vs `upstream/main`. The known arm64-fork-Docker CI job failure is unrelated to this change.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85398",
          "createdAt": "2026-08-13T15:40:13Z",
          "updatedAt": "2026-08-13T15:40:13Z",
          "timestamp": "2026-08-13T15:40:13Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "PRATHAMESH75",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:2b9c16c97325d05fde84",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85349",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85349",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(compress): do not publish micro-compaction when DB sync fails",
          "text": "## What does this PR do? Micro-compaction spliced the rolling summary into the in-memory transcript, then called `_sync_micro_compact_to_db` and swallowed any exception. Resume then loaded the still-active originals on top of the already-compacted list. Rehydrated marker text and generated micro summaries also skipped the batch path's strict redaction, so a legacy secret in an old marker could re-enter the aux prompt and the session DB. `_sync_micro_compact_to_db` now returns success/failure. A failed persist restores the previous cursor and summary and returns the pre-splice messages. Rehydrated marker text and generated micro summaries go through `_redact_compaction_text` before they are stored or sent. ## Related Issue Fixes #84723 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `_sync_micro_compact_to_db` returns `True` on success / no DB, `False` on write exception. - Absorb path rolls back cursor/summary and keeps the original list when persist fails. - Rehydrate and generated micro summaries are redacted before prompt/DB. - Tests: persist-fail keeps the pre-splice transcript; leaked summarizer output and a legacy marker secret do not survive publish or the aux prompt. ## How to Test 1. On `origin/main`, `test_failed_db_sync_keeps_pre_splice_transcript`, `test_generated_micro_summary_is_redacted_before_publish`, and `test_rehydrated_secret_is_redacted_before_summarizer_prompt` fail. 2. `scripts/run_tests.sh tests/agent/test_micro_compaction.py tests/agent/test_compaction_redaction_boundaries.py` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — or N/A - [x] I've updated `cli-config.yaml.example` — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — or N/A - [x] I've considered cross-platform impact — or N/A - [x] I've updated tool descriptions/schemas — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85349",
          "createdAt": "2026-08-13T14:20:09Z",
          "updatedAt": "2026-08-13T15:40:12Z",
          "timestamp": "2026-08-13T15:40:12Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "P2",
            "sweeper:risk-session-state",
            "area/sessions",
            "area/compression"
          ],
          "author": "Christopher-Schulze",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:bb15fc6552a8844f07f2",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85397",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85397",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(install): fail when Node dependencies cannot install",
          "text": "## Summary - Treat the checkout-root and TUI `npm install` operations as required steps in `scripts/install.sh`. - Propagate dependency-install failures through both the monolithic installer and the `node-deps` stage, restore tracked lockfile churn, and suppress success output when installation fails. - Preserve successful installs and the existing best-effort behavior for optional browser, Browser Use, and Computer Use setup. ## Validation - Focused regression tests: 3 passed. - Nearby installer tests: 29 passed. - Installer family: 90 passed, 1 unrelated pre-existing failure, and 2 skips. - `bash -n`, Python compilation, and `git diff --check` passed. - Full `scripts/run_tests.sh` was attempted; the available Python 3.14.6 environment stopped the bounded run after environment-caused failures from missing `acp`, `python-dotenv`, and `openai` dependencies and the async pytest plugin. - CodeRabbit committed-diff review: no findings. Fixes #85297 ## Agent Disclosure - Created by: GPT-5.6 in Codex - Human looked at and manually signed the commit",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85397",
          "createdAt": "2026-08-13T15:37:51Z",
          "updatedAt": "2026-08-13T15:37:51Z",
          "timestamp": "2026-08-13T15:37:51Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "egilewski",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:249dcc8ff993d2a53245",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85373",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85373",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): surface actionable error when Nous Cloud agent returns 503 (#85335)",
          "text": "## Fix for #85335 ### Problem When a Hermes Desktop is configured to connect to a Nous-managed cloud agent (e.g. `https://ares-3009.agents.nousresearch.com`) and that backend returns **HTTP 503 Service Unavailable**, the error surfaces as a generic 'Hermes backend did not become ready: 503: ...' message with no guidance that the cloud server itself is down. The user cannot restart a Nous-managed cloud instance themselves and has no actionable recovery path. ### Fix Added two helper functions to `apps/desktop/electron/backend-health.ts`: - `isServerSideHttpError(error)` — detects HTTP 502/503/504 from backend probe errors - `isNousCloudAgentUrl(baseUrl)` — detects URLs matching `\\*.agents.nousresearch.com` In `waitForHermesReady`, when the backend URL is a Nous cloud agent and the final error is a server-side HTTP error, the function now throws a rich error message that: 1. Names the failing cloud agent hostname 2. Reports the HTTP status code 3. Suggests checking https://portal.nousresearch.com 4. Suggests switching to Local mode in Settings → Gateway 5. Provides Discord support link (discord.gg/NousResearch) 6. Includes `isCloudBackendDown` flag and `statusCode` property for renderer overlay customization ### Changes - `apps/desktop/electron/backend-health.ts`: Added `isServerSideHttpError` and `isNousCloudAgentUrl` helpers; enhanced error in `waitForHermesReady` - `apps/desktop/electron/backend-health.test.ts`: Added tests for both helpers and the enhanced error path ### Verification - Helper functions tested with tsx: 503/502/504 detected correctly; cloud URL detection works - Full `waitForHermesReady` tested: cloud 503 returns actionable message, non-cloud 503 returns generic message - Existing tests unchanged (vitest runner hangs on this environment, verified via direct tsx execution) Fixes #85335 See also: #85333 (read_file dedup bug — related PR #85353)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85373",
          "createdAt": "2026-08-13T14:54:39Z",
          "updatedAt": "2026-08-13T15:37:12Z",
          "timestamp": "2026-08-13T15:37:12Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "P2",
            "comp/desktop"
          ],
          "author": "andrexibiza",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:7349c461d41ad52d25c5",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85396",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85396",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(wake-word): support Chinese wake phrases via sherpa wenetspeech pinyin model",
          "text": "## Summary The sherpa wake-word engine hardcoded BPE tokenization (`text2token` + `bpe.model`), which only covers Latin-script phrases on the default English GigaSpeech model. The wenetspeech KWS model — sherpa-onnx's Chinese counterpart — is **pinyin-modeled** (声母/韵母 phonemes) and ships no `bpe.model`, so pointing `wake_word.sherpa.model_dir` at it crashed at listener init. ## Changes - **Model-family detection**: `_SherpaKwsEngine` now checks for `bpe.model` — BPE models keep the existing `text2token` path; pinyin models (no `bpe.model`) romanize phrases to phonemes. - **Pinyin tokenizer** (`_phrase_to_pinyin_phonemes`): splits pypinyin's toned syllables (`Style.TONE`) after the initial — `nǐ → n ǐ`, `wén → w én`, `huì → h uì` — which also fixes pypinyin's mis-timed bare finals (`úen`, `ueì`), with a `ü→u` fallback for finals the vocab spells without umlaut (`üé → ué`). - **Graceful degradation**: phrases a model cannot represent (e.g. Latin on the pinyin model) are skipped with a warning instead of failing the whole listener — so mixed-language profile routing degrades safely rather than crashing. ## Verification - Unit tests: 31 pass (5 new pinyin-tokenization tests, offline-safe via `tmp_path` + `pytest.importorskip`). - E2E against the real wenetspeech model + its bundled test wavs: all 8 official keywords recognized (`文森特卡索/周望军/朱丽楠/蒋友伯/女儿/法国/见面会/落实`) with correct phoneme output matching the official `keywords.txt` byte-for-byte. - TTS-rendered `你好妮妮` fires the listener with correct profile routing (`last_match == ('你好妮妮', 'nina')`). ## Docs New \"Chinese wake phrases (pinyin model)\" section under the sherpa engine docs with model download link and config example. ## Notes - `pypinyin` was already a `wake.sherpa` lazy-dep (sherpa_onnx's `text2token` imports it unconditionally, so no new dependency. - The wenetspeech model (~13 MB) is auto-downloadable from sherpa-onnx releases; users point at the unpacked directory. EOF )",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85396",
          "createdAt": "2026-08-13T15:35:56Z",
          "updatedAt": "2026-08-13T15:35:56Z",
          "timestamp": "2026-08-13T15:35:56Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "ruhipiano",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:7e9b3925a717707c4c6f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85394",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85394",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): guard merged assistant compaction handoffs",
          "text": "## What does this PR do? Extends the #80622 active-turn guard to one merged-carrier shape that still escaped it. When compaction merges its summary into an assistant tail message, the carrier keeps `role=\"assistant\"`, the assistant's prior prose, and any inherited `tool_calls`. If that merged carrier immediately follows a completed assistant response with `finish_reason=\"stop\"`, those preserved fields describe the completed assistant turn; they are not evidence of a fresh user request or a live tool loop. Without this guard, `reference_handoff_would_drive_next_model_call()` treats the carrier's merged prose as live content and its own stale `tool_calls` as in-flight work, allowing the reference-only handoff to drive another model call and repeat completed work. The fix is deliberately narrow: - only `classify_summary_content(...) == \"merged\"` - only an assistant-role carrier - only when the immediately preceding assistant message completed with `finish_reason=\"stop\"` - the carrier's own inherited `tool_calls` do not count as later live work - a real user turn after the carrier still proceeds - a distinct later assistant tool-call row still proceeds - a merged carrier without the completed-stop boundary remains an in-flight loop - standalone handoffs continue to use the existing guard ## Issue linkage - Follow-up to #80622 - Addresses the remaining merged-assistant-carrier leak in #42768 ## Tests RED first: - added the production-shaped merged assistant carrier - `1 failed, 18 passed` on current `main` - only `test_merged_assistant_carrier_after_completed_stop_drives` failed GREEN: - `195 passed` across the active-turn guard, compressed-summary metadata, context compressor, summary continuity, zero-user provenance, temporal anchoring, session-end state, and cross-session guard suites - `ruff` clean - `compileall` clean - `git diff --check` clean - GitNexus: 2 files, 9 symbols, 0 affected processes, low risk ## Checklist - [x] I have read the contributing guidelines - [x] This PR targets `main` - [ ] `pytest tests/ -q` passes locally - [x] Relevant docs/comments updated where needed - [x] No dependencies added - [x] The diff is limited to the guard and its regression tests I ran the complete adjacent compaction surface rather than claiming an unrelated full-suite run. The patch changes 2 files with 104 additions and 2 deletions; 85 additions are regression tests.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85394",
          "createdAt": "2026-08-13T15:34:03Z",
          "updatedAt": "2026-08-13T15:45:11Z",
          "timestamp": "2026-08-13T15:45:11Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [],
          "author": "abundantbeing",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:9e6bf9f9245a22770db1",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85334",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85334",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(transports): normalize xhigh and minimal reasoning effort levels to high and low",
          "text": "Fixes HTTP 400 error (invalid reasoning value 'xhigh') when reasoning effort is set to xhigh on OpenAI-compatible and Nous Portal endpoints. Clamps xhigh -> high and minimal -> low before assembling reasoning extra_body.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85334",
          "createdAt": "2026-08-13T13:54:13Z",
          "updatedAt": "2026-08-13T15:32:46Z",
          "timestamp": "2026-08-13T15:32:46Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/nous",
            "provider/openai",
            "P2"
          ],
          "author": "finansovazashtita-arch",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:5a8c22e9c9c8d5756db7",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85393",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85393",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cron): retry pinned dead model via fallback_providers on 402",
          "text": "## Summary Cron jobs pinned to a dead model failed with HTTP 402 for days, ignoring `fallback_providers`. The cron layer only had fallback at the resolve stage (credential-dead) but not at runtime 402 — a pinned dead model (quota exhausted / plan removed) made the job fail forever with `Fallbacks=None`. ## Change (`cron/scheduler.py`) - **Dead-model 402 detection** (+100): `_DEAD_MODEL_BILLING_PATTERNS` / `_is_dead_model_billing_error()` — cause-chain aware (checks `status_code` attr + message text), mirrors the billing buckets in `agent/error_classifier.py` so cron recognizes the same failure class the agent loop does. Disambiguates transient usage-limit (resets) from dead-model (never recovers). - **Bounded fallback re-run in `run_job`**: when the pinned model's run fails with a dead-model 402, the job re-runs once against `fallback_providers` (finite, 1 extra run per tick) — never dies for days, never overspends. - The diff refactors the `run_job` error path (335+/217-) to fold the fallback retry into the existing delivery/error handling. ## Verification - `tests/cron/test_scheduler.py`: **68 passed**. Stylistically mirrors the fallback helpers from #84008 (not merged — no conflict). Closes #85215",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85393",
          "createdAt": "2026-08-13T15:32:39Z",
          "updatedAt": "2026-08-13T15:32:39Z",
          "timestamp": "2026-08-13T15:32:39Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "webtecnica",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:36839130523c390ab743",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85324",
        "event": "changed",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [
          "title",
          "text",
          "updatedAt",
          "metrics",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85324",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): wire /learn slash command to the backend",
          "text": "## Summary Desktop's `isDesktopSlashCommand` gate rejected `/learn` because it had no row in `DESKTOP_COMMAND_SPECS`, so it never reached the TUI backend handler that already builds the prompt (`agent/learn_prompt.py::build_learn_prompt`). Add it as an `exec()` spec (`argumentMode: text`), matching the existing backend-executed commands. The desktop dispatcher now routes it through `slash.exec` -> `command.dispatch`, which the backend already handles. No client-side logic is duplicated, so the behavior inherits all upstream changes to the backend prompt builder. ## Test plan - Added a routing case asserting `/learn` resolves to `exec()` and is suggestible/runnable. - `vitest run src/lib/desktop-slash-commands.test.ts` -> 28 passed. - `tsc --noEmit -p tsconfig.json` clean. ## Notes This is intentionally scoped to `/learn` only. The same fall-through gap affects `/init` (and a few other backend commands), but those are left for a separate change to keep this PR minimal.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85324",
          "createdAt": "2026-08-13T13:43:04Z",
          "updatedAt": "2026-08-13T15:32:35Z",
          "timestamp": "2026-08-13T15:32:35Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "P3",
            "comp/desktop"
          ],
          "author": "rarf",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:d8c6b90eb775a5de0516",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84496",
        "event": "changed",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84496",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cli): resolve named custom provider credentials through the profile secret scope",
          "text": "## What does this PR do? `hermes model` → a named `custom_providers` entry lands in `_model_flow_named_custom` (`hermes_cli/model_setup_flows.py:1505`), which resolves the provider's credential and immediately probes the endpoint with it: `fetch_api_models(api_key, base_url, **fetch_kwargs)`. Both of the shapes it resolves that credential from were read scope-blind, so the value it sends as the bearer token could be a *different profile's* API key — or not a key at all. **1. `key_env` was read with a raw `os.environ.get`.** ```python if not api_key and key_env: api_key = os.environ.get(key_env, \"\") # :1527 on main ``` Under the multiplexed gateway one process serves many profiles from one `os.environ`, so this hands the current profile whatever key happens to be in the process environment. Because the resolved value is then transmitted to *this* entry's `base_url`, the failure mode is not a wrong menu — it is another profile's credential being disclosed to whatever third party that URL points at. `get_env_value` routes the read through `agent.secret_scope.get_secret`, which encodes the whole policy (scope authoritative under multiplexing; identical to the legacy `os.environ` read when multiplexing is off) and is fail-closed when multiplexing runs with no scope installed. **2. An unresolved `${VAR}` config ref was sent verbatim as the bearer token.** An entry may carry `api_key: ${MYCORP_API_KEY}` — the shape `hermes model` itself writes (`hermes_cli/main.py` sets `model[\"api_key\"] = f\"${{{custom_key_env}}}\"`). `load_config` expands those refs via `_expand_env_vars`, which by design *keeps the reference verbatim* when the variable is unset, \"so callers can detect them\". This caller did not detect it: the literal `${MYCORP_API_KEY}` is a non-empty string, so it was accepted as the credential and used as the `Authorization` value — a guaranteed 401 whose cause is invisible — and, being truthy, it also made the `key_env` fallback directly below unreachable for any entry that sets both fields. The ref is now resolved through the same scope-aware reader first, so a ref that resolves wins, a ref that does not falls through to `key_env`, and the placeholder is never transmitted. **3. …and the `${VAR}` case that is *invisible* in the expanded value.** Shape 2 above only covers the branch where the referenced variable is **unset** — which is the branch where there is no credential to leak. `_expand_env_vars` keeps the literal verbatim *only* then. When the variable **is** set, `config.py::_env_expand_match` substitutes it straight out of the process-global `os.environ`, with no scope check, and `api_key` arrives as a plain resolved string carrying no trace of the ref: | `${MYCORP_API_KEY}` | `provider_info[\"api_key\"]` after `load_config()` | placeholder guard fires? | |---|---|---| | unset | `'${MYCORP_API_KEY}'` | yes — the harmless case | | set to another profile's key | `'sk-other-profile'` | **no** — the harmful case | Nothing about that string distinguishes it from a directly-configured inline key, so the guard in shape 2 cannot see it, and the value is accepted as this provider's credential and sent as the bearer token to its `base_url`. So the very thing this PR is about — a scope-blind read reaching a third-party endpoint — survived on the config shape `hermes model` itself writes. The fix does not need a new config read: the unexpanded template is already carried on `provider_info[\"api_key_ref\"]`, the same field `_custom_provider_api_key_config_value` trusts to persist the entry. Keying the decision off the template instead of off the expanded value covers both branches with one rule. Composite templates are handled explicitly so neither direction regresses: `sk-${SUFFIX}` is not a bare ref and keeps its expanded value, while a composite the expansion could *not* resolve still contains `${` and is dropped rather than probed with. Mirrors the resolver precedence on the `/model` picker path in `hermes_cli/model_switch.py`: `${VAR}` ref > `key_env` > empty, both read through the profile secret scope. ## Related Issue No filed issue — this is a follow-on to the scope-blind-credential-reader sweep already asserted on `main`. `0569c001d08` (*\"fix(model-switch): route switch_model user-provider key reads through the secret scope\"*) converted the byte-identical shape `os.environ.get(_kenv, \"\")` on the `/model` picker path, and the helper it introduced states the remaining gap in its own docstring: `_scoped_key_env` — *\"That is the class swept in `854007d1c` for the fallback/aux key reads; the picker's `key_env` reads were not covered.\"* `get_env_value`'s docstring likewise records the class as closed — *\"this was the last scope-blind reader of the trio (#67027)\"* — so a surviving scope-blind reader on the same code path is a regression against a stated invariant, not a new feature request. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] 🔒 Security fix - [x] ✅ Tests (adding or improving test coverage) ## Changes Made - `hermes_cli/model_setup_flows.py` — in `_model_flow_named_custom`: - resolve an unresolved `${VAR}` / `${env:VAR}` `api_key` ref through `get_env_value` before the `key_env` branch, instead of sending the placeholder as the bearer token; - key that resolution off the **unexpanded** template on `provider_info[\"api_key_ref\"]`, so an `api_key` whose ref `load_config` already expanded out of the process environment is re-resolved through the scope rather than trusted; - resolve `key_env` through `get_env_value` instead of `os.environ.get`. - Ref-name normalization uses the config module's own `_env_ref_var_name`, so the `${env:VAR}` SecretRef spelling and the non-env sources (`bitwarden:`, `vault:`, …) are treated exactly as `load_config` treats them rather than by a second ad-hoc parser. - `tests/hermes_cli/test_named_custom_provider_key_scope.py` — new, 7 regressions. Shipped as 6 atomic commits: each production site separately, each followed by its tests. ### Sibling-site sweep `os.environ.get(key_env, …)` / `os.getenv(key_env, …)` across the repo, production files only: | site | status | |---|---| | `hermes_cli/model_switch.py` (picker) | already converted upstream in `0569c001d08` | | `hermes_cli/model_setup_flows.py:1527` | **fixed here** — the last raw one in this module | | `acp_adapter/server.py:155` | same cause, deliberately not changed — see below | Within `hermes_cli/model_setup_flows.py` this was the only raw `key_env` read. The other four all already use the scope-aware reader and all feed the same `fetch_api_models` call: - `:2850` `api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else \"\")` - `:2864` same shape - `:2879` same shape - `:2941` `get_env_value(key_env) if key_env else \"\"` ### Deliberately not changed - **`acp_adapter/server.py:155`** — `api_key = os.environ.get(key_env, \"\").strip() if key_env else \"\"` in `_named_custom_provider_catalogs` is genuinely the same root cause. It is left out because that file is being actively rewritten right now (open PRs #84332, #84232, #84101, #73768 and #67934 all touch it, several within the last day), and #67934's diff already edits the two lines immediately above it. Landing this one-line change there would collide for no benefit; it wants its own PR once that traffic settles. Flagging it explicitly so it is not lost. - **`:2151`, `:2753`, `:2805` — `os.getenv(base_url_env, …)`.** A base URL is not a credential; routing it through a credential reader (with a `.env` fallback and a fail-closed raise) is a different change and probably the wrong one. - **`:3071` — `os.getenv(var, \"\").strip() == existing_key`.** A display-attribution comparison (\"(from Bitwarden)\"), not a credential resolution. - **`hermes_cli/model_switch.py:1707`** — `if _ukey.startswith(\"${\") and _ukey.endswith(\"}\")` on the `/model` picker path has the *same* expanded-value blindness that shape 3 fixes here: `_ucfg` comes from `user_providers`, which `inventory.py:105` builds from `load_config()`, so it is already expanded. Left out on purpose, because the remedy is genuinely different rather than the same patch: that path reads the `providers:` block, which has no `api_key_ref` equivalent, so fixing it means plumbing a `read_raw_config()` lookup into the picker — a different config surface, a different function, and a design call about where raw config gets read on that path. It wants its own PR. Flagging it explicitly so it is not lost. - **`hermes_cli/config.py::_env_expand_match`** also reads raw `os.environ`, and is what leaves the `${VAR}` placeholders this PR now handles. It is deliberately untouched: it expands *every* config value, not just credentials, so giving it a `.env` fallback and a fail-closed raise would change config loading globally. That is a maintainer call, and handling the unresolved ref at the credential call site is the diff-local fix. - **`hermes_cli/models.py`** provider-catalog key reads (`:2128`, `:2972`, `:4486`, `:4646`, `:4920`) — same class, but a different surface and a much busier file; a separate PR. ## How to Test ``` uv run --with pytest --with pytest-asyncio python3 -m pytest \\ tests/hermes_cli/test_named_custom_provider_key_scope.py -v ``` Before/after was verified in both directions, per commit, on `a3bcb2c2326`: | test | on `main` | after | |---|---|---| | `test_key_env_resolves_from_the_profile_scope_not_the_process_env` | probes with `'sk-other-profile'`, expected `'sk-this-profile'` | pass | | `test_unresolved_env_ref_api_key_is_resolved_not_sent_as_the_bearer_token` | probes with `'${MYCORP_API_KEY}'` | pass | | `test_unresolvable_env_ref_api_key_falls_through_to_key_env` | probes with `'${MYCORP_MISSING_KEY}'` | pass | | `test_key_env_is_fail_closed_when_multiplexing_runs_without_a_scope` | `DID NOT RAISE UnscopedSecretError` | pass | | `test_already_expanded_env_ref_api_key_is_re_resolved_through_the_scope` | probes with `'sk-other-profile'`, expected `'sk-this-profile'` | pass | | `test_already_expanded_env_ref_api_key_is_dropped_when_out_of_scope` | probes with `'sk-other-profile'`, expected `''` | pass | | `test_partially_interpolated_api_key_keeps_its_expanded_value` | pass (non-regression guard) | pass | Note the first of those three: on the pre-fix code the probe transmits the literal string `sk-other-profile` — i.e. the leak is directly observable at the call site, not inferred. Each production commit is independently load-bearing: applying only the first leaves tests 2 and 3 red, only the second leaves tests 1 and 4 red, and only the first two leaves tests 5 and 6 red. The assertions inspect the argument `fetch_api_models` actually receives, because that is the point at which the credential leaves the process — a test that only read the local variable would still pass if the value never reached the probe. Manual: add a `custom_providers` entry with `key_env: MYCORP_API_KEY` (or `api_key: ${MYCORP_API_KEY}`), run `hermes model`, pick it. Before, an entry whose variable is unset probes with the literal `${MYCORP_API_KEY}` and 401s with no indication why; after, it probes with no credential and falls back to the saved model list. Adjacent suites run green: `test_model_switch_custom_providers.py`, `test_custom_provider_model_switch.py`, `test_setup.py`, `test_config_env_expansion.py`, `test_config_env_ref_parity.py`, `tests/test_secret_scope_plugin_families.py` — 89 passed. ## CI note `Python tests` is fully green on the current head (`8124a84a07c`) — all 12 slices plus `e2e`. The e2e flake noted here previously (`test_plaintext_restart_gateway_routes_to_safe_restart_command[telegram]`) did not recur and that note is withdrawn. One check is red: `build (arm64, ubuntu-24.04-arm, linux/arm64, ...)`. It is an infrastructure failure in the image build, not a test failure, and nothing in it touches this diff: ``` error: Failed to install cpython-3.11.14-linux-aarch64-gnu Caused by: Request failed after 3 retries Caused by: Failed to download https://github.com/astral-sh/python-build-standalone/ releases/download/20260127/cpython-3.11.14+20260127-aarch64-unknown-linux-gnu-... Caused by: http2 error Caused by: stream error received: refused stream before processing any application logic ``` `uv python install 3.11` could not reach the GitHub release asset; the workflow's own retry wrapper burned both of its attempts on the same transport error. The identical check is `success` on the two other branches I pushed today, so it is a transient network failure on that runner rather than anything repo-wide or branch-specific. Not spending an empty commit on it: it has no upstream root cause to name, so it is not a legitimate retrigger — and as an outside contributor `gh run rerun` returns *\"Must have admin rights to Repository\"*. Any future push to this branch re-runs it naturally. ## Related / Positioning Duplicate search, so the basis is visible rather than asserted: - **Text search** (`gh search prs --state open`, which indexes title/body only) on `_model_flow_named_custom`, `model_setup_flows`, `get_env_value`, `_scoped_key_env`, `named custom provider key_env`: no open PR proposes routing this flow's credential reads through the secret scope. The nearest neighbours are on other files — #83908 and #84199 (`model_switch.py` aliases), #76882 (`agent/auxiliary_client.py`, `gateway/`), #79283 (`agent/credential_pool.py`), #74587 (`hermes_cli/config.py`). - **By changed path**, across the open queue: 48 open PRs touch `hermes_cli/model_setup_flows.py`; five have hunks anywhere near this region and none covers line 1527 — #58776, #76480, #25250, #45533, and #66522 (whose hunks are all inside `_model_flow_vertex`, `@@ -2539` … `-2637`). - **#67934** (*\"fix: use native Ollama tags for local model discovery\"*) is worth calling out because it rewrites **this same function** — for an unrelated purpose (native `/api/tags` catalog discovery), and it does not touch the credential resolution. Its `model_setup_flows.py` hunks are `@@ -1505,14`, `-1538,13`, `-1552,19`, `-1623,7`; this PR's single production hunk is `@@ -1522,9`, which sits in the gap between its first two. The two apply independently and in either order — the import used here is scoped to the credential block precisely so the function's shared import header stays byte-identical to `main` and cannot collide. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass <!-- ran the focused + adjacent suites listed under \"How to Test\" (89 passed), not the full tree --> - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin 25.4) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A <!-- N/A: no user-facing surface changes; rationale is in code comments --> - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A <!-- N/A: no new config keys --> - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A <!-- N/A --> - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A <!-- pure env-var resolution, no platform-specific paths; only tested on macOS --> - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A <!-- N/A -->",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84496",
          "createdAt": "2026-08-12T11:39:52Z",
          "updatedAt": "2026-08-13T15:32:07Z",
          "timestamp": "2026-08-13T15:32:07Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/security",
            "comp/cli",
            "area/auth",
            "P2",
            "sweeper:risk-security-boundary",
            "area/profiles"
          ],
          "author": "briandevans",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:90e748d9c0301d18f6f4",
        "signalId": "github:NousResearch/hermes-agent:issue:72662",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:72662",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Feature: Apply DeepSeek peak-hour rate multiplier (2x during 01:00-04:00 and 06:00-10:00 UTC)",
          "text": "This issue requests implementation of DeepSeek peak-hour pricing multiplier announced by DeepSeek on 2026-07-26. ## Feature Request DeepSeek announced peak-hour pricing with 2x rate multiplier during: - **01:00 - 04:00 UTC** (daily) - **06:00 - 10:00 UTC** (daily) All other hours: standard rate. ## Current State Hermes has a usage_pricing.py that tracks model costs per call. It does not currently account for DeepSeek's time-based pricing variation. ## Proposed Implementation 1. When pricing a DeepSeek API call, check the current UTC hour 2. If hour in [1, 2, 3, 4] or [6, 7, 8, 9]: - Multiply the calculated cost by 2x 3. Otherwise: - Use standard rate ## Example **Standard rate:** DeepSeek call costs .00 **During peak hour (02:00 UTC):** Same call costs .00 (1.00 * 2x multiplier) **During off-peak (08:30 UTC):** Still .00 (08 is in the 06:00-10:00 window) **During off-peak (11:00 UTC):** Back to .00 ## Impact For orchestration systems running 24/7: - Accurate cost tracking during peak hours - Ability to detect and analyze peak-vs-off-peak spend patterns - Proper billing allocation This is important for systems that autoscale or shift heavy workloads to off-peak hours. ## Reference DeepSeek pricing announcement (July 2026): Peak-hour rates are 2x standard. Would be happy to provide more implementation details if helpful.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/72662",
          "createdAt": "2026-07-27T13:00:16Z",
          "updatedAt": "2026-08-13T15:31:42Z",
          "timestamp": "2026-08-13T15:31:42Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "provider/deepseek",
            "P3",
            "area/usage-cost"
          ],
          "author": "dominicelayda",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:7900674c9f6e7faff24d",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85392",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85392",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "test(web): preserve MCP profile scoping",
          "text": "Related to #85301 The current web API layer already scopes `/api/mcp/*` requests through the global management-profile mechanism. This adds a focused regression test for `api.getMcpServers()` so switching profiles cannot silently regress to reading the dashboard process profile. Checks: - `npx vitest run --config vitest.config.ts src/lib/api.test.ts` (8 passed) - `npm run typecheck` - `npx eslint src/lib/api.test.ts`",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85392",
          "createdAt": "2026-08-13T15:29:01Z",
          "updatedAt": "2026-08-13T15:29:01Z",
          "timestamp": "2026-08-13T15:29:01Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "seanxuu",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:7b2c9a684466fc4f6bcb",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85390",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85390",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(memory): add interactive setup support for self-hosted supermemory",
          "text": "## Description Updates the supermemory plugin to explicitly support self-hosting by prompting for the \base_url during hermes memory setup and saving it to supermemory.json. This removes the need for users to manually edit supermemory.json prior to running the setup wizard, smoothing out the user experience for local installations while adhering to the configuration footprint rules (i.e., avoiding new \\.env\\ variables for non-secret configs).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85390",
          "createdAt": "2026-08-13T15:25:57Z",
          "updatedAt": "2026-08-13T15:25:57Z",
          "timestamp": "2026-08-13T15:25:57Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "akashgohilwork-art",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:76f298b58f758b595cb9",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85389",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85389",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(gateway): native MCP Streamable-HTTP endpoint + OpenAI tool-progress chunks",
          "text": "## Summary Adds a native **MCP Streamable-HTTP endpoint** to the Hermes API server (gateway), so MCP clients (e.g. Open WebUI) can consume agent tools directly from the gateway process — replacing the need for a separate MCP bridge service. Also makes tool-progress visible to strict OpenAI-stream clients by additionally emitting standard `chat.completion.chunk` with `delta.tool_calls`. ## Changes 1. **`gateway/mcp_endpoint.py`** (new) — mounts FastMCP (SSE mode) into the aiohttp app via an ASGI bridge (~90 lines, `_hold_mcp_lifespan` keeps the session task-group alive for the app lifetime). Routes: `/mcp` and automatic `/p/<profile>/mcp` mirrors. Auth: Bearer `API_SERVER_KEY` (scope-aware per profile), 401 on missing/invalid key. `transport_security.allowed_hosts` set to `172.17.0.1:*` / `host.docker.internal:*` because the gateway binds the docker bridge IP, not loopback. 2. **`gateway/mcp_tools_exec.py`** (new) — 9 execution tools (`execute_terminal`, `read_file`, `write_file`, `list_files`, `search_files`, `http_request`, `docker_ps`, `docker_logs`, `docker_exec`) running **inside the gateway process**, with 3 safety layers re-established in Python: command whitelist (`validateCommand`), path confinement to configured allowed roots (`validatePath`), per-identity sliding-window rate limit + timeout. Every executed tool call writes an audit event to MongoDB (parity with the old bridge behaviour). 3. **`gateway/mcp_tools_admin.py`** (new) — 8 admin tools (`skill_list/read/create/update/delete`, `audit_recent`, `mcp_list`, `mcp_remove`) backed by MongoDB (users/events collections), same semantics as the previous facade implementation. 4. **`gateway/mcp_acl.py`** (new) — per-user role resolution from MongoDB `users` via `X-Hermes-User-Id` (fallback `X-Hermes-User`); most-restrictive role wins; admin tools return `ERROR: admin permission required` for non-admin. 5. **`gateway/platforms/api_server.py`** — (a) mounts the MCP endpoint + lifespan on the existing HTTP server; (b) additive OpenAI-stream compatibility: each internal tool-progress event additionally emits a standard `chat.completion.chunk` carrying `delta.tool_calls` (stable per-call `index`, `function.name` on `running`, empty `arguments` on `completed`), so strict OpenAI clients like Open WebUI can render tool steps. The custom `hermes.tool.progress` event is preserved; `finish_reason` stays `None` because tools run server-side. ## Configuration New non-secret config block `gateway.mcp` (config.yaml): `allowed_paths` (host workspace roots), `whitelist_commands`, `rate_limit_per_hour` (default 100), `tool_timeout_ms` (default 30000). MongoDB URI is read from the Hermes env file as a secret (`MCP_MONGO_URI`). ## Testing (all real, on WSL + Open WebUI stack) - MCP handshake over SSE: `initialize` 200 + `Mcp-Session-Id`; `tools/list` returns 18 tools (17 legacy parity + `echo` smoke); `DELETE` terminates 200. - ACL matrix: admin → all tools + `audit_recent` OK; user role → `ERROR: admin permission required`; missing key → 401; cross-profile key (`/p/<team>/mcp`) → 401. - Safety layers: `rm` (not whitelisted) → `command not allowed`; `/etc/passwd` → `outside allowed roots`; burst > limit → 429. - Audit continuity: `list_files` + `execute_terminal` calls produce fresh `type=tool` events in MongoDB with the correct userId (a regression bug where 7/9 exec tools skipped auditing was found and fixed in the final commit). - Open WebUI end-to-end: chat + MCP tools work via `http://host.docker.internal:8642/mcp`; the separate MCP bridge container was removed from the compose stack. ## Notes - The delta.tool_calls commit keeps the original `hermes.tool.progress` event intact — additive only. - The local-patch comment in the second commit is descriptive of its origin; happy to reword on review.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85389",
          "createdAt": "2026-08-13T15:25:19Z",
          "updatedAt": "2026-08-13T15:25:19Z",
          "timestamp": "2026-08-13T15:25:19Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "thucnobita97",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:8aa6f79aba4ef4a407d0",
        "signalId": "github:NousResearch/hermes-agent:pull_request:54737",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:54737",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cli): bound generic model catalog response reads",
          "text": "## Summary - bound the generic custom-provider `/models` probes at 4 MiB - preserve the existing `/models` -> `/v1/models` fallback sequence for oversized or malformed responses - keep the current credential-safe opener and also clamp bodies discarded by `urllib` while following redirects, so an intermediate 3xx response cannot bypass the final-response cap - retarget the regression tests to the current secure transport seam instead of mocking raw `urllib.request.urlopen` Part of #54838. ## Why this boundary This PR now owns the shared credential-safe bounded-read primitive and its generic CLI model-probe consumer. Both are required for the 4 MiB guarantee: bounding only the final catalog response would leave `urllib`'s discarded redirect-body read unbounded. The provider-only `ProviderProfile.fetch_models()` change is deliberately excluded. It remains the canonical contribution in #42930, including that PR's original author and 16 MiB policy. The remaining provider-specific catalog and pricing readers in `hermes_cli/models.py` remain in #54840 under #54838. ## Maintainer feedback addressed - preserved `open_credentialed_url` and its cross-origin credential stripping - applied the bound at the current secure transport call path - updated tests to exercise the bounded-reader seam and real redirect behavior - explicitly scoped the remaining catalog readers to #54840 ## Verification - `scripts/run_tests.sh tests/hermes_cli/test_urllib_security.py tests/hermes_cli/test_model_validation_response_size.py tests/hermes_cli/test_model_validation.py tests/hermes_cli/test_custom_provider_extra_headers.py -q` - 71 passed - Ruff passed for all changed Python files - `compileall` passed for all changed Python files - `git diff --check` passed - merge-tree against current `main` (`fa83af3f9a42790730b8966ff67e7d9fb627899f`) succeeded - structured Codex AutoReview: no actionable findings (`patch is correct`, confidence 0.96) ## Deliberately excluded - `ProviderProfile.fetch_models()` - #42930 / #54735 - provider-specific catalog and pricing readers - #54840 / #54838 - manifest loading - #54793 - slow-drip deadlines, retries, decompression, cache policy, and model-selection changes",
          "url": "https://github.com/NousResearch/hermes-agent/pull/54737",
          "createdAt": "2026-06-29T08:19:18Z",
          "updatedAt": "2026-08-13T15:24:54Z",
          "timestamp": "2026-08-13T15:24:54Z",
          "metrics": {
            "reactions": 0,
            "comments": 5
          },
          "labels": [
            "type/security",
            "comp/cli",
            "area/config",
            "P2",
            "sweeper:blast-moderate"
          ],
          "author": "ooiuuii",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ec478d0e2c9b8ca80f02",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85382",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85382",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat: support Discord decision reactions",
          "text": "## Summary - add configurable Discord decision channels where 👍 approves and 👎 rejects bot-authored decision messages - isolate each reacted decision into its own session so rapid approvals cannot interrupt or overwrite one another - enforce existing user/channel authorization, ignore bot reactions, and preserve first-reaction semantics while allowing retry after failed handoff - document `discord.decision_reaction_channels` ## Test plan - [x] `scripts/run_tests.sh tests/gateway/test_discord_*.py tests/plugins/platforms/test_discord_gate_isolation.py -q` - 279 passed, 0 failed, 1 skipped - [x] Ruff checks pass - [x] `git diff --check` passes - [x] independent security/logic re-review passes ## Activation This PR does not merge or activate the feature. A configured decisions-channel ID and gateway restart are required after merge.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85382",
          "createdAt": "2026-08-13T15:15:03Z",
          "updatedAt": "2026-08-13T15:24:49Z",
          "timestamp": "2026-08-13T15:24:49Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/plugins",
            "platform/discord",
            "area/config",
            "P3"
          ],
          "author": "kundanuppal7-art",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:8fde16a7294c841cd32a",
        "signalId": "github:NousResearch/hermes-agent:issue:85383",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85383",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "cronjob create fails when repeat='forever' is passed explicitly",
          "text": "# Bug report: cronjob tool create fails when repeat='forever' is passed explicitly ## Summary `cronjob action='create'` raises `TypeError: '<=' not supported between instances of 'str' and 'int'` whenever the `repeat` parameter is explicitly provided as the string `\"forever\"`, even though the tool's own schema/success responses use that exact string value for infinite recurrence. ## Environment - Platform: Hermes desktop / Linux - Tool: `cronjob` (create action) - Date: 2026-08-13 ## Reproduction 1. Call `cronjob` with `action='create'`, `name='...'`, `schedule='30 6 * * *'`, `repeat='forever'`, plus any of `prompt`, `script`, or `workdir`. 2. Observe failure: ``` '<=' not supported between instances of 'str' and 'int' ``` 3. Retry with the same payload but omit `repeat` entirely. 4. Observe success; tool returns `\"repeat\": \"forever\"` in the created job. Also confirmed: - `repeat=1` succeeds. - Failure is independent of whether `script`, `prompt`, or `workdir` are supplied. ## Evidence - Failing call shape: - `name='proposal-evaluator'` - `schedule='30 6 * * *'` - `repeat='forever'` - `prompt='Run proposal-evaluator.py once.'` - Successful call shape: - Same as above, but without `repeat`. - Successful call shape: - `name='proposal-evaluator-debug'` - `schedule='30 6 * * *'` - `repeat=1` - `prompt='Run proposal-evaluator.py once.'` ## Documentation reference Hermes background-systems docs describe `repeat` as supporting integer counts and infinite recurrence. The tool's own successful responses also represent infinite recurrence as the string `\"forever\"`. So the documented/intended representation is the string `\"forever\"`; the bug is that passing it explicitly triggers a type comparison against an integer sentinel. ## Root-cause hypothesis The tool appears to have a validation branch that assumes `repeat` is numeric and performs an integer comparison against it, without first normalizing the string sentinel `\"forever\"`. When `repeat` is omitted, a different defaulting path is used that avoids the comparison. ## Workaround Omit `repeat` entirely when creating jobs that should run forever. The tool defaults to `\"forever\"` on its own, as shown in successful responses. ## Impact Users cannot explicitly request infinite recurrence via the documented parameter value, even though that value is what the tool itself outputs for infinite jobs.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85383",
          "createdAt": "2026-08-13T15:17:00Z",
          "updatedAt": "2026-08-13T15:24:47Z",
          "timestamp": "2026-08-13T15:24:47Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "duplicate",
            "comp/cron",
            "P2"
          ],
          "author": "kaalalexanderrosser",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:3d50804f1ce3c3d20ce7",
        "signalId": "github:NousResearch/hermes-agent:issue:85384",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85384",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "fix(cli): Windows PowerShell same-surface guard weak + pid_alive cross-module blast radius + voice auto-restart direct invalidate",
          "text": "## Summary Two fixes in one file for the stacked-frame guard on Windows PowerShell: ### 1. _get_terminal_surface_id: HWND augmentation After GetConsoleTitleW returns a title, also call GetConsoleWindow() and append the HWND. Two PowerShell tabs with the same default title (\"Windows PowerShell\") previously both resolved to \"win32-console:Windows PowerShell\" and the same-surface guard could not distinguish them. Each tab now gets a distinct key \"win32-console:{title}:hwnd:{handle}\". Falls back to title-only if GetConsoleWindow fails or returns NULL. ### 2. _pid_alive: replace per-call import with lazy resolver Replace the per-call \"from gateway.status import _pid_exists\" inside the inner try/except with a lazy one-time resolver (_resolve_pid_exists) that binds _PID_EXISTS globally. The old pattern meant any import failure in gateway.status (circular import, syntax error, missing transitive dep, scaffold race) was silently caught and returned False for every PID — wiping the entire active-session registry through _prune_dead exactly when it matters most (startup). With the lazy resolver an import failure surfaces as a real error instead of silently making every PID look dead. Runtime failures of _pid_exists itself are still caught and return False as before. ### Scope - Both changes are additive inside existing fallback paths; POSIX and PID-based fallbacks are untouched. - Not touching the orphaned TUI node reaper (dashboard_procs.py) — that has a separate gap (reaper only runs at TUI launch, not CLI startup) that needs its own design review. ### Validation - py_compile passes on hermes_cli/active_sessions.py - No new external dependencies - Existing active-sessions tests continue to pass (test_active_sessions.py, test_cli_active_session_limit.py)",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85384",
          "createdAt": "2026-08-13T15:19:22Z",
          "updatedAt": "2026-08-13T15:24:40Z",
          "timestamp": "2026-08-13T15:24:40Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "duplicate",
            "comp/cli",
            "P2",
            "needs-repro",
            "sweeper:risk-session-state",
            "sweeper:risk-platform-windows",
            "platform/windows"
          ],
          "author": "iap",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:4da1049cd56d6bbbb18c",
        "signalId": "github:NousResearch/hermes-agent:issue:85385",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85385",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "permission-test",
          "text": "test",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85385",
          "createdAt": "2026-08-13T15:21:47Z",
          "updatedAt": "2026-08-13T15:24:32Z",
          "timestamp": "2026-08-13T15:24:32Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "invalid",
            "P3"
          ],
          "author": "linglinex01",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:8155ff852bf98dd42564",
        "signalId": "github:NousResearch/hermes-agent:pull_request:72756",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:72756",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(provider/deepseek): apply peak-hour rate multiplier (#72662)",
          "text": "## Summary Implements #72662: Apply DeepSeek peak-hour rate multiplier. DeepSeek announced peak-hour pricing on 2026-07-26 with 2x rate multiplier during: - **01:00 – 04:00 UTC** (daily) - **06:00 – 10:00 UTC** (daily) All other hours: standard rate. ## Changes ### `agent/usage_pricing.py` - Added `_deepseek_peak_hour_multiplier()` — returns `Decimal(\"2\")` when current UTC hour falls in either peak window, otherwise `Decimal(\"1\")`. - Modified `estimate_usage_cost()` — applies the 2x multiplier to the computed amount when `route.provider == \"deepseek\"` and the current time is in a peak window. Adds a note to `CostResult.notes` when the multiplier is active. ### `tests/agent/test_usage_pricing.py` Added 6 new tests: - `test_deepseek_peak_hour_multiplier_peak_window_1` — verifies hours 1–4 return 2x - `test_deepseek_peak_hour_multiplier_peak_window_2` — verifies hours 6–10 return 2x - `test_deepseek_peak_hour_multiplier_off_peak` — verifies hours outside both windows return 1x - `test_estimate_usage_cost_deepseek_peak_hour_applies_multiplier` — end-to-end: DeepSeek call during peak hour costs 2x - `test_estimate_usage_cost_deepseek_off_peak_standard_rate` — end-to-end: same call during off-peak costs standard rate - `test_estimate_usage_cost_non_deepseek_not_affected_by_peak_hour` — invariant: non-DeepSeek providers are unaffected ## Testing All 38 tests pass.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/72756",
          "createdAt": "2026-07-27T16:30:36Z",
          "updatedAt": "2026-08-13T15:24:23Z",
          "timestamp": "2026-08-13T15:24:23Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "provider/deepseek",
            "P3",
            "sweeper:incoherent",
            "area/usage-cost"
          ],
          "author": "webtecnica",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ae32ab6af15be9d8aee2",
        "signalId": "github:NousResearch/hermes-agent:pull_request:67426",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:67426",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(write_file): detect cwd-shaped relative paths missing leading slash",
          "text": "## Problem When a model emits a *relative* path that textually mirrors the working directory (e.g. `home/user/dev/notes/x.md` — an absolute path missing its leading `/`), `write_file` silently creates a doubled path like `/home/user/dev/home/user/dev/notes/x.md` instead of failing or writing to the intended location. Closes #67185. ## Root cause `_resolve_path_for_task()` in `tools/file_tools.py` joins relative paths with the task base directory. A path like `home/user/dev/notes/x.md` passes through unchanged, and the join produces a doubled path because the path already contains the base dir as a prefix. ## Fix Added a **structural check** inline in `_resolve_path_for_task()` before the base-dir join: if a relative path reproduces the base directory tail as its own prefix, prepend `/`. No hard-coded allowlist — the check works for any root directory the model might drop the slash on. Example: `base_dir = /home/user/dev`, `filepath = \"home/user/dev/notes/x.md\"` → `base_dir.lstrip(\"/\") = \"home/user/dev\"` → `filepath.startswith(\"home/user/dev/\")` → True → prepend `/`. ## Changes per maintainer review (teknium1) - Removed `_ABSOLUTE_PATH_ROOTS` frozenset (brittle allowlist) - Removed `_coerce_missing_leading_slash()` helper - Added inline structural check in `_resolve_path_for_task()` before the base_dir join - Added regression tests through `write_file_tool` in `test_file_tools_cwd_resolution.py` ## Testing - Regression tests in `test_file_tools_cwd_resolution.py`: - `test_cwd_shaped_relative_path_prepends_slash` — unit test for the structural check - `test_cwd_shaped_relative_path_through_write_file` — integration test through write_file_tool - `test_legitimate_relative_path_not_affected` — normal relative paths are untouched - All 42 existing tests in the suite continue to pass ## Checklist - [x] Bug fix (non-breaking change which fixes an issue) - [x] No new dependencies - [x] Tests added that prove the fix is effective - [x] Commit references the issue",
          "url": "https://github.com/NousResearch/hermes-agent/pull/67426",
          "createdAt": "2026-07-19T10:31:13Z",
          "updatedAt": "2026-08-13T15:23:44Z",
          "timestamp": "2026-08-13T15:23:44Z",
          "metrics": {
            "reactions": 0,
            "comments": 16
          },
          "labels": [
            "type/bug",
            "tool/file",
            "P2",
            "needs-decision",
            "sweeper:blast-moderate"
          ],
          "author": "rkfshakti",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:09b4f9260668c3d57765",
        "signalId": "github:NousResearch/hermes-agent:pull_request:69252",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:69252",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix: propagate caption-fallback warning and add voice/audio retry in Telegram caption parse path",
          "text": "## Problem Two error-handling gaps in the Telegram caption parse retry path: 1. **Caption-fallback failure is invisible to the agent.** When the caption-fallback send fails (the file is gone and the caption text is sent alone), the error is logged via `logger.warning` but never appended to the `warnings` list — the agent never knows it happened. 2. **Voice/audio files silently downgraded on caption parse error.** The caption parse retry block was missing the `_VOICE_EXTS` and `_TELEGRAM_SEND_AUDIO_EXTS` type checks that exist in the primary `try` block and the thread-not-found retry block. If an audio or voice file encounters a caption parse error, it falls through to `send_document` instead of `send_voice`/`send_audio`. ## Fix Two changes in `tools/send_message_tool.py`: 1. **Caption-fallback warning propagation:** Added `warnings.append(_sanitize_error_text(_cap_err))` alongside the existing `logger.warning` call in the caption-fallback exception handler. 2. **Voice/audio handling in caption parse retry:** Added the missing `elif ext in _VOICE_EXTS and is_voice:` and `elif ext in _TELEGRAM_SEND_AUDIO_EXTS:` branches to the caption parse retry block, matching the structure of the primary and thread-not-found retry blocks. Split from #69169 (closed as duplicate of #67565 for the send_document basename changes).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/69252",
          "createdAt": "2026-07-22T10:55:43Z",
          "updatedAt": "2026-08-13T15:23:38Z",
          "timestamp": "2026-08-13T15:23:38Z",
          "metrics": {
            "reactions": 0,
            "comments": 8
          },
          "labels": [
            "type/bug",
            "comp/tools",
            "platform/telegram",
            "P3",
            "sweeper:risk-message-delivery",
            "sweeper:blast-moderate"
          ],
          "author": "rkfshakti",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:b4ae739888c74b967021",
        "signalId": "github:NousResearch/hermes-agent:pull_request:76013",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:76013",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix: detect progress narration as final answer and retry turn",
          "text": "Fixes #74604 The agent can end a turn by emitting a progress narration (e.g. 'I am now compiling the complete answer.') as its final assistant message, producing no deliverable. No bound was reached, nothing errored, and no hook exists to catch this. Work in progress — root cause analysis and fix coming in the next commit.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/76013",
          "createdAt": "2026-08-01T08:09:10Z",
          "updatedAt": "2026-08-13T15:23:34Z",
          "timestamp": "2026-08-13T15:23:34Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "invalid",
            "comp/agent",
            "P3",
            "sweeper:blast-contained"
          ],
          "author": "rkfshakti",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c58b4ad0bd91d4039089",
        "signalId": "github:NousResearch/hermes-agent:pull_request:83367",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:83367",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cli): don't serve stale update-check results after fetch failure",
          "text": "## What does this PR do? When `_check_via_local_git`'s `git fetch origin main` fails (timeout, offline, DNS resolution failure), the code silently fell through to compare `HEAD` against the stale `origin/main` tracking ref. That stale ref hadn't been updated, so `git rev-list --count HEAD..origin/main` could report `0` (up to date) even when upstream had moved forward by several commits. Combined with the 6-hour cache in `check_for_updates`, a single fetch failure could suppress update notifications for days. This is the exact symptom in #82166: the daily cron job hit the `/api/hermes/update/check` endpoint, which called `check_for_updates()`, which cached the stale `0` for 6 hours. After the cache expired, the next check attempted another fetch — if that also failed (intermittent network issue, slow DNS), another stale `0` was cached. This cycle repeated for 4 days while v0.20.0 was already available on GitHub. ## Related Issue Fixes #82166 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_cli/banner.py` — `_check_via_local_git()`: detect fetch failure (returncode != 0 or exception) and return `None` instead of falling through to stale refs. The caller treats `None` as \"check could not run\" rather than \"up to date\". - `hermes_cli/banner.py` — `check_for_updates()`: no longer cache `None` results. Previously, a `None` from a failed check was cached for 6 hours, suppressing retries until the cache expired. Now only conclusive results (`0` or `>=1`) are cached, so the next check attempt runs immediately on the next call. ## How to Test 1. `pytest tests/hermes_cli/test_update_check.py -v` — all 4 tests pass 2. `test_check_via_local_git_fetch_failure_returns_none` — verifies that a failed `git fetch` (returncode=1) returns `None` instead of a stale behind-count 3. `test_check_for_updates_does_not_cache_none` — verifies that `None` results are not written to the `.update_check` cache file 4. Manual: simulate a fetch failure by disconnecting network, run `hermes update --check`, confirm it reports \"could not determine\" instead of \"up to date\" ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15.5, Python 3.13.5 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/83367",
          "createdAt": "2026-08-10T17:46:42Z",
          "updatedAt": "2026-08-13T15:23:27Z",
          "timestamp": "2026-08-13T15:23:27Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [],
          "author": "rkfshakti",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:90e87793ac2bf6c15687",
        "signalId": "github:NousResearch/hermes-agent:issue:85387",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85387",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Security] Dashboard managed-files write endpoints can overwrite .env / config.yaml — read-side sensitive guard not applied to writes",
          "text": "## Summary The dashboard's managed-files API protects sensitive files (`.env`, `config.yaml`, credential stores) on the **read** side (`list` / `read` / `download`), but the **write** endpoints (`/api/files/upload`, `/api/files/upload-stream`, `/api/files/mkdir`) will happily **overwrite** those same files. `_is_sensitive_path()` explicitly documents this asymmetry: > *\"Read-side only: this guards list/read/download (the #57505 exfil surface). The write endpoints (upload/mkdir/delete) are a separate threat class handled by the write-path checks; extending this guard to them is out of scope for this fix.\"* But the \"write-path checks\" it refers to only enforce `..` traversal and `locked_root` containment — **they never check the target basename against `_SENSITIVE_MANAGED_FILE_BASENAMES`**. So the claimed protection on the write side does not exist. ## Reproduction (hermes-agent v0.19.0, docker) ```bash # 1. Start dashboard (localhost bind) hermes dashboard --no-open # 2. Upload a file that overwrites .env — this SUCCEEDS (200) curl -X POST http://127.0.0.1:9119/api/files/upload-stream \\ -H \"Content-Type: multipart/form-data\" \\ -F \"path=.env\" \\ -F \"overwrite=true\" \\ -F \"file=@evil.env\" # 3. .env is now overwritten (verify) cat $HERMES_HOME/.env # → EVIL=1 ``` Same for `config.yaml`, `auth.json`, `google_token.json`, etc. — all present in `_SENSITIVE_MANAGED_FILE_BASENAMES` (read-side) but writable via upload. ## The contradiction: docstring claims separation, auth model has none The guard's own docstring says writes are *\"a separate threat class handled by the write-path checks\"* — implying reads and writes are governed differently. In reality: - **Authentication is identical for read and write.** `auth_middleware` (web_server.py:606) applies the same session-token (loopback) or OAuth-cookie (remote bind) check to every `/api/` route. `/api/files` (list) and `/api/files/upload` (write) pass through the exact same gate with no per-method authorization. The only read/write difference is that `/api/files/download` additionally accepts `?token=` (because a browser-opened download link can't set a header) — writes have no such carve-out. - **The \"write-path checks\" that do exist are path-safety only, not authorization.** `_resolve_managed_path(for_write=True)` enforces `..` rejection, `locked_root` containment, and (for delete) \"can't delete the root\". None of these check *what* file is being written — `.env`, `config.yaml`, `auth.json` are all writable targets. - **Net effect**: authentication (who you are) is being conflated with authorization (what you may touch). Any client that clears the auth gate — a valid OAuth identity on a remote bind, a compromised browser tab, a local process on the host — has *unrestricted write* to every file under the managed root, including the ones the read side treats as secret. A \"read-only auditor\" OAuth user would not exist in this model; anyone who can read `.env` can also overwrite it. This is exactly the \"authentication is not authorization\" gap flagged for the dashboard's control-plane surface in #84743 (auth ≠ authorization for the host-owner power set) — the managed-files write path is the same class of gap, one level deeper: not just \"any valid identity gets host-owner power\" but \"any valid identity gets secret-file overwrite power\". ## Proposed fix (minimal) Apply the existing read-side guard to the write path — one line in `_resolve_managed_path()`: ```python # in _resolve_managed_path(), when for_write=True: if for_write and _is_sensitive_path(candidate): raise HTTPException(status_code=403, detail=\"Cannot write to a sensitive path\") ``` This blocks upload/mkdir/delete against `.env`, `config.yaml`, `auth.json`, `mcp-tokens/**`, `pairing/**`, etc. while leaving the dedicated `/api/env` (and any intentional management route) untouched. Alternative: add the check in each write route explicitly. ## Notes - We independently reimplemented the managed-files API for a lightweight local file browser (same security posture) and hit this exact gap during testing; adding the sensitive-path check on writes was a 2-line change and caught the failure in one test run. - Severity: P2 (medium) on localhost bind with single trusted user; P1 (high) on any OAuth/remote bind, because the write side currently has **zero** secret-file protection. ## Environment - Hermes Agent v0.19.0 (2026.7.20) · upstream 199f5580 · docker - Python 3.13.5 - Reproduced against `web_server.py` lines 2456-2557 (upload / upload-stream / mkdir)",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85387",
          "createdAt": "2026-08-13T15:22:58Z",
          "updatedAt": "2026-08-13T15:22:58Z",
          "timestamp": "2026-08-13T15:22:58Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "linglinex01",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:31bb1e2ab92a28c2d41f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85386",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85386",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(redact): mask WebSocket transport credentials",
          "text": "## What does this PR do? Redacts opaque `access_key` and `ticket` values from `ws://` and `wss://` endpoint query strings before they reach logs or tool output. The redaction is deliberately limited to WebSocket transport URLs. Ordinary `http://` and `https://` links keep the existing passthrough behavior so OAuth callbacks, magic links, and pre-signed URLs remain actionable. ## Related Issue N/A - no matching issue or pull request was found. ## Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [x] 🔒 Security fix - [ ] 📝 Documentation update - [ ] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `agent/redact.py`: add focused WebSocket transport query redaction for `access_key` and `ticket`, preserving other parameters and URL components. - `tests/agent/test_redact.py`: cover log formatting, case-insensitive parameter names, public WebSocket parameters, and HTTP(S) passthrough. ## How to Test 1. Run `.venv/bin/ruff check agent/redact.py tests/agent/test_redact.py`. 2. Run `scripts/run_tests.sh tests/agent/test_redact.py -q`. 3. Confirm Ruff passes and all 102 redaction tests pass. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26.5.2, Python 3.14.3 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys - N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows - N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) - uses only existing Python regex/string handling - [x] I've updated tool descriptions/schemas if I changed tool behavior - N/A ## Screenshots / Logs N/A - behavior is covered by automated tests.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85386",
          "createdAt": "2026-08-13T15:22:38Z",
          "updatedAt": "2026-08-13T15:22:38Z",
          "timestamp": "2026-08-13T15:22:38Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "Zero94Smile",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:4bf7bc5cf676ebdbe523",
        "signalId": "github:NousResearch/hermes-agent:pull_request:70184",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:70184",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix: pass target_model to resolve_runtime_provider in model override rehydration",
          "text": "When the gateway restarts, _rehydrate_session_model_override calls _resolve_runtime_agent_kwargs_for_provider which calls resolve_runtime_provider without passing the model name. The api_mode is resolved from the provider's default transport, not the specific model's required transport. For opencode-go models like mimo-v2.5 that need chat_completions (OpenAI client), they get anthropic_messages (Anthropic client) because that's the provider's default. The resolve_runtime_provider function already has a target_model parameter designed for this purpose — it just wasn't being used in the rehydration path. Fixes #70153",
          "url": "https://github.com/NousResearch/hermes-agent/pull/70184",
          "createdAt": "2026-07-23T16:18:46Z",
          "updatedAt": "2026-08-13T15:21:45Z",
          "timestamp": "2026-08-13T15:21:45Z",
          "metrics": {
            "reactions": 0,
            "comments": 10
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "P2",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery",
            "sweeper:blast-moderate"
          ],
          "author": "rkfshakti",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:0a0c9fb1d1d5743901c1",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85379",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85379",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix: run python-script launchers through the current interpreter in hermes.desktop",
          "text": "## Problem On Linux, the auto-installed `hermes.desktop` menu entry can be silently broken. `resolve_exec_command()` writes `Exec=<argv[0]> desktop`, and when Hermes is launched through a venv wrapper script (e.g. `~/.local/bin/hermes` doing `exec venv/bin/python .../hermes-agent/hermes \"$@\"`), `argv[0]` is the in-checkout `./hermes` Python script. That script's `#!/usr/bin/env python3` shebang resolves to the **system** interpreter when the desktop launcher runs it — outside the venv — so it dies instantly: ``` File \".../hermes_cli/env_loader.py\", line 12, in <module> from dotenv import load_dotenv ModuleNotFoundError: No module named 'dotenv' ``` From the user's perspective, clicking Hermes in the app launcher just does nothing, while `hermes desktop` in a terminal works fine. And because the entry is regenerated on every launch, hand-fixing the `.desktop` file doesn't stick. ## Fix When the resolved launcher is itself a Python-shebang script, prefix the `Exec` line with `sys.executable` — the interpreter that is provably working, since it's running Hermes at that moment: ``` Exec=/home/user/.hermes/hermes-agent/venv/bin/python /home/user/.hermes/hermes-agent/hermes desktop ``` Non-script binaries and shell wrappers are left untouched. One subtlety: `sys.executable` is used **unresolved**. A venv's `bin/python` is a symlink, and its *path* is what selects the venv (via the adjacent `pyvenv.cfg`); `Path(...).resolve()` yields the base interpreter without the venv's site-packages, reintroducing the same crash. The pre-existing `-m hermes_cli.main` fallback had this latent issue and is fixed by the same change. ## Testing - Two new tests in `tests/hermes_cli/test_linux_desktop_entry.py` (written first, watched fail): - a `#!/usr/bin/env python3` script gets the current interpreter prefixed, asserting the venv symlink path is preserved un-resolved - a bash wrapper stays unprefixed - Full `test_linux_desktop_entry.py` + `test_gui_uninstall.py` suites pass (20 passed, 2 platform-skipped) - Verified end-to-end on Arch/Hyprland (walker launcher): regenerated entry launches the Electron app from the menu, where it previously crashed on import",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85379",
          "createdAt": "2026-08-13T15:07:22Z",
          "updatedAt": "2026-08-13T15:27:36Z",
          "timestamp": "2026-08-13T15:27:36Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "P2",
            "needs-decision",
            "sweeper:risk-compatibility",
            "comp/desktop",
            "area/install-update"
          ],
          "author": "hsearcy",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e25cb5d9ba7447f33a92",
        "signalId": "github:NousResearch/hermes-agent:pull_request:51849",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:51849",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): render reasoning effort as a distinct badge in the model menu (#51833)",
          "text": "## Summary The desktop model dropdown no longer makes a model look duplicated when a reasoning effort is set. The effort (and \"Fast\") now renders as a distinct badge chip beside the model name instead of plain grey text appended to it. Fixes #51833. **Root cause:** in `model-menu-panel.tsx` each row built a single `meta` string (`Fast High`, `Med`, …) and rendered it *inside the same truncating span as the model name*, separated only by a leading space and a `text-(--ui-text-tertiary)` color: ```tsx <span className=\"min-w-0 flex-1 truncate\"> {name} {meta ? <span className=\"text-(--ui-text-tertiary)\"> {meta}</span> : null} </span> ``` With `agent.reasoning_effort: high`, a row reads `Qwen3.7 Max High` — the lighter grey isn't enough separation, so users (per the report) read it as a second, differently-named model rather than the same model carrying a \"High\" reasoning setting. ## Changes - `apps/desktop/src/app/shell/model-menu-panel.tsx`: build the fast/effort meta as a `metaTags` array (instead of a joined string) and render each as a discrete bordered badge chip (`rounded-sm border` + uppercase micro-label) beside the model name. The name now lives in its own `truncate` span so a long name still clips without swallowing the badges. No change to *which* tags show or how effort/fast are computed — purely how they're presented. - `apps/desktop/src/app/shell/model-menu-panel.test.tsx`: new regression test — the effort renders in its own bordered badge element (not concatenated into the name node), and the badge is dropped entirely for models without reasoning support. ## Validation | | Before | After | |---|---|---| | `reasoning_effort: high` row | `Qwen3.7 Max High` — grey text on the name, reads as a duplicate model | `Qwen3.7 Max` + a separate `HIGH` badge chip | | Non-reasoning model | trailing `Med` text | no badge | | Long model name | name + meta share one truncating span | name truncates on its own; badge stays visible | - `npx vitest run --environment jsdom src/app/shell/model-menu-panel.test.tsx`: 2/2 passed - Related suites (`model-status-label`, `model-edit-submenu`): 13/13 passed - `npm run typecheck` (apps/desktop): clean - `eslint` on both changed files: clean",
          "url": "https://github.com/NousResearch/hermes-agent/pull/51849",
          "createdAt": "2026-06-24T10:58:05Z",
          "updatedAt": "2026-08-13T15:21:32Z",
          "timestamp": "2026-08-13T15:21:32Z",
          "metrics": {
            "reactions": 0,
            "comments": 5
          },
          "labels": [
            "type/bug",
            "duplicate",
            "P3",
            "sweeper:blast-moderate",
            "comp/desktop"
          ],
          "author": "PRATHAMESH75",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:78df3b6c463bcbfb9c6c",
        "signalId": "github:NousResearch/hermes-agent:pull_request:67336",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:67336",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): recover Codex reasoning-only stalls with a nudge + fallback ladder (#67321)",
          "text": "## What does this PR do? Codex/Responses turns can return several consecutive `status=\"incomplete\"` responses that carry only **encrypted** reasoning items — no final text, no tool call. The merged reasoning-only nudge (#, grok-4.20 on xai-oauth) only fires when the interim has *nothing to replay* (plain-text reasoning, no `encrypted_content`). Encrypted reasoning items **do** replay byte-for-byte, so a bare retry is identical to the request that just failed and the model deterministically repeats the stall. After three attempts the turn dies with: ``` Codex response remained incomplete after 3 continuation attempts ``` …and never reaches the configured fallback provider. This is distinct from context overflow, output-token exhaustion, or a transport exception, and distinct from the merged #64764 (which handled reasoning-only responses marked `completed`) — this is the remaining `status=incomplete` recovery/fallback path. The fix tracks a per-turn **consecutive reasoning-only streak** (no visible answer, no tool call) separate from the aggregate incomplete counter and drives a recovery ladder off it: - **streak 1** → replay the provider state once (unchanged) - **streak 2** → append the existing continuation nudge, now also for **encrypted** reasoning-only interims (previously nudged only when nothing was replayable) - **streak 3** → hand the turn to the configured fallback via `_try_activate_fallback(reason=FailoverReason.incomplete_response)` — a new semantic failover reason — instead of returning the terminal sentinel Visible/replayable partial progress **resets the streak**, so the mixed-partial sequence (one visible partial, then an encrypted reasoning-only streak) reaches its own threshold instead of dying early on the aggregate counter; the turn-wide iteration budget stays the hard bound. When the triggering response consumed the iteration budget, exactly one **bounded grace call** is granted (reusing the existing dormant `_budget_grace_call` hook) so the fallback actually runs rather than the loop exiting first. Cross-protocol cleanup is handled by existing machinery, not new code: `_try_activate_fallback` rewrites the `Model:`/`Provider:` system-prompt identity (synced into the in-flight system message), and crossing to a non-Codex provider flips `agent.api_mode`, which makes the existing `drop_codex_reasoning_items` gate strip the opaque replay state from the wire while preserving tool evidence and role ordering. When no fallback is configured, the terminal sentinel is preserved (no regression). ## Related Issue Fixes #67321 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `agent/conversation_loop.py` — add the reasoning-only streak, extend the nudge to encrypted interims (streak ≥ 2), and route streak ≥ 3 to the fallback provider with a bounded grace call when the budget was consumed; sync the failover system identity. - `agent/error_classifier.py` — add `FailoverReason.incomplete_response`. - `agent/turn_context.py` — reset `_codex_reasoning_only_streak` per turn. - `tests/run_agent/test_run_agent_codex_responses.py` — 5 tests: encrypted-nudge-after-replay, streak→fallback, no-fallback sentinel, visible-partial resets streak (mixed-partial), and bounded grace call. - `tests/agent/test_error_classifier.py` — cover the new enum member. ## How to Test ``` scripts/run_tests.sh tests/run_agent/test_run_agent_codex_responses.py tests/agent/test_error_classifier.py tests/agent/test_turn_context.py ``` Result: all pass (110 in the codex_responses file, including the 5 new tests). Also ran, all green: `tests/run_agent/test_run_agent.py`, `tests/agent/test_codex_responses_adapter.py`, `tests/gateway/test_incomplete_gateway_turns.py`, `tests/run_agent/test_24996_fallback_exhaustion_cooldown.py`, `tests/run_agent/test_32646_fallback_429_after_timeout.py`, `tests/run_agent/test_message_sequence_repair.py`. New-test coverage maps to the issue's scenarios: 1. Pure encrypted reasoning-only → nudge after the state has replayed once, then recover. 2. Streak 3 activates the fallback with the `incomplete_response` reason. 3. Mixed partial-then-reasoning-only → streak resets on visible progress and still reaches its own threshold despite the aggregate counter ≥ 3. 4. Bounded grace call when the third reasoning-only response lands on the last budgeted iteration (exactly one extra call). 5. No fallback configured → terminal sentinel preserved. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix - [x] I've run the affected tests and they pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin 25.5.0) ### Documentation & Housekeeping - [x] I've updated relevant documentation (docstrings/comments) — or N/A - [x] `cli-config.yaml.example` — N/A (no config keys) - [x] `CONTRIBUTING.md`/`AGENTS.md` — N/A (no architecture/workflow change) - [x] Cross-platform impact — N/A (pure control-flow, no OS-specific code) - [x] Tool descriptions/schemas — N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/67336",
          "createdAt": "2026-07-19T05:42:13Z",
          "updatedAt": "2026-08-13T15:19:32Z",
          "timestamp": "2026-08-13T15:19:32Z",
          "metrics": {
            "reactions": 2,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/openai",
            "P2",
            "codex",
            "sweeper:risk-session-state",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "PRATHAMESH75",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:17ef8d23701ea1a04b7d",
        "signalId": "github:NousResearch/hermes-agent:pull_request:61882",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:61882",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(security): fail closed when terminal config bridge is unavailable",
          "text": "Closes #54354 ## What Problem This Solves When a cold-start terminal tool call arrives before the config bridge has run, stale env vars (TERMINAL_ENV=local, TERMINAL_DOCKER_IMAGE=old-image) can cause the terminal to silently run on the host instead of in the configured Docker container. This is a sandbox escape: the user thinks code runs in Docker but it actually runs on the host. ## Why This Change Was Made Adds `_terminal_env_snapshot()` to bridge config.yaml into terminal env resolution during cold-start, and a fail-closed guard in `_get_env_config()`: - When the config bridge fails, stale TERMINAL_* container vars are stripped (preserving explicit TERMINAL_ENV so env-only configuration is not regressed) - `_get_env_config()` checks whether config.yaml intends an isolated backend (docker, modal, etc.) - If yes → `RuntimeError` (fail closed, refuse to run on host) - If config.yaml is also unreadable → `RuntimeError` (refuse to run without confirmed backend) - If config intends local → safe to proceed ## User Impact Users with Docker/container terminal backends are now protected against silent sandbox downgrades during cold-start tool calls. Previously, an unavailable config bridge could cause code to execute on the host without the user knowing. ## Evidence - `pytest tests/tools/test_docker_cold_start_guard.py` — 12 tests passed - Codex review approved by egilewski (\"fully addressed\") - New tests cover: Docker bridge failure → RuntimeError, local bridge failure → safe proceed, env-only TERMINAL_ENV preserved, double-failure → RuntimeError ## Previous iterations - Supersedes #54982 (stale branch, rebased cleanly on current main) 🤖 Generated with [Claude Code](https://claude.com/claude-code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>",
          "url": "https://github.com/NousResearch/hermes-agent/pull/61882",
          "createdAt": "2026-07-10T05:26:30Z",
          "updatedAt": "2026-08-13T15:19:07Z",
          "timestamp": "2026-08-13T15:19:07Z",
          "metrics": {
            "reactions": 0,
            "comments": 12
          },
          "labels": [
            "type/security",
            "comp/tools",
            "tool/terminal",
            "backend/docker",
            "area/config",
            "P2",
            "sweeper:risk-security-boundary",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "Xingkai98",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ad69650341e4f0ebf30f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:72638",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:72638",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(openai): configure Responses verbosity",
          "text": "## What does this PR do? Adds first-class `agent.text_verbosity` configuration for OpenAI Responses requests. Valid values are `low`, `medium`, and `high`; empty, unset, or malformed values preserve the provider default. The setting is emitted as top-level `text.verbosity` only for supported GPT-5 requests through Codex OAuth or the exact `api.openai.com` host. It is not sent to chat-completions, xAI/Grok, GitHub Models, custom endpoints, non-GPT models, or unrelated providers. This is a corrected continuation of #63543, not an independent rewrite. Its original two commits and author attribution are preserved. The follow-up commit aligns the config key with #20203, handles malformed config safely, and fails closed across provider-routing edge cases. ## Related Issue Fixes #20203 Related implementations reviewed while preparing this version: #20258, #29574, #59844, #63543. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] 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 - Added centralized parsing and provider capability checks in `agent/text_verbosity.py` and agent initialization. - Merged `text.verbosity` into Responses requests without replacing `request_overrides`, `service_tier`, or sibling `text` fields. - Added Responses adapter validation and gateway cache invalidation for config changes. - Documented the new setting in `cli-config.yaml.example` and the configuration guide. - Added transport, initialization, adapter, config, and gateway-cache regression coverage. ## How to Test 1. Run the focused Responses/config/cache suite: ```bash python -m pytest -q \\ tests/agent/transports/test_codex_transport.py \\ tests/agent/test_codex_responses_adapter.py \\ tests/run_agent/test_run_agent_codex_responses.py \\ tests/hermes_cli/test_config.py \\ tests/gateway/test_agent_cache.py ``` 2. Run the initialization and provider-impact coverage: ```bash python -m pytest -q tests/run_agent/test_run_agent.py -k TextVerbosityConfig python -m pytest -q \\ tests/run_agent/test_provider_parity.py \\ tests/run_agent/test_strict_api_validation.py \\ tests/honcho_plugin/test_async_memory.py \\ tests/honcho_plugin/test_session.py ``` 3. Set the configuration below and confirm a GPT-5 OpenAI Responses request contains `text: {\"verbosity\": \"low\"}`. Confirm the field is omitted when unset and for xAI/Grok, GitHub Models, custom endpoints, non-GPT models, and chat-completions requests. ```yaml agent: text_verbosity: low ``` Tested on macOS 26.5.2 with Python 3.13.11. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't an unnoticed duplicate - [x] My PR contains only changes related to this feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass (focused and impact suites pass; a broader run encountered six unrelated failures reproducible on current `main` in credential/Anthropic tests) - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 26.5.2, Python 3.13.11 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - configuration guide updated - [x] I've updated `cli-config.yaml.example` because this adds a config key - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows - N/A - [x] I've considered cross-platform impact (Windows, macOS) - no platform-specific behavior - [x] I've updated tool descriptions/schemas if I changed tool behavior - N/A ## Screenshots / Logs N/A - this changes request construction and configuration rather than UI.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/72638",
          "createdAt": "2026-07-27T12:12:28Z",
          "updatedAt": "2026-08-13T15:16:57Z",
          "timestamp": "2026-08-13T15:16:57Z",
          "metrics": {
            "reactions": 0,
            "comments": 5
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/cli",
            "comp/gateway",
            "provider/openai",
            "area/config",
            "P3",
            "needs-decision",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility",
            "sweeper:risk-caching",
            "sweeper:blast-contained"
          ],
          "author": "Diaspar4u",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:217a758e4390d750df9d",
        "signalId": "github:NousResearch/hermes-agent:pull_request:67471",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:67471",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cli): run -w/--worktree in one-shot mode instead of silently ignoring it (#67458)",
          "text": "## What does this PR do? `hermes -z \"…\" -w` accepts `-w/--worktree` but silently ignores it. One-shot mode had no worktree lifecycle — `run_oneshot` (`hermes_cli/oneshot.py`) never set one up, and both `-z` dispatch sites in `hermes_cli/main.py` forwarded neither the flag nor any isolation. So the agent runs in the caller's cwd and its commits land on the checked-out branch, instead of the isolated branch `-w` implies — a data-loss-adjacent surprise for scripts that rely on `-z … -w` for isolation. This PR **honors** `-w` in one-shot mode instead of dropping it, matching the documented contract (`website/docs/user-guide/git-worktrees.md`, `cli.md` both advertise `hermes -w -z`) and the interactive `hermes -w` behavior. It reuses the existing worktree lifecycle rather than adding a parallel one. ## Related Issue Fixes #67458 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_cli/oneshot.py`: `run_oneshot()` gains a `worktree: bool` parameter. When set, it creates a disposable worktree on a dedicated `hermes/…` branch, `chdir`s into it for the run, and tears it down on exit (kept only if it has unpushed commits) — reusing `cli._setup_worktree` / `_cleanup_worktree`, the same helpers the interactive path uses. Setup/cleanup notices are routed to **stderr** so stdout still carries only the final response; a setup failure exits `2` without running the agent. - `hermes_cli/main.py`: both one-shot dispatch sites (Termux fast-CLI path and main dispatch) now forward `worktree=getattr(args, \"worktree\", False)`. The previous hard-reject helper is removed. - Tests: - `tests/hermes_cli/test_oneshot_worktree.py` (new): lifecycle coverage — agent runs inside the worktree, cwd restored afterward, cleanup runs even when the agent raises, setup failure returns `2` without running the agent, and no-op when `-w` is absent. - `tests/hermes_cli/test_tui_resume_flow.py`: dispatch-level coverage that `-w` forwards `worktree=True` through **both** the main and Termux one-shot routes. ## How to Test 1. `hermes -z \"make a commit adding a line to README\" -w` now runs the agent in a fresh `.worktrees/hermes-<hash>` worktree on branch `hermes/hermes-<hash>`; the caller's checked-out branch is left untouched. If the run produces unpushed commits, the worktree is preserved and its path printed to stderr. 2. `scripts/run_tests.sh tests/hermes_cli/test_oneshot_worktree.py tests/hermes_cli/test_tui_resume_flow.py` → all pass. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix - [x] I've run the affected tests and they pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin 25.5) ### Documentation & Housekeeping - [x] I've updated relevant documentation — N/A (this makes the already-documented `hermes -w -z` behavior actually work; no doc change needed) - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A - [x] I've updated `CONTRIBUTING.md`/`AGENTS.md` — N/A - [x] I've considered cross-platform impact — reuses the existing cross-platform worktree helpers - [x] I've updated tool descriptions/schemas — N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/67471",
          "createdAt": "2026-07-19T11:38:42Z",
          "updatedAt": "2026-08-13T15:16:52Z",
          "timestamp": "2026-08-13T15:16:52Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "duplicate",
            "comp/cli",
            "P2",
            "sweeper:risk-compatibility",
            "sweeper:blast-contained"
          ],
          "author": "PRATHAMESH75",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:715a6c73ebf82579aa73",
        "signalId": "github:NousResearch/hermes-agent:pull_request:63084",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:63084",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(tools): don't report interrupted read_file as File not found (#63069)",
          "text": "## What does this PR do? `read_file` / `read_file_raw` in `tools/file_operations.py` stat the target with `wc -c` before reading it. **Any** non-zero exit fell straight through to `_suggest_similar_files()`, which returns `File not found: <path>`. When a client disconnects mid-turn (`SSE client disconnected; interrupted agent task ...`), the interrupt kills the in-flight `wc -c` subprocess with exit code **130** (SIGINT). An existing, readable file is then misreported as *missing*. Agents that treat `read_file` failures as \"file genuinely deleted\" (e.g. governance/constitution files read at task start) can wrongly conclude a critical file vanished and escalate/block unnecessarily. This branches on the stat probe's exit code **before** the not-found fallback: - `130` → `Read interrupted while checking file: <path>` - `124` (timeout) → `Read timed out while checking file: <path>` - any other non-zero → unchanged `_suggest_similar_files()` path, so genuinely missing files still return `File not found`. The mapping lives in a small `_aborted_stat_read_result()` helper, colocated with the existing `_search_stdout_and_limit()` that already special-cases exit `124` — matching the module's established exit-code-semantics pattern. The fix stays inside the shell abstraction (no host-side `os.path.exists`), so it holds for every backend (local, docker, ssh, modal, daytona). ## Related Issue Fixes #63069 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `tools/file_operations.py`: add `_aborted_stat_read_result(path, exit_code)` helper; call it in `read_file()` and `read_file_raw()` before falling back to `_suggest_similar_files()`. - `tests/tools/test_file_operations.py`: add `TestReadStatProbeAborted` covering exit 130 (interrupted) and 124 (timeout) for both `read_file` and `read_file_raw`, plus a regression that other non-zero codes still return `File not found`. ## How to Test ``` scripts/run_tests.sh tests/tools/test_file_operations.py -q ``` Result: **95 passed** (5 new). The new cases mock the terminal env's `wc -c` probe to exit `130`/`124`/`1` and assert the surfaced error string. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin 25.5) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) — fix is exit-code branching inside the existing shell abstraction, no platform-specific paths - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/63084",
          "createdAt": "2026-07-12T08:54:26Z",
          "updatedAt": "2026-08-13T15:16:48Z",
          "timestamp": "2026-08-13T15:16:48Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "duplicate",
            "tool/file",
            "P2",
            "sweeper:blast-broad"
          ],
          "author": "PRATHAMESH75",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:9d9bd81b923438cfc75b",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85378",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85378",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix: attribute Codex subscription usage correctly",
          "text": "## Reproduction On Hermes 0.20.0, an explicit openai-codex prompt succeeds against the ChatGPT backend, but the Codex quota helper returns no data because the pool-only OAuth path omits ChatGPT-Account-Id and receives HTTP 401. Desktop and gateway /usage also show Nous credits solely because a Nous login exists, even while openai-codex is active. ## Fix - derive the non-secret ChatGPT account identifier from the OAuth JWT for pool-only quota requests - show active-provider account limits in Desktop/TUI usage - show Nous credits only for Nous inference sessions - never persist or log token claims ## Verification - focused Hermes harness: 10 tests passed - controlled live prompt with --provider openai-codex --ignore-user-config --safe-mode returned CODEX_SUBSCRIPTION_OK and recorded non-zero tokens No API-key or Nous fallback is introduced.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85378",
          "createdAt": "2026-08-13T15:06:17Z",
          "updatedAt": "2026-08-13T15:16:43Z",
          "timestamp": "2026-08-13T15:16:43Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "comp/gateway",
            "comp/tui",
            "provider/openai",
            "area/auth",
            "P2",
            "area/billing"
          ],
          "author": "PJHammond",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:6a8d90a807c79e3d704f",
        "signalId": "github:NousResearch/hermes-agent:issue:85380",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85380",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "In cut 20, updates add LF, should not.",
          "text": "+# Bug Report: Spinner/status line smears vertically instead of updating in-place (v0.20.0, Windows) + +## Description + +After updating from v0.19.0 to v0.20.0, the CLI spinner/status line no longer updates in-place. Each animation frame renders on a new line, filling the terminal with hundreds of repeated status lines instead of a single updating line. + +## Environment + +- Hermes Agent v0.20.0 (2026.8.3) +- Windows 10 +- PowerShell (Windows Terminal) +- Python 3.11.15 +- Display interface: cli (not tui) +- Model: claude-opus-4-6 (Anthropic) + +## Expected Behavior + +The spinner status line (showing face, thinking verb, elapsed time, token flow, model info) should update in-place on a single line using carriage return (`\\r`). + +## Actual Behavior + +Every spinner tick prints on a NEW line, rapidly scrolling the terminal. The output looks like: + +``` +(?_?) brainstorming... (0.0s) ? claude-opus-4-6 159K/1M ] 16% ? 1 2.8d ? 5s | √ 50m +(?_?) brainstorming... (0.2s) ? claude-opus-4-6 159K/1M ] 16% ? 1 2.8d ? 5s | √ 50m +(?_?) brainstorming... (0.4s) ? claude-opus-4-6 159K/1M ] 16% ? 1 2.8d ? 5s | √ 50m +...hundreds more lines... +(?_?) cogitating... (0.0s) ? claude-opus-4-6 159K/1M ] 16% ? 1 2.8d ? 5s | √ 50m +(?_?) cogitating... (0.2s) ? claude-opus-4-6 159K/1M ] 16% ? 1 2.8d ? 5s | √ 50m +``` + +This continues throughout tool execution and model thinking, making the terminal nearly unusable. + +## Analysis + +The code in `agent/display.py` has a `_is_patch_stdout_proxy()` check that detects prompt_toolkit's `StdoutProxy` and suppresses `\\r`-based animation. However, the status line rendering path in `cli.py` (via `_render_spinner_text()` and the TUI widget system) appears to bypass this guard, causing each frame to render as a new [smear.bmp](https://github.com/user-attachments/files/31034352/smear.bmp) line through StdoutProxy's newline-injecting flush behavior. + +This is a regression from v0.19.0 where the spinner updated cleanly in-place. + +## Screenshot + +A screenshot showing the smearing behavior is available (24-bit BMP showing the full terminal filled with repeated status lines). + +## Workaround + +None found. `display.spinner_token_flow: false` reduces line length but doesn't fix the underlying issue. Many thanks. This is likely a very simple change. -- David Small p.s. ff you need to contact me, davidmsmall (at) gmail.com",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85380",
          "createdAt": "2026-08-13T15:10:27Z",
          "updatedAt": "2026-08-13T15:16:34Z",
          "timestamp": "2026-08-13T15:16:34Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "P2",
            "needs-repro",
            "sweeper:risk-platform-windows",
            "platform/windows"
          ],
          "author": "davidmsmall-lab",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:4b1f9274389739418f8d",
        "signalId": "github:NousResearch/hermes-agent:pull_request:63899",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:63899",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(delegate): surface child's resolved toolsets in async completion block (#63887)",
          "text": "## What does this PR do? Async delegation records each subagent's full task source (goal, context, role, model) in the completion block that re-enters the parent conversation — but **not the toolsets the child actually held** after parent-intersection and blocked-tool stripping. Because of that, when a delegation goal structurally requires a toolset the child can never have (e.g. a Telegram parent whose `platform_toolsets` excludes `file`/`terminal` delegates *\"Write a bridge request file to ~/.hermes/bridge/requests/<name>.md\"*), dispatch proceeds silently. The child never has the tool to do the work, and a budget-tier child model then reports `completed` with a **fabricated verification narrative** (\"Confirmed file contents match via `cat`\") — nothing anywhere signals the capability mismatch. This PR implements the issue's suggested minimum: it records each subagent's **resolved** toolset list on its result entry and renders it per-subagent in the batch completion block, so the parent (and human) can see the child never held the required capability and catch the mismatch. The intersection rule itself is intentionally left unchanged — as the issue notes, \"the child correctly cannot have file tools\" is good design, not the bug. ## Related Issue Fixes #63887 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `tools/delegate_tool.py`: add the resolved `child_toolsets` (post parent-intersection + blocked-tool stripping) to each per-task result `entry` — both the normal and the exception-path entry. - `tools/process_registry.py`: in `_format_async_delegation`, render a `Toolsets available: …` line per subagent in the batch completion block. - `tests/tools/test_delegate_completion_block.py`: new test asserting the resolved toolsets appear per-task and the impossible capability is visibly absent from the rendered block. ## How to Test ``` scripts/run_tests.sh tests/tools/test_delegate_completion_block.py tests/tools/test_process_registry.py ``` Result: all pass. The new test reproduces the issue scenario — a `completed` child whose resolved toolset is `[\"todo\"]` — and asserts the block now surfaces `Toolsets available: todo` with `file` visibly absent, exposing the mismatch to the parent. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits (`fix(scope):`) - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix (no unrelated commits) - [x] I've run the affected tests and they pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 26 (Darwin 25.5) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A (no config keys) - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) — or N/A (pure string/dict rendering) - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/63899",
          "createdAt": "2026-07-13T18:04:12Z",
          "updatedAt": "2026-08-13T15:16:18Z",
          "timestamp": "2026-08-13T15:16:18Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "tool/delegate",
            "P2",
            "sweeper:risk-session-state",
            "sweeper:blast-contained"
          ],
          "author": "PRATHAMESH75",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:aac3063844e8d8d3aca4",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85292",
        "event": "changed",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [
          "text",
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85292",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cli): override MiniMax API-key overlay to Anthropic endpoint",
          "text": "## What does this PR do? The MiniMax **API-key** overlay advertises `anthropic_messages` but had no `base_url_override`. `get_provider()` therefore kept the models.dev OpenAI-style URL (`https://api.minimax.io/v1`). Worse, `hermes setup` persists that `/v1` URL into `model.base_url`, and the runtime pool path treated it as an explicit override of the registry `/anthropic` default — so every call 404'd. This PR: 1. Sets `base_url_override` on the API-key overlays (`minimax` / `minimax-cn`) to the same Anthropic endpoints as OAuth / `PROVIDER_REGISTRY`. 2. Remaps only the known stale catalog URLs (`api.minimax.io/v1`, `api.minimaxi.com/v1`) to `/anthropic` when resolving a MiniMax runtime. A user-set China or custom host is left alone. `MINIMAX_BASE_URL` still wins. ## Related Issue Fixes #84838 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_cli/providers.py`: set `base_url_override` on `HERMES_OVERLAYS[\"minimax\"]` and `[\"minimax-cn\"]`. - `hermes_cli/runtime_provider.py`: remap persisted `/v1` catalog defaults on both the pool and env-key MiniMax paths. - Tests: overlay vs fake models.dev `/v1`; runtime remaps persisted `/v1`; explicit custom `/anthropic` host is kept. ## How to Test 1. On `origin/main`, `HERMES_OVERLAYS[\"minimax\"].base_url_override` is empty. `get_provider(\"minimax\")` can inherit models.dev `https://api.minimax.io/v1`. 2. On this branch, `pytest tests/hermes_cli/test_api_key_providers.py::TestProviderRegistry::test_minimax_overlay_overrides_models_dev_openai_url tests/hermes_cli/test_api_key_providers.py::TestRuntimeProviderResolution::test_runtime_minimax -q` passes. 3. `MINIMAX_API_KEY=… hermes` with provider `minimax` and no custom `model.base_url` should talk to `https://api.minimax.io/anthropic`. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85292",
          "createdAt": "2026-08-13T12:55:34Z",
          "updatedAt": "2026-08-13T15:16:09Z",
          "timestamp": "2026-08-13T15:16:09Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "provider/minimax",
            "P3"
          ],
          "author": "Christopher-Schulze",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:eeea089b030d0af85007",
        "signalId": "github:NousResearch/hermes-agent:pull_request:62239",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:62239",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): unify local OpenAI-compatible provider aliases to custom (#62213)",
          "text": "## What does this PR do? Local OpenAI-compatible provider aliases (`vllm`, `llamacpp`, `llama.cpp`, `llama-cpp`) were normalized **inconsistently** across the three provider-name tables Hermes uses, which is the \"custom, local, custom:local\" confusion reported in the issue and made it hard to configure a working local endpoint from `config.yaml`: | alias | `hermes_cli.providers` | `hermes_cli.models` | `hermes_cli.auth` | |-------|------------------------|---------------------|-------------------| | `vllm` / `llamacpp` (before) | `local` (orphan id, no `ProviderDef`) | unchanged (`vllm` — unknown) | `custom` ✅ | | `ollama` (before) | `custom` | `custom` | `custom` | `auth.resolve_provider` already treated all of these as the generic `custom` provider (the documented intent — see the #27132 fix and `runtime_provider._config_base_url_trustworthy_for_bare_custom`), but the other two tables disagreed, so the model picker / web server saw an unknown provider and `providers.normalize_provider` produced an orphan `\"local\"` id that has no `ProviderDef`. This aligns all three tables on `\"custom\"` so a `provider: vllm` (or `llamacpp`) in `config.yaml` normalizes identically everywhere. Bare `local` is intentionally left untouched — it's a legitimate user-defined custom-provider *name* (matched in `resolve_runtime_provider`), not an alias. ## Related Issue Fixes #62213 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_cli/providers.py` — `ALIASES`: `vllm`/`llamacpp`/`llama.cpp`/`llama-cpp` now map to `custom` (were `local`); clarified the comment. - `hermes_cli/models.py` — `_ALIASES`: added `vllm`/`llamacpp`/`llama.cpp`/`llama-cpp` → `custom` (were absent, so left unresolved). - `tests/hermes_cli/test_local_provider_alias_parity.py` — new invariant test asserting these aliases resolve to `custom` in all three layers. ## How to Test ``` scripts/run_tests.sh tests/hermes_cli/test_local_provider_alias_parity.py tests/hermes_cli/test_runtime_provider_resolution.py ``` Result: parity test passes; the existing #27132 alias-routing tests still pass. Broader sweep (`test_model_normalize.py`, `test_provider_catalog.py`, `test_user_providers_model_switch.py`, `test_custom_provider_identity.py`, `test_web_server.py`, `test_custom_provider_model_switch.py`) — 680 tests, 0 failures. Manual check: ```python from hermes_cli.providers import normalize_provider as p from hermes_cli.models import normalize_provider as m from hermes_cli.auth import resolve_provider as a for x in (\"vllm\",\"llamacpp\",\"llama.cpp\",\"llama-cpp\",\"ollama\"): assert p(x) == m(x) == a(x) == \"custom\" ``` ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix - [x] I've run the affected tests and they pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 26.5 ### Documentation & Housekeeping - [x] I've updated relevant documentation (docstrings/comments) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A (no new config keys) - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A - [x] I've considered cross-platform impact (Windows, macOS) — N/A (pure string normalization) - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/62239",
          "createdAt": "2026-07-10T18:28:35Z",
          "updatedAt": "2026-08-13T15:13:44Z",
          "timestamp": "2026-08-13T15:13:44Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "area/config",
            "P3",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "PRATHAMESH75",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:25e4f85121ae42b38395",
        "signalId": "github:NousResearch/hermes-agent:pull_request:62373",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:62373",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(auth): reject scheme-downgraded portal_base_url in resolve_nous_access_token",
          "text": "## What does this PR do? `resolve_nous_access_token()` and `resolve_nous_runtime_credentials()` are twin allowlist checks on the same stored/network-sourced `portal_base_url` field (both `hermes_cli/auth.py`) — both POST the user's `refresh_token` bearer to this URL on refresh, so both must reject a poisoned value before using it. A same-day fix to `resolve_nous_runtime_credentials()` hardened the allowlist check to validate the URL **scheme**, not just the hostname (require `https`, or `http` only for `localhost`/`127.0.0.1`). That hardened one twin but not the other: `resolve_nous_access_token()` still only checks `parsed_portal_url.hostname not in _NOUS_PORTAL_ALLOWED_HOSTS`, so an allowlisted hostname served over plain HTTP (e.g. a cross-profile shared-state value poisoned to `http://portal.nousresearch.com`) passes through unchanged — sending the refresh_token bearer in cleartext, exploitable by a network-level MITM per the original `#27612`/`#30611` threat model (\"prevent bearer token exfiltration\"). This also closes a secondary gap: the old check only fired `if parsed_portal_url.hostname` (truthy), so a malformed `portal_base_url` with no parseable hostname passed through unvalidated. The new check rejects that too, matching the sibling function exactly. `resolve_nous_access_token()` is not an edge case — it backs `tools/managed_tool_gateway.py`, `hermes_cli/nous_account.py`, `hermes_cli/dashboard_register.py`, `hermes_cli/nous_billing.py`, the Chronos cron provider (`plugins/cron_providers/chronos/_nas_client.py`), and the relay self-provisioning enroll flow (`gateway/relay/__init__.py`). ## Related Issue N/A — found while investigating today's `resolve_nous_runtime_credentials()` scheme-validation commit and checking for sibling call sites reading the same `portal_base_url` field. Not a duplicate of open PR #57644, which targets a third, distinct function (`refresh_nous_oauth_pure`) and explicitly mirrors this function's *old* (pre-hardening) pattern rather than modifying it. ## Type of Change - [x] 🐛 Bug fix - [x] 🔒 Security fix ## Changes Made - `hermes_cli/auth.py`: `resolve_nous_access_token()`'s allowlist check now validates scheme in addition to hostname, mirroring `resolve_nous_runtime_credentials()` exactly. - `tests/hermes_cli/test_auth_nous_provider.py`: add `test_runtime_rejects_http_for_production_portal`. ## How to Test ``` pytest tests/hermes_cli/test_auth_nous_provider.py -v ``` All 74 tests in the file pass. Mutation-verified: reverting the fix reproduces the failure — `http://portal.nousresearch.com` is passed straight through to the refresh call instead of healing to `DEFAULT_NOUS_PORTAL_URL`. Also ran the full related test surface (`test_dashboard_register.py`, `test_nous_portal_staging_allowlist.py`, `test_nous_account.py`, `test_nous_inference_url_validation.py`, `test_auth_commands.py`, `test_billing_scope_stepup.py`, `test_proxy.py`, `test_status*.py`, `gateway/relay/test_identity_token_resolver.py`, `gateway/relay/test_self_provision.py`) — 209 passed, 1 skipped, no regressions. ## Checklist - [x] Read the Contributing Guide - [x] Conventional Commits format - [x] No duplicate PR - [x] Single logical change - [x] Tests added - [x] Cross-platform: N/A (pure Python URL parsing)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/62373",
          "createdAt": "2026-07-11T00:27:38Z",
          "updatedAt": "2026-08-13T15:08:52Z",
          "timestamp": "2026-08-13T15:08:52Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/security",
            "comp/cli",
            "provider/nous",
            "area/auth",
            "P2",
            "sweeper:risk-security-boundary",
            "sweeper:blast-moderate"
          ],
          "author": "pierrenode",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:12887f6a764e013d8ba1",
        "signalId": "github:NousResearch/hermes-agent:pull_request:82228",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:82228",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(weixin): add POST /api/weixin/send endpoint",
          "text": "POST /api/weixin/send — stateless HTTP send for alert-bridge/cron. - Auth via API_SERVER_KEY - Input validation: Content-Type (415), chat_id format (400), message length (400) - JSON body shape validation (rejects non-dict payloads) - Profile adapter resolution (named-profile support via _api_request_profile ContextVar) - Weakref for runner reference (avoids circular reference cycle) - Audit logging: [weixin-send-audit] - 19 unit tests Closes #70892",
          "url": "https://github.com/NousResearch/hermes-agent/pull/82228",
          "createdAt": "2026-08-09T03:47:52Z",
          "updatedAt": "2026-08-13T15:08:22Z",
          "timestamp": "2026-08-13T15:08:22Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/feature",
            "comp/gateway",
            "platform/wecom",
            "area/auth",
            "P2",
            "sweeper:risk-message-delivery",
            "sweeper:risk-security-boundary"
          ],
          "author": "HouMinXi",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:469b784cc2a9b4cd1a6e",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85376",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85376",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "test(state): trace the pooled read connection in FTS projection coverage",
          "text": "## Summary - prime the pooled read path before attaching SQLite trace callbacks - trace the same connection that `_read_ctx()` will borrow during FTS searches - restore deterministic query-count coverage for context projection ## Root cause The test opened a fresh read connection with `_get_read_conn()` and attached a trace callback to it. Since the read path is now pooled, that connection was not returned to the pool and the searches borrowed a different connection. The context enrichment queries executed correctly, but the test observed zero statements. Using `_read_ctx()` once primes and returns the connection to the pool before tracing it, so the subsequent searches reuse the observed connection. ## Verification Baseline on current `main`: ```text 1 failed: TestFTS5Search::test_search_projection_skips_context_enrichment_queries ``` After this change: ```text 1 passed 222 passed in tests/test_hermes_state.py ruff check tests/test_hermes_state.py: All checks passed ``` Production code is unchanged.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85376",
          "createdAt": "2026-08-13T14:58:17Z",
          "updatedAt": "2026-08-13T15:08:12Z",
          "timestamp": "2026-08-13T15:08:12Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/test",
            "comp/agent",
            "P3"
          ],
          "author": "yuzilongleif-collab",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:dba4a7c6cc7e158ea742",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85375",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85375",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(hindsight): show embedded config in desktop",
          "text": "## Summary Fixes #84572 by making Desktop represent existing Hindsight `local_embedded` configuration honestly without bypassing the setup flow that installs its runtime dependencies. - preserves an existing `local_embedded` mode as a disabled, read-only select option instead of displaying the Cloud default - exposes the embedded LLM provider, base URL, model, and secret fields in Full config - maps the embedded LLM secret to `HINDSIGHT_LLM_API_KEY` - keeps selecting `local_embedded` setup-owned, while allowing edits to an already configured embedded installation - adds backend and Desktop behavior coverage ## Root cause The generic declared-config serializer replaced stored select values outside a surface's write allow-list with the field default. Hindsight intentionally omits `local_embedded` from Desktop's writable Mode options because `hermes memory setup` owns dependency installation, but that caused a valid setup-created value to render as Cloud. The declared schema also omitted the embedded LLM fields and secret mapping. ## User impact Users with Hindsight Local Embedded configured through setup now see their actual mode and LLM settings in Desktop and can edit those settings safely. Desktop still cannot create a partially installed embedded configuration. ## Validation - `152 passed, 1 skipped` across the current Hindsight schema and full web-server test files - `10 passed` in the Desktop provider-config panel test - Ruff passed - Prettier 3.9.5 passed - `git diff --check` passed",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85375",
          "createdAt": "2026-08-13T14:57:35Z",
          "updatedAt": "2026-08-13T15:08:10Z",
          "timestamp": "2026-08-13T15:08:10Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/plugins",
            "tool/memory",
            "area/config",
            "P3",
            "sweeper:risk-compatibility",
            "comp/desktop",
            "area/memory"
          ],
          "author": "aikeepsitreal",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e817ef83003c78748c01",
        "signalId": "github:NousResearch/hermes-agent:issue:85377",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85377",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Desktop build fails on Linux: get-windows npm install fails (404 prebuilt + gyp ModuleNotFoundError in venv)",
          "text": "## Bug Description During `hermes update`, the desktop app rebuild fails because the `get-windows@9.3.0` npm dependency cannot install on Linux. This causes the Electron GUI to be unavailable until the build eventually succeeds (or never, if the conditions persist). The failure chain: 1. `node-pre-gyp` tries to download a prebuilt binary for `get-windows` → **404 Not Found** (no Linux prebuilt exists) 2. Falls back to `node-gyp` source compilation → **fails** because `gyp_main.py` runs under the Hermes venv Python (`~/.hermes/hermes-agent/venv/bin/python3`), which does **not** have the `gyp` Python package installed 3. `npm ci` exits with code 1 → desktop build is skipped ## Steps to Reproduce 1. Install Hermes Agent on Linux (Ubuntu 26.04, Node v22.22.1) via git method 2. Run `hermes update` when there are upstream desktop changes that trigger a rebuild 3. The update pulls code successfully, but the desktop build step fails ## Expected Behavior The desktop app should rebuild successfully on Linux during `hermes update`, or `get-windows` should be handled gracefully (it's a Windows/macOS-only package — no Linux prebuilt binary exists and source compilation depends on `gyp` being available in the active Python environment). ## Actual Behavior `npm ci` fails with:npm ERR! code 1 npm ERR! path node_modules/get-windows npm ERR! command failed npm ERR! command sh -c node-pre-gyp install --fallback-to-build 1. Prebuilt binary download fails: npm ERR! node-pre-gyp http GET [github.com/sindresorhus/get-windows/releases/download/v9.3.0/napi-9-linux-glibc-x64.tar.gz](https://github.com/sindresorhus/get-windows/releases/download/v9.3.0/napi-9-linux-glibc-x64.tar.gz) npm ERR! node-pre-gyp ERR! install response status 404 Not Found 2. node-gyp fallback fails: npm ERR! gyp info find Python using Python version 3.11.15 found at \".../venv/bin/python3\" npm ERR! Traceback (most recent call last): npm ERR! File \"/usr/share/nodejs/node-gyp/gyp/gyp_main.py\", line 5, in npm ERR! from gyp import script_main npm ERR! ModuleNotFoundError: No module named 'gyp' npm ERR! gyp ERR! configure error ✗ Desktop dependency install failed Run manually: cd ... && npm ci ⚠ Desktop build failed (non-fatal; run hermes desktop to retry) The build failure is marked \"non-fatal\", but in practice the GUI goes down: The running Electron process is killed when the gateway restarts during update The new binary is not built, so hermes desktop cannot launch desktop.log shows ~351 backend boot cycles (repeated spawn → port assignment → immediate restart) until the build eventually succeeds on a later retry Root Cause Two compounding issues: get-windows has no Linux prebuilt binary. The package (by sindresorhus) only publishes Windows and macOS binaries. The napi-9-linux-glibc-x64.tar.gz release asset returns 404. On Linux, get-windows is functionally useless (it's for getting the active window title on Win/macOS). node-gyp picks up the Hermes venv Python, which lacks gyp. When node-pre-gyp falls back to source compilation, node-gyp discovers the Hermes virtualenv Python and uses it to run gyp_main.py. The venv doesn't include the gyp package, so the build crashes immediately. Environment OS: Ubuntu 26.04 LTS (Linux 7.0.0-29-generic, x86_64) Node.js: v22.22.1 (system, node-v127 ABI) npm: bundled with Node Python: 3.11.15 (Hermes venv) Hermes Agent: v0.20.0 (git install, commit fa83af3f) Electron: 40.10.2 Suggested Fixes Skip get-windows on Linux in the desktop build pipeline (it's a no-op package on this platform). The stage-native-deps script already stages it but with an empty .node binding. Or: Make get-windows an optional dependency (optionalDependencies in package.json) so its install failure doesn't break npm ci. Or: If the dependency must stay, ensure the Python environment used by node-gyp has gyp installed (e.g., pip install gyp into the venv, or point npm_config_python to a system Python that has it).",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85377",
          "createdAt": "2026-08-13T15:01:55Z",
          "updatedAt": "2026-08-13T15:08:01Z",
          "timestamp": "2026-08-13T15:08:01Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "P2",
            "needs-repro",
            "sweeper:risk-compatibility",
            "comp/desktop",
            "area/install-update"
          ],
          "author": "hageliezioula",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ecc43341e111c998cfe1",
        "signalId": "github:NousResearch/hermes-agent:pull_request:67944",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:67944",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway): scope GET /api/config to config-only profile to unwedge the event loop (#67936)",
          "text": "## What does this PR do? `GET /api/config` is an `async def` handler that runs on the asyncio event-loop thread, but it entered `_profile_scope(profile)` — the skills-aware scope that acquires the process-global `_SKILLS_PROFILE_LOCK` **synchronously**. When a worker thread holds that lock (e.g. slow model-option / skills discovery during Desktop setup), the event loop blocks inside `get_config()` and can no longer flush queued JSON-RPC/WebSocket responses or serve HTTP probes — remote Desktop startup times out at \"Loading Hermes settings\". `get_config()` only resolves configuration through the task-local `HERMES_HOME` override; it never touches the skills module globals that `_SKILLS_PROFILE_LOCK` protects. The sibling `get_schema()` handler directly below it already uses the await-safe, config-only `_config_profile_scope()`. This aligns `get_config()` with that pattern. ## Related Issue Fixes #67936 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_cli/web_server.py`: `get_config()` now uses `_config_profile_scope(profile)` instead of `_profile_scope(profile)`, so the async handler never waits on `_SKILLS_PROFILE_LOCK`. Added a comment explaining the event-loop hazard. - `tests/hermes_cli/test_web_server_profile_unification.py`: new `TestConfigEndpointNonBlocking` regression — while another thread holds `_SKILLS_PROFILE_LOCK`, `get_config()` still returns promptly. On the old `_profile_scope()` path this blocked for the full lock-hold duration. ## How to Test 1. `scripts/run_tests.sh tests/hermes_cli/test_web_server_profile_unification.py -q` → passes (includes the new `TestConfigEndpointNonBlocking::test_get_config_does_not_wait_on_skills_lock`). 2. Manual: hold `_SKILLS_PROFILE_LOCK` in a background thread and call `asyncio.run(web_server.get_config())` — returns in ~0.001s with the fix vs. the full hold duration before it. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix - [x] I've run the affected tests and they pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS (arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — N/A (internal handler scope change) - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — N/A - [x] I've considered cross-platform impact — fix is platform-independent (asyncio + threading lock semantics)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/67944",
          "createdAt": "2026-07-20T08:38:48Z",
          "updatedAt": "2026-08-13T15:07:33Z",
          "timestamp": "2026-08-13T15:07:33Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "P2",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate",
            "comp/dashboard",
            "area/profiles"
          ],
          "author": "PRATHAMESH75",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:49406a6622da518fb085",
        "signalId": "github:NousResearch/hermes-agent:pull_request:61982",
        "event": "discovered",
        "observedAt": "2026-08-13T16:19:22.035158Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:61982",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat: add sanitized Kanban REST API",
          "text": "# Sanitized Kanban REST API for external control planes ## Summary Adds a narrow, authenticated, safe-by-default REST API over the existing Hermes Kanban store, mounted at `/api/plugins/kanban`. It gives external control planes a stable integration boundary for creating, routing, inspecting, and managing Kanban tasks — without reading raw SQLite state, touching internal dashboard routes, or executing profiles directly. Design constraints, all preserved end to end: - Reuses `hermes_cli.kanban_db` — same database, WAL/transaction behavior, task state machine, dependency promotion rules, and dispatcher lifecycle. No second schema, no second queue. - No profile-execution endpoint. Work is routed by task assignment; the existing gateway dispatcher claims and launches it. - Sanitized DTOs only — never raw internal rows. - No product-specific workflow types or business logic. ## API surface | Area | Endpoints | |---|---| | Status | `GET /health`, `GET /capabilities` | | Boards | `GET /boards`, `GET /boards/{id-or-name}` | | Profiles | `GET /profiles` — sanitized assignee roster (read-only) | | Tasks | `GET /tasks`, `POST /tasks`, `GET /tasks/{id}`, `PATCH /tasks/{id}` | | Actions | `POST /tasks/{id}/comment`, `/complete`, `/block`, `/unblock`, `/archive` | | Dependencies | `POST /tasks/{parent}/links/{child}`, `DELETE /tasks/{parent}/links/{child}` | | Observability | `GET /tasks/{id}/events`, `/runs`, `/log` | All request models are strict Pydantic (`extra=\"forbid\"`); unknown fields are rejected. ## Authentication External controllers authenticate with a dedicated service credential, wired through the dashboard-auth framework rather than bolted onto the routes: - The generic token-auth seam gains **prefix registration** (exact-path matching cannot cover parameterised routes) and **required scopes**: a verified principal lacking the route's scope is refused with 403 + an audit event. With multiple service credentials stacked, one credential can no longer open another surface's routes — the drain secret cannot drive the kanban API and vice versa. - A new bundled `dashboard_auth` plugin (mirroring the drain plugin) verifies `Authorization: Bearer $HERMES_KANBAN_API_SECRET` with a constant-time compare and vouches for a `kanban`-scoped principal. A weak/short/low-entropy secret (< 256 bits) fails closed at load; the shared entropy gate now lives in `dashboard_auth.secret_strength` and is reused by both plugins. - The interactive dashboard subtree (see below) is excluded from the token seam and stays on cookie/session auth. Without the secret set, the plugin is a no-op and prior behavior is unchanged. ## Idempotency (concurrency-safe) `POST /tasks` accepts an `Idempotency-Key` header or `idempotency_key` body field. A repeat returns the existing live task with HTTP 200 and `created: false`; a fresh insert returns HTTP 201 and `created: true`. The guarantee holds under concurrency and is enforced at the storage layer: - A **partial UNIQUE index** on `idempotency_key` (live, non-archived tasks; legacy duplicates deduped during migration) closes the check-then-insert race. Archiving a task frees its key. - `create_task_idempotent` resolves a lost race to the winner's row instead of surfacing `IntegrityError` — every caller (CLI, tools, dispatcher, REST) gets one task id, satisfying the multi-process stress contract (`idempotency_key_race`). ## Sanitization Task and run DTOs expose workflow state plus routing attribution — `assignee`, `created_by` (which profile/surface created the card), and the executing `profile` per run — and deliberately omit: task bodies and results, comment text, workspace paths, branch names, claim locks, worker PIDs, session IDs, idempotency keys, run summaries/metadata/errors, and raw event payloads. `GET /profiles` returns only `name`, `description`, and `has_description` — no models, providers, paths, env, or skill inventories. `GET /tasks/{id}/log` returns a bounded excerpt (8 KiB default, 32 KiB max) after Hermes secret redaction, `Authorization`-header scrubbing, and absolute-path replacement with `[PATH]`. Error details follow an allowlist: known-safe validation messages pass through verbatim; anything else collapses to a stable generic detail with the raw error logged server-side only. ## Dashboard compatibility The first-party dashboard needs a richer API than should be exposed externally, so its existing routes are namespaced under `/api/plugins/kanban/dashboard` (bundled dashboard JS updated). Both routers share the same `kanban_db` implementation. Third-party consumers of the previously undocumented dashboard routes should migrate to the sanitized API or adopt the new namespace. ## Documentation `website/docs/user-guide/features/kanban-rest-api.md` — credential provisioning, the full endpoint contract, idempotency semantics, and a worked example (parent operation → dependent children → dependency links → completion → polling sanitized events/runs/logs). ## Testing - Affected suites (REST adapter, kanban store, token-auth seam, auth middleware, both auth plugins): ```bash python -m pytest tests/hermes_cli/test_kanban_api.py tests/hermes_cli/test_kanban_db.py \\ tests/hermes_cli/test_dashboard_token_auth.py tests/hermes_cli/test_dashboard_auth_middleware.py \\ tests/plugins/dashboard_auth/ -q # 509 passed - Auth is covered end-to-end through the real mounted dashboard app in gated mode: valid bearer drives the external surface, missing/wrong token → 401, a foreign-scoped service credential → 403, and the dashboard subtree never accepts the bearer. - Idempotency race coverage: a deterministic lost-race regression at the storage layer, a threaded concurrent-create regression, and the multi-process stress scenario. Exercised live as well: 6 concurrent POST /tasks with one key → exactly one 201 + five 200s, one row persisted. - Full atypical stress suite on this branch: python tests/stress/test_atypical_scenarios.py # 28 scenarios, 0 failures, 0 skips ▎ Note: one commit (test(stress): catch atypical scenarios up to current kanban contracts) repairs two stress ▎ scenarios that fail on current main independently of this PR — they had gone stale against the spawn_failures → ▎ consecutive_failures rename (#20410) and the archived-parents-are-terminal dependency fix. Included so the suite is ▎ green on this branch; happy to split it into its own PR. Out of scope Profile execution or configuration endpoints, raw worker/session state, product-specific workflow types, and any second Kanban schema or storage layer. Follow-ups - Cursor pagination for large boards - Finer-grained RBAC within the kanban scope (e.g. read-only credentials) - Explicit API versioning if additional external contracts are introduced",
          "url": "https://github.com/NousResearch/hermes-agent/pull/61982",
          "createdAt": "2026-07-10T09:25:51Z",
          "updatedAt": "2026-08-13T15:05:51Z",
          "timestamp": "2026-08-13T15:05:51Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/feature",
            "comp/cron",
            "comp/plugins",
            "P3",
            "sweeper:risk-security-boundary",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "andriy4k07",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:0674ce4e9495d418931b",
        "signalId": "github:NousResearch/hermes-agent:pull_request:67667",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:67667",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(codex): preserve azure reasoning replay ids with hostname-aware matching",
          "text": "## What does this PR do? Fixes Azure AI Foundry/OpenAI Responses replay failures by preserving reasoning item `id` fields only on Azure Foundry-style endpoints, while keeping the existing OpenAI/Codex behavior that strips those ids from general Responses replay. Based on the original PR #63264 by @LeonSGP43. This version applies the review feedback from @teknium1: uses `base_url_host_matches()` from `utils.py` instead of bare `in` substring matching to avoid false-positive Azure detection when Azure domain text appears in a relay/proxy URL path. ## Related Issue Closes #63257 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Preserve replayed reasoning `id` fields for Azure AI Foundry/OpenAI Responses endpoints in `agent/codex_responses_adapter.py` - Thread Azure endpoint detection through `agent/transports/codex.py`, `agent/conversation_loop.py`, `agent/chat_completion_helpers.py`, `agent/auxiliary_client.py`, and `run_agent.py` - Uses `base_url_host_matches()` (hostname-aware) instead of substring `in` matching for security — prevents relay URLs with \"services.ai.azure.com\" in their path from false-positive detection - Add regression tests covering adapter replay normalization, transport build/preflight behavior, and auxiliary Codex replay behavior ## How to Test 1. Reproduce with an Azure AI Foundry/OpenAI Responses endpoint and a prior assistant turn containing replayable `codex_reasoning_items` 2. Run `uv run pytest tests/agent/test_codex_responses_adapter.py tests/agent/transports/test_codex_transport.py tests/agent/test_auxiliary_client.py -q` 3. Confirm replayed reasoning items sent to Azure retain `{type, id, encrypted_content, summary}` while non-Azure Responses paths continue stripping the reasoning `id` ## Checklist - [x] Bug fix - [x] Tests added - [x] Uses `base_url_host_matches()` per review feedback on PR #63264",
          "url": "https://github.com/NousResearch/hermes-agent/pull/67667",
          "createdAt": "2026-07-19T20:19:00Z",
          "updatedAt": "2026-08-13T17:43:30Z",
          "timestamp": "2026-08-13T17:43:30Z",
          "metrics": {
            "reactions": 0,
            "comments": 9
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/openai",
            "P2",
            "needs-decision",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "vidarak",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ed353b35ec2d0d914164",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85487",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85487",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(kanban): explicit notify/wake delivery modes with faithful wake session routing (salvage #37865)",
          "text": "## Summary Kanban notify subscriptions gain explicit delivery modes — `notify`, `notify+wake`, `wake` — and woken turns now land in the creator's REAL session instead of a context-less parallel one. Salvage of #37865 by @verybigdog onto current main (authorship preserved; original commits carried a local placeholder identity, re-attributed to the contributor's public email), plus one follow-up fix of ours. ## Changes - `hermes_cli/kanban_db.py`: `delivery_mode` + `chat_type` + `user_id_alt` columns with idempotent migration; child tasks inherit the parent's notify return path (ACK-edge inheritance) - `gateway/kanban_watchers.py`: wake gated on `delivery_mode` instead of unconditional; passive send suppressed in pure-`wake` mode - `gateway/session_context.py` / `tools/kanban_tools.py`: persist `chat_type` and alt user id so the wake replay reconstructs the same `build_session_key`; gateway autosubscribes as `notify+wake`, TUI as `notify` - `hermes_cli/kanban.py`: `--delivery-mode` CLI flag; `gateway/slash_commands.py` autosubscribe; docs updated - **Follow-up (ours):** migration backfills pre-existing gateway subscriptions to `notify+wake` on first-add of the column — before this column existed, wake was unconditional, so defaulting legacy rows to `notify` would have silently disabled active wake on upgrade. Backfill runs only on first-add; explicit downgrades survive re-migration. Sabotage-verified regression tests. ## Validation | | Before | After | |---|---|---| | wake session key | could land context-less (user_id vs alt id divergence) | reconstructs creator's session | | legacy gateway subs after upgrade | silently lose wake | backfilled to notify+wake | | targeted tests | — | 60/60 pass (kanban_notify + kanban_tools + kanban_notifier) | Supersedes #37865; closes it with credit. Triage previously anointed #37865 canonical over dupes #49593/#49749/#49751. ## Infographic ![Kanban delivery modes](https://files.catbox.moe/se1zot.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85487",
          "createdAt": "2026-08-13T17:31:01Z",
          "updatedAt": "2026-08-13T17:43:26Z",
          "timestamp": "2026-08-13T17:43:26Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "comp/gateway",
            "comp/cron",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e97fcb03d2661c2e3696",
        "signalId": "github:NousResearch/hermes-agent:pull_request:41351",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:41351",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(acp): resolve symlink targets when guarding sensitive edit auto-approval",
          "text": "## What does this PR do? The ACP edit-approval guard promises that sensitive files (`.env`, `.ssh`, `.git`, `id_rsa`, `id_ed25519`) always prompt the user, even when a session runs under an autonomous policy (`session` / `workspace_session`). It did not hold for symlinks. `should_auto_approve_edit` resolves the path with `Path(...).resolve()` for the workspace-containment check, but `_is_sensitive_auto_approve_path` inspected only the *unresolved* literal path. So a symlink with a harmless name — e.g. `notes.txt` -> `~/.ssh/authorized_keys`, or `link.txt` -> `.env` inside the workspace — passed the sensitive check and was silently auto-approved, writing straight through to the protected file. A repo can ship such a symlink (git tracks them), so an autonomous editor session could have a secret overwritten without a prompt. Before: `_is_sensitive_auto_approve_path(\"link.txt\")` looks at the name `link.txt`, sees nothing sensitive, returns `False` -> auto-approved. After: the guard also checks `link.txt`'s `resolve()`d target (`.env`), matches the sensitive set, returns `True` -> the edit prompts instead of auto-approving. ## Related Issue N/A ## Type of Change - [x] 🔒 Security fix ## Changes Made - `acp_adapter/edit_approval.py`: split the sensitivity test into a reusable `_path_is_sensitive(Path)` helper and make `_is_sensitive_auto_approve_path` check both the expanded literal path and its `resolve(strict=False)` target, so an innocently named symlink can no longer launder a write to a protected file. `resolve()` failures (e.g. symlink loops) fall back to treating only the literal path, matching the prior conservative behavior. - `tests/acp/test_edit_approval.py`: add `test_symlink_to_sensitive_file_is_not_auto_approved`, covering a workspace symlink to `.env` (under both `session` and `workspace_session`) and to `.ssh/authorized_keys`, asserting none are auto-approved. ## How to Test 1. `scripts/run_tests.sh tests/acp/test_edit_approval.py` — the new `test_symlink_to_sensitive_file_is_not_auto_approved` passes; `test_workspace_auto_approval_allows_workspace_and_tmp_but_not_sensitive` still passes (real workspace files keep auto-approving). 2. Manually: in a workspace, `ln -s .env link.txt`, drive an ACP session under `workspace_session`/`session` policy, and confirm an edit to `link.txt` now raises an approval prompt instead of being auto-applied. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits (`fix(scope):`, etc.) - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains only changes related to this fix - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin 25.5.0) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/41351",
          "createdAt": "2026-06-07T15:03:25Z",
          "updatedAt": "2026-08-13T17:43:25Z",
          "timestamp": "2026-08-13T17:43:25Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/security",
            "comp/acp",
            "P2",
            "sweeper:risk-security-boundary",
            "sweeper:blast-moderate"
          ],
          "author": "Vesna-9",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:5437c27bf6907112c8ca",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85488",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85488",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(streaming): kimi-coding header-read timeout + anthropic routing",
          "text": "## Summary A `kimi-coding` streaming request blocked **forever** in httpcore header read — no timeout applied, fallback never engaged, the run had to be killed externally. ## Root cause Two layers: (1) the keepalive HTTP clients had `httpx.Timeout(read=None)` as a floor (per-request timeout still applied for most providers, but the floor made any path that skipped it unbounded); (2) `kimi-coding` was not routed through `anthropic_messages` in the agent_init chain — the request went over OpenAI wire against `/coding`, which never responds with headers. ## Change (defense-in-depth) - `agent/process_bootstrap.py` + `run_agent.py`: keepalive client `read=None` → `read=300.0` (fail-closed; per-request still overrides). - `agent/agent_init.py`: kimi `/coding` detection → `anthropic_messages` routing (the CLI runtime already resolved correctly; the agent_init chain didn't). - `tests/agent/test_kimi_stream_header_read_timeout_85446.py` (new, +8): header-read blocking → APITimeoutError (2.3s repro confirmed empirically); routing tests. ## Verification - `tests/agent/test_kimi_stream_header_read_timeout_85446.py`: **8 passed**. Empirical repro showed the per-request timeout fires (APITimeoutError in 2.3s) once routing is correct. The issue's `api_mode: anthropic_messages` workaround still works and is now redundant. Closes #85446",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85488",
          "createdAt": "2026-08-13T17:31:05Z",
          "updatedAt": "2026-08-13T17:43:20Z",
          "timestamp": "2026-08-13T17:43:20Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/kimi",
            "P2"
          ],
          "author": "webtecnica",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:dc40cde03ec0bfa1d859",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85489",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85489",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(image-gen): add Azure Foundry backend provider plugin",
          "text": "## Summary Adds Azure Foundry as a backend provider for the `image_generate` tool. Organizations using Azure-hosted GPT Image deployments can now keep image generation in-platform instead of switching out. ## Change (plugin, per Contributing guide) - `plugins/image_gen/azure-foundry/` (new): `plugin.yaml` (backend manifest, `requires_env: AZURE_FOUNDRY_IMAGE_API_KEY`) + `__init__.py` (`ImageGenProvider` + `register(ctx)`, 552 lines) — follows the existing `plugins/image_gen/<name>/` pattern. Auth via the existing `agent/azure_identity_adapter.build_token_provider()` (callable accepted by the OpenAI SDK as `api_key`). - `tests/plugins/image_gen/test_azure_foundry_provider.py` (new, +37): provider registration, env-gating, Entra ID auth wiring, payload shape. ## Verification - `tests/plugins/image_gen/test_azure_foundry_provider.py`: **37 passed**. Zero modifications to existing files (3 new files only). Closes #85448",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85489",
          "createdAt": "2026-08-13T17:31:27Z",
          "updatedAt": "2026-08-13T17:43:15Z",
          "timestamp": "2026-08-13T17:43:15Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/plugins",
            "tool/vision",
            "provider/openai",
            "P3"
          ],
          "author": "webtecnica",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:7c785d5b764380f1206d",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85490",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85490",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(whatsapp): group history backfill via channel_context",
          "text": "## Problem In a mention-gated WhatsApp group (`require_mention: true`), messages that don't address the bot never reach the session transcript. When someone finally @-mentions the bot, it answers **blind to the conversation around it** — it can't even see the message immediately above the one that tagged it. Every other group-capable platform already solves this: - **Discord** fetches recent channel history and attaches it as `MessageEvent.channel_context` (`_fetch_channel_context`). - **Slack** does the same for thread backfill, with per-user watermarks. - **Telegram** has `observe_unmentioned_group_messages`. WhatsApp had neither mechanism — and no history API to fetch from. ## Approach Since Baileys offers no history fetch, the adapter **buffers the group messages its own mention gate skips** (bounded, in-memory, per chat) and replays them on the next triggered message through the existing `channel_context` contract. No core changes — `run.py` already prepends `channel_context` above the trigger message for every platform. ``` [Recent group messages] [Alice] has anyone seen the plumber? [Bob] the pool reopens sunday [New message] [Dana] @bot what did I miss? ``` Design decisions, each anchored to an existing precedent: - **Per-(chat, sender) watermarks** — WhatsApp group sessions are per-sender by default (`group_sessions_per_user: true`), so each participant's session catches up independently; a message is injected at most once per participant. Mirrors the Slack thread-watermark pattern. - **Output shape mirrors Discord** — `[Recent group messages]` header, `[name] text` lines, `[unverified]` tags (via the adapter's configured `_is_sender_authorized` check) with the same background-only preamble Discord uses. - **Prompt-injection guard** — sender display names (user-controlled `pushName`) are collapsed with `neutralize_untrusted_inline_text`, the same guard `run.py` applies to the trigger message's sender prefix. - **Only mention-gated messages are buffered** — messages that triggered the bot are already in a transcript; broadcasts, disallowed groups, and DMs are dropped exactly as before. - **Config keys copied from Discord**: `history_backfill` (default `true`) / `history_backfill_limit` (default `50`), plus `WHATSAPP_HISTORY_BACKFILL(_LIMIT)` env fallbacks, bridged from the `whatsapp:` YAML block like the existing group keys. **Default-on is behavior-neutral for existing deployments**: the buffer only fills and only injects when `require_mention` is on, which is off by default for WhatsApp. Happy to flip the default to opt-in if you'd prefer the Telegram convention over the Discord one here. ## Docs `whatsapp.md` previously didn't document groups at all — this adds a \"Group Chats\" section (`group_policy` / `group_allow_from` / `require_mention` / `mention_patterns` / `free_response_chats`) including the new backfill, plus `environment-variables.md` entries for the two new env vars. ## Tests `tests/gateway/test_whatsapp_group_history_backfill.py` (12 tests, style of `test_whatsapp_group_gating.py`): recording + replay round-trip, watermark semantics across senders, ring-buffer bound, per-chat isolation, broadcast/disallowed-group/DM exclusion, media placeholder, disabled-via-config, no-injection without a mention gap, sender-name neutralization, `[unverified]` tagging, adapter wiring through `_build_message_event`, and YAML→extra config bridging. ``` tests/gateway -k whatsapp: 192 passed, 4 skipped ruff check (changed files): clean ``` ## Future work (out of scope here) - Recording the bot's own group replies as `[bot]` lines so other participants' sessions see answered questions (Discord includes bot lines; needs an outbound-path hook). - The same buffer could back the Cloud API adapter once it gains group support. 🤖 Generated with [Claude Code](https://claude.com/claude-code)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85490",
          "createdAt": "2026-08-13T17:31:55Z",
          "updatedAt": "2026-08-13T17:43:11Z",
          "timestamp": "2026-08-13T17:43:11Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/gateway",
            "comp/plugins",
            "platform/whatsapp",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery"
          ],
          "author": "NehoraiHadad",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:b85f042360a467b74055",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85493",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85493",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): map subagent timeout/error completion to terminal failed state",
          "text": "## Summary Timed-out (or exception-failed) subagents stay stuck as **\"running\"** in the desktop Agents panel and status-bar count forever — spinner glyph, counted in \"Agents N running\", never pruned — even though the parent conversation already received the failure. ## Root cause Status-vocabulary drift between the Python backend and the desktop store. The backend completes subagents with statuses the UI never learned: - `tools/delegate_tool.py:2479-2488` — hard child timeout (`delegation.child_timeout_seconds`) emits `subagent.complete` with `status=\"timeout\"` (status introduced in #13770) - `tools/delegate_tool.py:2485` — non-timeout exception exit emits the same event with `status=\"error\"` The desktop store's `asStatus()` (apps/desktop/src/store/subagents.ts) is an allowlist of `completed | failed | interrupted | queued`; anything else falls through to `'running'`. A stuck `running` row is never cleaned up because `pruneFinishedSessionSubagents` only prunes terminal-status rows at the `message.start` boundary, and `activeSubagentCount` keeps counting it in the status bar. ## Changes - **`apps/desktop/src/store/subagents.ts`** - Map backend `\"timeout\"` / `\"error\"` completion statuses to the existing terminal `failed` status — the row renders the failed glyph, counts as failed, and is pruned like any other finished row. No type/i18n/glyph surface changes needed. - Synthesize a `Timed out after Xs` summary (from the backend's `duration_seconds`, which it already sends) when the raw status is `timeout` and no summary arrived, so the row explains why it failed instead of rendering as a bare failure. - **`apps/desktop/src/store/subagents.test.ts`** — timeout → terminal failed + pruned, error → failed, unknown status stays `running` (fall-through guard). ## Testing - `npx vitest run src/store/subagents.test.ts` — **13 passed** (10 pre-existing + 3 new) - `npx eslint src/store/subagents.ts src/store/subagents.test.ts` — clean ## Notes **Open question:** a distinct `timeout` status with its own glyph/label (instead of mapping to `failed`) would be richer UI, but is a much larger surface change — type union, glyph mapping, i18n strings, status-bar counts — for the same user-visible outcome. This PR keeps the narrow mapping; the synthesized summary line preserves the reason. Closes #85492",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85493",
          "createdAt": "2026-08-13T17:32:59Z",
          "updatedAt": "2026-08-13T17:43:09Z",
          "timestamp": "2026-08-13T17:43:09Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "duplicate",
            "tool/delegate",
            "P2",
            "sweeper:risk-session-state",
            "comp/desktop"
          ],
          "author": "DavidMetcalfe",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:186f405b8dd9a2938f91",
        "signalId": "github:NousResearch/hermes-agent:issue:69371",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:69371",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Windows: TestGitBashCoreutilsOnPath layout tests compare native-separator paths against forward-slash literals",
          "text": "**Version:** v2026.7.20 (also present on current `main`) **Platform:** Windows 11, Python 3.11, pytest run from a native Windows checkout ## Summary `tests/tools/test_local_env_windows_msys.py::TestGitBashCoreutilsOnPath::test_derives_dirs_from_portablegit_layout` and `::test_derives_dirs_from_mingit_usr_bin_layout` fail on any Windows checkout. Both tests assert forward-slash string literals (e.g. `\"/pg/usr/bin\"`) against the output of `_git_bash_bin_dirs()`, but that function builds its candidate dirs with `os.path.join`, which emits native separators. On Windows the derived dirs come back as mixed-separator strings like `/pg\\usr\\bin`, so the `in` assertions fail: ``` AssertionError: assert '/pg/usr/bin' in ['\\\\pg\\\\mingw64\\\\bin', '\\\\pg\\\\usr\\\\bin', '\\\\pg\\\\bin'] ``` Note the irony: these are the *Windows MSYS* tests, but they only pass on POSIX hosts. The test class already anticipates this problem for `isdir` — its own `_fake_isdir` helper normalizes separators before comparing (`existing = {e.replace(\"\\\\\", \"/\") for e in existing}`). The assertions just need the same treatment on the output side. ## Reproduction On any Windows machine: ``` python -m pytest tests/tools/test_local_env_windows_msys.py::TestGitBashCoreutilsOnPath -x ``` ## Suggested fix Mirror `_fake_isdir`'s normalization with a small helper and wrap the two calls: ```diff --- a/tests/tools/test_local_env_windows_msys.py +++ b/tests/tools/test_local_env_windows_msys.py @@ -341,6 +341,13 @@ class TestGitBashCoreutilsOnPath: existing = {e.replace(\"\\\\\", \"/\") for e in existing} return lambda p: p.replace(\"\\\\\", \"/\") in existing + @staticmethod + def _norm(dirs): + # os.path.join emits native separators, so on Windows the derived + # dirs mix the fixture's \"/\" root with \"\\\" joins; compare + # separator-agnostically, like _fake_isdir. + return [d.replace(\"\\\\\", \"/\") for d in dirs] + def test_derives_dirs_from_portablegit_layout(self, monkeypatch): monkeypatch.setattr(local_mod, \"_IS_WINDOWS\", True) monkeypatch.setattr(local_mod, \"_git_bash_bin_dirs_cache\", None) @@ -348,7 +355,7 @@ class TestGitBashCoreutilsOnPath: existing = {\"/pg/mingw64/bin\", \"/pg/usr/bin\", \"/pg/bin\"} monkeypatch.setattr(local_mod.os.path, \"isdir\", self._fake_isdir(existing)) - dirs = _git_bash_bin_dirs() + dirs = self._norm(_git_bash_bin_dirs()) # usr/bin is the load-bearing coreutils dir; mingw64 precedes it. assert \"/pg/usr/bin\" in dirs @@ -363,7 +370,7 @@ class TestGitBashCoreutilsOnPath: existing = {\"/mg/usr/bin\", \"/mg/mingw64/bin\"} monkeypatch.setattr(local_mod.os.path, \"isdir\", self._fake_isdir(existing)) - dirs = _git_bash_bin_dirs() + dirs = self._norm(_git_bash_bin_dirs()) # MinGit ships bash under usr\\bin; root must still resolve to /mg. assert \"/mg/usr/bin\" in dirs ``` Verified locally on Windows 11: both tests pass with this change, and the change is a no-op on POSIX.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/69371",
          "createdAt": "2026-07-22T13:43:36Z",
          "updatedAt": "2026-08-13T17:43:06Z",
          "timestamp": "2026-08-13T17:43:06Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/test",
            "comp/tools",
            "backend/local",
            "P3",
            "sweeper:risk-platform-windows",
            "platform/windows"
          ],
          "author": "daragao3",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:8629551406075ad6db7c",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85494",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85494",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(hindsight): memory provider improvements — recall_sync, retain_source, setup templates, memory indicators, error hints",
          "text": "## Summary Salvages PR #74379 (@benfrank241) with security and efficiency fixes applied on top. Hindsight memory provider improvements: opt-in synchronous recall (recall_sync), actionable error hints for missing local runtime, opt-in retain_source attribution, starter memory templates during setup, provider-unavailable warning, and deterministic \"recalled N memories\" / \"saving to memory\" status indicators. Fixes #5820, #7718, #2765. Supersedes #70278, #70295, #72926, #73415, #70257, #46504, #5838, #13101. ## Changes - `plugins/memory/hindsight/__init__.py`: recall_sync, retain_source default, indicators, local runtime hint, unavailable_reason(), recall_status() - `plugins/memory/hindsight/templates.py` (new): starter bank template catalog fetch + apply + overwrite warning - `agent/memory_provider.py`: RecallStatus dataclass, unavailable_reason() and recall_status() ABC hooks, generic INDICATOR_GLYPH - `agent/memory_manager.py`: describe_recall() aggregator - `agent/turn_context.py`: recall indicator emit after prefetch - `agent/agent_init.py`: provider-unavailable warning with dedup guard - `hermes_cli/memory_setup.py`: systemd/.env inheritance hint ## Fixes applied on top of @benfrank241's work 1. **Security:** `templates.py` apply_template() and probe_existing_customization() now use `open_credentialed_url()` instead of bare `urlopen()` — prevents credential leak via cross-origin redirect. 2. **Efficiency:** `agent_init.py` guards `unavailable_reason()` with the dedup set check before calling it — was running disk I/O + importlib probes on every gateway turn despite the warning being deduped. 3. **Abstraction:** `INDICATOR_GLYPH` in core is now a generic brain (🧠) instead of Hindsight's brand mark. Hindsight overrides with its own `_HINDSIGHT_GLYPH` (👁️). ## Validation - 129/129 targeted tests pass (7 test files) - Ruff clean - E2E smoke tests pass with real imports Contributor: @benfrank241 (ben.bartholomew@vectorize.io) — authorship preserved via commit 1.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85494",
          "createdAt": "2026-08-13T17:34:49Z",
          "updatedAt": "2026-08-13T17:43:02Z",
          "timestamp": "2026-08-13T17:43:02Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/cli",
            "comp/plugins",
            "tool/memory",
            "P3"
          ],
          "author": "kshitijk4poor",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:cc48333fcb694456b7a4",
        "signalId": "github:NousResearch/hermes-agent:issue:85491",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85491",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "terminal_tool: cron lifecycle guard crashes with ValueError on venv binary paths (os.open raises ValueError, not OSError)",
          "text": "## Summary `terminal_tool` crashes with `ValueError: embedded null byte` before running the command, when the command string leads the guard to derive a script path containing a NUL byte. The failure happens in the cron lifecycle guard's pre-flight scan, so the command never executes and the tool call returns an error instead of output. ## Impact Hit repeatedly during an ordinary coding session on `main` (`470aa20d22`). Two commands that failed: ```bash cd /path/to/repo && git merge --ff-only origin/master && echo \"...\" && .venv/bin/python -m pytest -q cd /path/to/repo && .venv/bin/python -m pytest -q | tail -2 ``` Running a virtualenv interpreter by relative path is extremely common, so this is likely to affect many sessions. It fails loudly rather than silently, which limits the damage — but the error surfaces as a tool traceback with no indication that the *command* was fine and the *guard* was at fault, so it reads like a repo or environment problem. **Workaround:** assign the path to a shell variable first, which changes the token the guard inspects. ```bash PY=.venv/bin/python; \"$PY\" -m pytest -q # works ``` ## Root cause `cron/lifecycle_guard.py::_read_referenced_script` (line ~260): ```python flags = os.O_RDONLY | getattr(os, \"O_NONBLOCK\", 0) try: descriptor = os.open(path, flags) except OSError: return None, False ``` `os.open()` raises **`ValueError`** — not `OSError` — when the path contains an embedded NUL byte. The `except OSError` clause does not catch it, so it propagates up through `_contains_unsafe_gateway_action` → `contains_gateway_lifecycle_command_or_referenced_script` → `terminal_tool` and aborts the tool call. Verified in isolation: ```python >>> import os >>> os.open(\"/tmp/x\\x00y\", os.O_RDONLY) ValueError: embedded null byte ``` The surrounding code already treats unreadable and non-regular paths as \"nothing to scan\" and returns cleanly, so an unopenable path is an expected condition here rather than an exceptional one — this looks like a missed exception type rather than a design question. ## Full traceback ``` File \"tools/terminal_tool.py\", line 2560, in terminal_tool if contains_gateway_lifecycle_command_or_referenced_script( File \"cron/lifecycle_guard.py\", line 353, in contains_gateway_lifecycle_command_or_referenced_script return _contains_unsafe_gateway_action( File \"cron/lifecycle_guard.py\", line 335, in _contains_unsafe_gateway_action if script_text and _contains_unsafe_gateway_action( File \"cron/lifecycle_guard.py\", line 324, in _contains_unsafe_gateway_action script_text, unsafe = _read_referenced_script(script_path) File \"cron/lifecycle_guard.py\", line 260, in _read_referenced_script descriptor = os.open(path, flags) ValueError: embedded null byte ``` ## Suggested fix Catch `ValueError` alongside `OSError`: ```python try: descriptor = os.open(path, flags) except (OSError, ValueError): return None, False ``` A path that cannot be opened is already handled as \"nothing to scan, not unsafe\", and a NUL-containing path can never name a real script, so the same return is correct. Worth checking whether the path *derivation* upstream should also reject NUL-containing candidates before they reach the open — the guard scans several tokens per command, and this one produced a candidate that could not name any file on disk. ## Notes I have not opened a PR — the one-line change is easy, but the guard is security-adjacent code and I would rather the maintainers decide whether the fix belongs at the `open()` call, at the path derivation, or both. Happy to submit one if that is useful.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85491",
          "createdAt": "2026-08-13T17:32:33Z",
          "updatedAt": "2026-08-13T17:42:54Z",
          "timestamp": "2026-08-13T17:42:54Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/tools",
            "comp/cron",
            "tool/terminal",
            "P2"
          ],
          "author": "PolyphonyRequiem",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:13fcaf97b4101f4c6f58",
        "signalId": "github:NousResearch/hermes-agent:pull_request:78590",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:78590",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(install): surface aborted gateway restart during hermes update (#78574)",
          "text": "## What does this PR do? `hermes update` can finish with a clean `✓ Update complete!` and exit 0 while leaving a running gateway on pre-update modules — the next turn then dies with `ImportError: cannot import name 'is_trivial_prompt' from 'agent.memory_provider'`. The cause is the blanket handler around the entire gateway auto-restart phase in `hermes_cli/update_cmd.py`: ```python except Exception as e: logger.debug(\"Gateway restart during update failed: %s\", e) ``` If anything in that phase raises — most plausibly the `from hermes_cli.gateway import (...)` at the top of the block, which pulls the *freshly pulled* module into a process that already loaded the *pre-update* ones — the whole phase is skipped. Every `→ draining …` / `✓ Restarted hermes-gateway` line vanishes from the update log, and the failure is invisible at default log level. That matches the reported log exactly: earlier updates show drain/restart lines, the failing run shows none, and exit status is 0. Upstream already has the right machinery for the *partial* failure case (`failed_or_stale_units` → `_warn_incomplete_gateway_fleet_restart` → `gateway_fleet_restart_incomplete` → nonzero exit + `.update_exit_code` marker). This PR routes the *total* failure case into the same contract instead of swallowing it. On exception the handler now: 1. probes for surviving gateway PIDs (best-effort, never raises — a broken `hermes_cli.gateway` is itself one of the ways we get here); 2. unless it can *positively* prove nothing is running (empty list), prints the underlying exception, the surviving PIDs, and `hermes gateway restart`; 3. sets `gateway_fleet_restart_incomplete`, so the update exits nonzero and — under `--gateway` — writes `1` to `.update_exit_code`, exactly like the existing failed-unit path. `None` (undeterminable) and a non-empty PID list are both treated as \"assume stale\"; only a positive empty result stays quiet, so a host with no gateway running does not start failing its updates. This is deliberately a *reporting* fix, not an attempt to guess why the phase died: the issue asks for \"exit nonzero or print a clear warning and manual recovery command\", and any root cause behind that blanket `except` produces the same silent-stale outcome today. ## Related Issue Fixes #78574 ## Type of Change - [x] 🐛 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 - `hermes_cli/update_cmd.py` - new `_surviving_gateway_pids_after_failed_restart()` — best-effort probe for gateways still running after the restart phase aborted; returns `None` when undeterminable so callers can distinguish \"nothing running\" from \"cannot tell\". - new `_warn_gateway_restart_phase_aborted()` — prints the cause, the surviving PIDs, and the `hermes gateway restart` / `hermes gateway status` recovery commands. Mirrors the wording/shape of the neighbouring `_warn_incomplete_gateway_fleet_restart()`. - the phase-level `except Exception` now calls both, sets `gateway_fleet_restart_incomplete = True`, and writes the gateway-mode `.update_exit_code` marker. - `hermes_cli/main.py` — re-export the two new helpers alongside the existing update-path names. - `tests/hermes_cli/test_update_gateway_restart_aborted.py` — new regression tests. ## How to Test ``` scripts/run_tests.sh tests/hermes_cli/test_update_gateway_restart_aborted.py tests/hermes_cli/test_update_fleet_restart_timeout.py ``` Result: `2 files, 9 tests passed, 0 failed`. Full update-path suite (22 files, includes every `tests/hermes_cli/test_update*.py` and `test_cmd_update*.py`): ``` scripts/run_tests.sh tests/hermes_cli/test_update*.py tests/hermes_cli/test_cmd_update*.py ``` Result: `22 files, 167 tests passed, 0 failed`. The tests cover the invariants from the issue: the probe never raises when `hermes_cli.gateway` is broken, an empty probe result stays distinguishable from an undeterminable one, and the warning names both the underlying cause and the manual recovery command. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass <!-- ran the 22-file update-path suite above, not the whole tree --> - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS (Darwin 25.5.0) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A <!-- N/A: helper docstrings only --> - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A <!-- N/A: no new config keys --> - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A <!-- N/A --> - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A <!-- the handler is platform-agnostic; `write_text` uses `encoding=\"utf-8\"`; no new POSIX-only calls --> - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A <!-- N/A --> ## Screenshots / Logs Before (reported in #78574 — no drain/restart step, exit 0): ```text ✓ Update complete! Tip: You can now select a provider and model: hermes model ``` After, when the restart phase aborts: ```text ⚠ Update incomplete — gateway auto-restart failed: cannot import name 'is_trivial_prompt' from 'agent.memory_provider' Gateway process(es) still running pre-update code: 4321 Restart it manually, then verify: hermes gateway restart hermes gateway status ``` …and the update process exits nonzero.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/78590",
          "createdAt": "2026-08-04T15:46:26Z",
          "updatedAt": "2026-08-13T17:42:54Z",
          "timestamp": "2026-08-13T17:42:54Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/gateway",
            "P1",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility",
            "area/install-update"
          ],
          "author": "PRATHAMESH75",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a71666861a3eeca5ca4f",
        "signalId": "github:NousResearch/hermes-agent:issue:85492",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85492",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: Desktop Agents panel keeps timed-out subagents stuck as running forever",
          "text": "### Bug Description After a delegated subagent hits the hard child timeout (`delegation.child_timeout_seconds`), the desktop app's **Agents panel keeps showing it as \"running\" forever** — spinning spinner, tool badge, counted in the status-bar \"Agents N running\" indicator — even though the parent conversation already received the timeout error. The row is never cleaned up, not even on subsequent turns. The same happens for subagents that exit via an exception. ### Steps to Reproduce 1. Set `delegation.child_timeout_seconds: 600` in `config.yaml` (or any finite value). 2. Use the desktop app; dispatch a batch of `delegate_task` subagents (`tasks=[...]`) and let them hit the 600s timeout (e.g. a subagent stuck before its first LLM call, or on a slow tool call). 3. Wait for the timeout — the parent conversation shows the \"Subagent timed out after 600s…\" error. 4. Open the Agents panel (or look at the status bar): the timed-out subagent rows are still shown with a running spinner and their last tool badge, and the status bar still counts them as active. ### Expected Behavior A timed-out (or exception-failed) subagent should render as a terminal failure — a failed/error state — and be pruned from the Agents panel like any other finished row at the next `message.start` boundary, so the status-bar count returns to the actually-active subagents. ### Actual Behavior The row stays in the `running` state indefinitely: spinner glyph, included in `activeSubagentCount` (status bar \"Agents N running\"), and never pruned by `pruneFinishedSessionSubagents` (which only removes terminal-status rows). Only a Stop action or session wipe clears it. ### Affected Component Other (Desktop app — Agents panel / status-bar subagent indicator) ### Messaging Platform (if gateway-related) N/A (CLI only) ### Debug Report ``` Report https://paste.rs/bIA8m agent.log https://paste.rs/bIA8m gateway.log https://paste.rs/Jjo1N gui.log https://paste.rs/eVe75 ``` ### Operating System macOS (26.5.2) ### Python Version 3.11.15 ### Hermes Version v0.20.0 (2026.8.3) · upstream 91e550b0 ### Root Cause Analysis (optional) Status-vocabulary drift between the Python backend and the desktop store. The backend completes subagents with status values the desktop store does not recognize: - `tools/delegate_tool.py:2479-2488` — hard child timeout emits `subagent.complete` with `status=\"timeout\"` (introduced in [`dd8ab40556`](https://github.com/NousResearch/hermes-agent/commit/dd8ab40556), PR #13770 — \"'timeout' as a new exit_reason/status\"). - `tools/delegate_tool.py:2485` — non-timeout exception exit emits the same event with `status=\"error\"`. - Success / interrupt / inner-failure paths emit `completed` / `interrupted` / `failed` (`delegate_tool.py:2642-2650`, `:2884`) — these the UI does recognize. The desktop renderer forwards `subagent.*` gateway events verbatim into its store (`apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts:919-932` → `upsertSubagent`), and the store's status mapper (`apps/desktop/src/store/subagents.ts:58-59`) is an allowlist: ```ts const asStatus = (v: unknown): SubagentStatus => v === 'completed' || v === 'failed' || v === 'interrupted' || v === 'queued' ? v : 'running' ``` `\"timeout\"` and `\"error\"` are not in the allowlist, so both fall through to `'running'`. Consequences: - The row keeps the running spinner and counts in `activeSubagentCount` → status bar \"Agents N running\". - `pruneFinishedSessionSubagents` only removes terminal-status rows at the `message.start` boundary, so the stuck row is never pruned. `asStatus` has not changed since the initial desktop app commit ([`51c68d4ab1`](https://github.com/NousResearch/hermes-agent/commit/51c68d4ab1)); the backend `timeout` status was added later without the desktop store learning it. ### Proposed Fix (optional) Map the backend's `timeout` and `error` completion statuses to the existing terminal `failed` status in the desktop store's `asStatus()`. This makes the row render the failed glyph, count as failed, and get pruned at the next `message.start` like any other finished row — no type/i18n/glyph changes needed. Also synthesize a \"Timed out after Xs\" summary line when the raw status is `timeout` and the backend sent no summary, so the row shows why it failed. PR: coming — will link from a comment once opened. - [x] I'd like to fix this myself and submit a PR",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85492",
          "createdAt": "2026-08-13T17:32:50Z",
          "updatedAt": "2026-08-13T17:42:51Z",
          "timestamp": "2026-08-13T17:42:51Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "duplicate",
            "tool/delegate",
            "P2",
            "sweeper:risk-session-state",
            "comp/desktop",
            "area/sessions"
          ],
          "author": "DavidMetcalfe",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:301ef8e9a5c54ce84bc0",
        "signalId": "github:NousResearch/hermes-agent:issue:85495",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85495",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "`--in <dir>` is silently overridden by restored session cwd in one-shot mode (`-z`)",
          "text": "v0.20.0 (2026.8.3), Windows native. In headless one-shot mode, `--in <dir>` is documented to set the working directory, but when a previous session has a saved cwd, that cwd wins silently: we invoked `hermes -z \"…\" --in C:\\<throwaway-dir> --ignore-user-config` from `C:\\<throwaway-dir>` itself, and the agent operated in `C:\\dev` (the restored cwd of an earlier session) — created the requested file there and reported on that directory's git state. No warning was emitted. Impact: headless automation can silently act in the wrong directory — in our case the file landed in an unrelated real workspace. Likely the same flag-handling gap as #67458 (`-w/--worktree` silently ignored in `-z` mode). Expected: `--in` takes precedence over any restored cwd (or hard error if both are given and conflict); at minimum a warning. Workaround for others: pass `--no-restore-cwd` alongside `--in` (verified mitigating in our runs where shell cwd and `--in` agreed).",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85495",
          "createdAt": "2026-08-13T17:37:21Z",
          "updatedAt": "2026-08-13T17:42:43Z",
          "timestamp": "2026-08-13T17:42:43Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "P2",
            "sweeper:risk-compatibility",
            "sweeper:risk-platform-windows"
          ],
          "author": "EirikWolf",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:cfeb3a8dbfbe4aa65438",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85274",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [
          "text",
          "updatedAt"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85274",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): bound large composer image previews",
          "text": "## What does this PR do? Fixes #41169. Supersedes #68744 while preserving David Metcalfe's original authorship on its two commits, replayed onto current `main`. Follow-up commits harden the fix for the reproduced 72-image failure and the async races exposed during independent review. The Desktop composer previously retained full-resolution image data URLs and rendered them as attachment thumbnails. Large images forced Chromium to decode and rasterize full-size sources on renderer display surfaces. With 72 images, the renderer emitted: ```text Failed to serialize op in 16777152 bytes webContents became unresponsive render-process-gone reason=crashed exitCode=133 ``` The `Failed to serialize op` message comes from Chromium's raster/PaintOp path, not Electron IPC. The parent Electron process, backend, and gateway remained alive; Electron replaced the failed renderer. The original PR's 2048×2048 thumbnail ceiling can itself decode to 16,777,216 RGBA bytes — 64 bytes above the logged 16,777,152-byte PaintOp serialization failure — so the hardened ceiling is intentionally 512px rather than 2048px. This PR bounds the complete composer-preview pipeline rather than only the final `<img>` dimensions: - serializes local/gateway reads and thumbnail creation so only one full image is read/decoded at a time; - creates display thumbnails with `createImageBitmap` + `OffscreenCanvas`, with a 512px longest edge; - closes decoded `ImageBitmap` resources and fails closed to a 1×1 placeholder instead of rendering the original when conversion is unavailable or fails; - retains only the bounded `thumbnailUrl` in new composer attachment state, rather than dozens of multi-megabyte full-resolution data URLs; - reads the original path only when the lightbox is opened, then releases that data URL when the lightbox closes; - keeps model input full resolution: submit/upload reads the authoritative on-disk original independently of the display thumbnail; - binds async preview completion to a renderer-lifetime per-occurrence UUID that survives session-draft cloning, so both A → B → A switching and remove + same-path reattach remain race-safe; - retains local-first reads with gateway fallback for local and remote project paths; - after cross-filesystem staging rewrites `path`, falls back to the preserved original host path in `detail` for lightbox/download; - uses the bounded thumbnail for the optimistic in-flight message bubble too, and never falls back to rendering `@image:<path>` while a queued thumbnail is still pending. No new custom protocol, URL allowlist, or navigation permission is introduced. The existing `MEDIA:` / transcript image path tracked by #42109 remains out of scope. ## Related Issue Fixes #41169 Supersedes #68744. A maintainer can close the conflicted original after accepting this attributed replacement. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [x] ♻️ Refactor (no intended external behavior change beyond fixing the failure) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `apps/desktop/src/lib/image-resize.ts` - serial one-at-a-time resize queue; - 512px default longest edge; - explicit bitmap cleanup and fail-closed fallback. - `apps/desktop/src/app/chat/hooks/use-composer-actions.ts` - serializes the full read + resize pipeline; - stores only the bounded thumbnail for new image chips; - updates only the exact optimistic attachment occurrence captured before async preview work, preserving identity across draft clones while preventing same-path replacement races. - `apps/desktop/src/lib/desktop-fs.ts` - centralizes local-first image reads with active-gateway fallback. - `apps/desktop/src/app/chat/composer/attachments.tsx` - renders bounded thumbnails; - keys each pill by occurrence identity so a removed occurrence's deferred lightbox read cannot populate a same-path replacement; - loads the full image on demand for lightbox/download, falls back from a staged backend path to the preserved host path, and releases it on close. - `apps/desktop/src/lib/chat-runtime.ts` - uses the bounded thumbnail in the optimistic in-flight bubble. - `apps/desktop/src/store/composer.ts` - documents thumbnail versus legacy/on-demand full-source semantics; - provides UUID-backed occurrence-identity conditional patching shared by main and tile composers; - patches the main live atom and inactive per-session draft stashes while merging into the latest occurrence state, so thumbnail and staging completions cannot overwrite each other; - removes only the attachment occurrences captured by a successful submit: UUID-backed chips match by occurrence token, while legacy occurrence-less chips match by exact object identity; this preserves newer same-ID file/URL replacements while still clearing successfully staged submitted files. - `apps/desktop/src/app/chat/session-tile-actions.ts` - applies the same occurrence-aware staging merge in independently mounted session-tile composers, rejecting stale upload completion after remove + same-path reattach. - `apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts` - merges cross-filesystem staging metadata into the current occurrence instead of replacing concurrent preview state. Regression coverage verifies: - 72 image reads run one at a time; - 72 bitmap decodes run one at a time and every generated canvas is 512×512 or smaller; - composer state contains 72 bounded thumbnails and no full-resolution preview data; - removing an attachment during queued resize does not resurrect it; - removing and reattaching the same path cannot receive the removed occurrence's late thumbnail; - a delayed thumbnail resolving while another session is active updates only the original session's stashed draft and appears after returning; - thumbnail generation and submit-time staging may finish in either order in both the main and session-tile composers without dropping thumbnail, staged path, session ownership, or upload state; - remove + same-path reattach rejects stale staging completion in both composer scopes; - successful main and tile submits remove only their captured occurrences, preserve newer same-ID image and occurrence-less URL replacements added while submission is in flight, still clear successfully staged legacy files, and emit cleanup state exactly once; - an old occurrence's deferred full-image read cannot open or populate a same-path replacement lightbox; - lightbox full-source data is read only on open and released on close; - a split-filesystem image remains previewable after its `path` is rewritten to a gateway-staged path; - local-first and gateway-fallback paths remain supported; - optimistic messages prefer the bounded thumbnail and render no path-backed image while resize is pending; - resize failures and unsupported APIs fail closed. ## How to Test 1. Attach or paste one large screenshot and verify the composer remains responsive. 2. Attach many large images (the reproduced report used 72) and verify the renderer remains responsive. 3. Verify attachment pills and the optimistic user bubble render bounded thumbnails. 4. Open/download an attachment and verify the original full-resolution image is used. 5. Send the prompt and verify the model receives the original image, not the thumbnail. 6. Remove a chip while thumbnails are still being created and verify it does not reappear. Automated verification on CachyOS Linux with Node 22.23.2: ```bash LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 TZ=UTC NODE_ENV=test \\ NODE_OPTIONS='--max-old-space-size=8192 --localstorage-file=/tmp/hermes-pr68744-full-ui-en-localstorage.json' \\ npm --workspace apps/desktop run test:ui # 423 files passed; 3,819 tests passed NODE_ENV=test NODE_OPTIONS='--max-old-space-size=8192 --localstorage-file=/tmp/hermes-pr68744-exact-localstorage.json' \\ npm --workspace apps/desktop exec -- vitest run --project ui \\ src/lib/image-resize.test.ts \\ src/app/chat/hooks/use-composer-actions.test.ts \\ src/lib/chat-runtime.test.ts \\ src/app/chat/composer/attachments.test.tsx \\ src/lib/desktop-fs.test.ts \\ src/store/composer.test.ts \\ src/app/chat/composer/hooks/use-composer-draft.test.tsx \\ src/app/session/hooks/use-prompt-actions/index.test.tsx \\ src/app/chat/session-tile-attachments.test.tsx # 9 files passed; 216 tests passed NODE_ENV=test npm --workspace apps/desktop run typecheck # passed NODE_ENV=test npm --workspace apps/desktop run lint # 0 errors; repository baseline warnings only, none in changed files NODE_ENV=production npm --workspace apps/desktop run build # production renderer and Electron bundles built; assert-dist-built passed git diff --check # passed ``` Running the full UI suite without forcing an English locale initially produced four locale-sensitive assertion failures (`1 234 567`/`25 USD` formatting versus en-US expectations). Re-running under the CI locale passed all 3,813 tests. Mutation proof: in an isolated worktree of the exact candidate, bypassing the end-to-end preview queue made the 72-image regression fail with `maxActiveReads = 72` versus the required `1`. Restoring the candidate returned the focused suite to 74/74. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for existing PRs; this is an attributed current-main replacement for conflicted #68744 - [x] My PR contains only changes related to this fix - [x] I've run the relevant Desktop automated tests - [x] I've added regression tests for the reported 72-image case - [x] I've tested on CachyOS Linux with Node 22.23.2 and a production Electron build ### Documentation & Housekeeping - [x] User documentation — N/A; no user-facing command or configuration changed - [x] `cli-config.yaml.example` — N/A; no config keys changed - [x] `CONTRIBUTING.md` / `AGENTS.md` — N/A; no workflow contract changed - [x] Cross-platform impact considered — uses Chromium/Electron-standard APIs with a safe fallback - [x] Tool descriptions/schemas — N/A; no agent tool changed ## For New Skills N/A — this PR does not add a skill.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85274",
          "createdAt": "2026-08-13T12:11:02Z",
          "updatedAt": "2026-08-13T17:41:44Z",
          "timestamp": "2026-08-13T17:41:44Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "P3",
            "comp/desktop"
          ],
          "author": "frizikk",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:4cd41470164cc5928eb3",
        "signalId": "github:NousResearch/hermes-agent:pull_request:72671",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:72671",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "test(gateway): make unset model override explicit in background fixture",
          "text": "## Summary Make the `/background` gateway tests exercise the real **no model override** path instead of a truthy mock value that looks like a persisted override. ## Why this matters `GatewayRunner` rehydrates a session's model override before starting a background task. In production, `SessionStore.get_model_override()` returns `None` when the user has not set a `/model` override. The test fixture used an unconfigured `MagicMock` session store. Its default return value is truthy, so the test could accidentally create a fake model override and pass a `MagicMock` into the session-scoped reasoning resolver. That means the test was not reliably exercising the normal no-override path, and its result could depend on mock behavior rather than the production contract. This is a test correctness issue: the fixture can mask regressions or fail for the wrong reason even though production behavior is unchanged. ## Fix Set: ```python mock_store.get_model_override.return_value = None ``` in `_make_runner()`, matching the real `SessionStore` contract. ## Scope - One test fixture in `tests/gateway/test_background_command.py`. - Four added lines; no production-code, dependency, configuration, or formatter changes. - Existing background-command scenarios now run with an explicit absent override. This should merge because it is a minimal, behavior-preserving correction that makes the gateway regression tests represent the production no-override state they intend to cover. ## Verification Against upstream `main` at `8d4b1e4b0e240af67c1f4eb6601aee99eac0bc49`, exact head `903c867cf8ba0d1737ae52d4ee296a71b09dd0aa`: ```text scripts/run_tests.sh tests/gateway/test_background_command.py -q 7 passed uv run --extra dev ruff check tests/gateway/test_background_command.py All checks passed git diff --check refs/remotes/upstream/main...HEAD passed ``` The canonical test harness ran with its clean environment (`TZ=UTC`, `LANG=C.UTF-8`, `PYTHONHASHSEED=0`). ## Checklist - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md). - [x] The change is narrowly scoped to one test fixture. - [x] The focused regression tests pass with `scripts/run_tests.sh`. - [x] Focused Ruff and whitespace checks pass. - [x] No production behavior, dependency, configuration, or platform adapter changes are included. - [x] The current upstream overlap and existing PR history were checked. No issue link: this is a narrowly scoped upstream test-fixture correction. Human sponsor and review-response owner: @tmielika",
          "url": "https://github.com/NousResearch/hermes-agent/pull/72671",
          "createdAt": "2026-07-27T13:16:14Z",
          "updatedAt": "2026-08-13T17:41:40Z",
          "timestamp": "2026-08-13T17:41:40Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/test",
            "comp/gateway",
            "P3",
            "sweeper:risk-automation",
            "sweeper:blast-contained"
          ],
          "author": "vadelma-agent",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:edaea3e18f3b5ff9e056",
        "signalId": "github:NousResearch/hermes-agent:pull_request:43130",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:43130",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(langfuse): omit explicit zero cost_details for subscription-included providers (#43129)",
          "text": "## What does this PR do? Fixes Langfuse showing $0 cost for subscription-included providers (e.g. `openai-codex`). Both the response-object path and the `post_api_request` dict path were sending explicit zero-valued `cost_details` to Langfuse, which treats them as authoritative and never recalculates from model pricing. ## Related Issue Fixes #43129 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `plugins/observability/langfuse/__init__.py`: In `_usage_and_cost()`, gate cost_details population on `cost.status != \"included\"` so subscription-included routes produce empty cost_details instead of explicit zeros. In the `post_api_request` dict path, check `resolve_billing_route().billing_mode != \"subscription_included\"` before computing per-type costs from the pricing entry. - `tests/plugins/test_langfuse_plugin.py`: Added `TestSubscriptionIncludedCostOmission` with 4 regression tests covering both code paths (subscription-included → empty cost_details, normal provider → populated cost_details). ## How to Test 1. Run `pytest tests/plugins/test_langfuse_plugin.py -xvs` — all 43 tests pass 2. The new tests verify: - `_usage_and_cost()` returns empty `cost_details` for `openai-codex` (subscription_included) - `_usage_and_cost()` returns populated `cost_details` for `openai` (normal provider) - `on_post_llm_call` dict path returns empty `cost_details` for `openai-codex` - `on_post_llm_call` dict path returns populated `cost_details` for `openai` 3. Manual verification: configure Langfuse + openai-codex provider, run a turn, check that Langfuse shows estimated cost (not $0) ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## Code Intelligence - Analyzed: `_usage_and_cost`, `on_post_llm_call`, `get_pricing_entry`, `estimate_usage_cost`, `resolve_billing_route` - Blast radius: LOW — changes are confined to the Langfuse plugin's cost-export logic; no behavioral change for non-Langfuse consumers - Related patterns: `estimate_usage_cost` returns `CostResult(amount_usd=Decimal(\"0\"), status=\"included\")` for subscription routes; `get_pricing_entry` returns `PricingEntry` with all `_ZERO` costs. Both paths now check the billing status before populating cost_details.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/43130",
          "createdAt": "2026-06-09T22:42:31Z",
          "updatedAt": "2026-08-13T17:41:04Z",
          "timestamp": "2026-08-13T17:41:04Z",
          "metrics": {
            "reactions": 2,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "provider/openai",
            "P3",
            "sweeper:risk-compatibility",
            "sweeper:blast-contained",
            "area/usage-cost"
          ],
          "author": "liuhao1024",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:3a9d215f0b5109f19412",
        "signalId": "github:NousResearch/hermes-agent:pull_request:64797",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:64797",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(langfuse): export canonical generation total",
          "text": "## What does this PR do? Fixes Langfuse generation/trace/session cost rollups when Hermes exports cache or other custom cost buckets. The bug was reproduced on unchanged `main` at `d2c81eb681dea1382fbd1ed403f58320d5aef575`; the branch is now rebased onto current `main` at `9baa7d4673ce89f09378daa3660530f8bf142708`. Hermes already computes the right request-level total, but both Langfuse export paths discard it and send only component buckets. Current Langfuse ingestion treats any supplied `cost_details` as authoritative and skips model-price inference. It derives `total` only for the exact built-in `input` + `output` case; adding `cache_read_input_tokens` leaves `total` absent. Downstream generation conversion treats an absent total as zero, and trace/session aggregation consumes that total. The contract was verified against the current [ingestion schema](https://github.com/langfuse/langfuse/blob/b445e2ff64141a5eab928122405c24f0576f8106/fern/apis/server/definition/ingestion.yml#L207-L229), [ingestion implementation](https://github.com/langfuse/langfuse/blob/b445e2ff64141a5eab928122405c24f0576f8106/worker/src/services/IngestionService/index.ts#L1457-L1482), [Python SDK serialization](https://github.com/langfuse/langfuse-python/blob/85208902bad63899352742bb85103ac97983b012/langfuse/_client/attributes.py#L116-L165), [observation conversion](https://github.com/langfuse/langfuse/blob/b445e2ff64141a5eab928122405c24f0576f8106/packages/shared/src/server/repositories/observations_converters.ts#L443-L464), [trace rollup](https://github.com/langfuse/langfuse/blob/b445e2ff64141a5eab928122405c24f0576f8106/packages/shared/src/server/services/traces-ui-table-service.ts#L293-L317), and [session rollup](https://github.com/langfuse/langfuse/blob/b445e2ff64141a5eab928122405c24f0576f8106/packages/shared/src/server/queries/clickhouse-sql/event-query-builder.ts#L1363-L1389). This patch exports Hermes's canonical `estimate_usage_cost(...).amount_usd` as `cost_details.total` in both the response-object and sanitized-summary paths. It deliberately does not sum component buckets: the canonical amount also includes per-request pricing (`request_count * request_cost`). Unknown or partially priced usage still emits no guessed cost, and subscription-included routes retain their existing component-only semantics without an explicit total. This cannot be repaired reliably with Langfuse model-price configuration or Hermes environment settings. Once Hermes supplies any cost bucket, current Langfuse ingestion does not run model inference; removing all supplied costs would also discard Hermes-specific cache/custom/request pricing and is not equivalent to completing the payload. ### Reproduction on unchanged main Local Langfuse server 3.178.0 with Python SDK 4.7.1: | Runtime path | Case | Hermes canonical total | Cost payload before | Stored generation total | Trace rollup | | --- | --- | ---: | --- | ---: | ---: | | response object | no cache | $1.60 | input=$1.00, output=$0.60 | $1.60 | $1.60 | | response object | cache | $0.88 | input=$0.20, output=$0.60, cache=$0.08 | $0.00 / absent | $0.00 | | sanitized summary | no cache | $1.60 | input=$1.00, output=$0.60 | $1.60 | $1.60 | | sanitized summary | cache | $0.88 | input=$0.20, output=$0.60, cache=$0.08 | $0.00 / absent | $0.00 | After this patch, the same four calls export explicit totals of $1.60, $0.88, $1.60, and $0.88. Each generation and trace stores the matching value, and the session metrics roll up to $4.96. ## Related Issue Related: #49932, #43130 Credit to @rdguidry: draft PR #49932 first identified the missing-explicit-total problem. This is a separate, provider-agnostic plugin-only fix because #49932 also owns Venice pricing/catalog changes and derives total by summing buckets, which omits request-level pricing. No code was copied from #49932; its Venice work remains there. #43130 is orthogonal subscription-included policy work: it removes all explicit cost details so Langfuse may estimate nominal list price. This patch is behaviorally compatible because it never adds `total` for an `included` result. If #43130 lands first, only a mechanical rebase around the shared helper may be needed. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `plugins/observability/langfuse/__init__.py`: share canonical usage/cost export between both call paths and include the authoritative request-level total only when pricing is complete and not subscription-included. - `tests/plugins/test_langfuse_plugin.py`: cover cache/no-cache, request-priced, request-only, partial/unknown pricing, and included-route parity through both real exporter paths. ## How to Test 1. Run `scripts/run_tests.sh tests/plugins/test_langfuse_plugin.py tests/agent/test_usage_pricing.py -q`. 2. Run `python -m ruff check plugins/observability/langfuse/__init__.py tests/plugins/test_langfuse_plugin.py` and `python scripts/check-windows-footguns.py plugins/observability/langfuse/__init__.py tests/plugins/test_langfuse_plugin.py`. 3. Point the plugin at a Langfuse v3 instance and compare otherwise identical calls with and without cached input tokens across the response-object and sanitized-summary hooks. Generation, trace, and session totals should match Hermes's canonical amount. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 26.5.1 arm64 Full wrapper run: 41,121 passed and 39 failed in unrelated host/environment-sensitive tests (live credential guards, Linux service/systemd assumptions, macOS `/tmp` path normalization, AWS credentials, and gateway SDK/race tests). The changed Langfuse tests passed in the full run. Focused wrapper: 70 passed. ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — N/A; no public configuration or API change - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — Windows footgun check passed - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A ## Screenshots / Logs - Focused tests: 70 passed. - Langfuse plugin tests: 55 passed. - Ruff, Python compile, `git diff --check`, and Windows footgun checks: passed. - Before-fix E2E run: `codex-langfuse-rollup-1784094329-099614`. - After-fix E2E run: `codex-langfuse-fix-1784095229-0cbc8f`.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/64797",
          "createdAt": "2026-07-15T06:13:00Z",
          "updatedAt": "2026-08-13T17:41:01Z",
          "timestamp": "2026-08-13T17:41:01Z",
          "metrics": {
            "reactions": 0,
            "comments": 5
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "P3",
            "telemetry",
            "sweeper:risk-compatibility",
            "sweeper:blast-contained",
            "area/usage-cost"
          ],
          "author": "NaMinhyeok",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e9b17a995e3599184a0f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:61166",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:61166",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(langfuse): use update_trace so turn Input/Output columns fill",
          "text": "## Bug Description Self-hosted Langfuse list view shows many `Hermes turn` rows with **blank Input/Output** while Observation Levels and Latency are still populated. Drilling in shows GENERATION/TOOL observations but **no CHAIN root**. ## Root Cause Langfuse Python SDK **v3** exposes `span.update_trace()`, not `set_trace_io()`. `_finish_trace` called the missing method inside a broad `try`: ```python state.root_span.set_trace_io(output=final_output) # AttributeError state.root_span.update(output=final_output) # never reached state.root_span.end() # never reached ``` The exception was swallowed by fail-open logging. Child generations/tools had already called `end()`, so they still exported — producing the half-empty list UI. ## Fix - Use `update_trace()` for TRACE-level Input/Output - Isolate `update_trace` / `update` failures so `end()` always runs - Last-chance `end()` if the outer try still fails - Tests mock the real SDK method; add regression coverage ## How to Verify 1. With `langfuse==3.15.0` and the plugin enabled, run any multi-step turn. 2. Langfuse list row should show Input + Output text, not empty cells. 3. Trace detail should include a `CHAIN` observation named `Hermes turn`. ## Test Plan - [x] `pytest tests/plugins/test_langfuse_plugin.py` — 50 passed - [x] Live repro: AttributeError on `set_trace_io`; `update_trace` works - [x] Manual diagnostic traces against self-hosted Langfuse confirm CHAIN+I/O when root ends ## Risk Assessment **Low** — observability plugin only; fail-open behavior preserved; `end()` is more defensive than before.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/61166",
          "createdAt": "2026-07-08T23:00:46Z",
          "updatedAt": "2026-08-13T17:40:57Z",
          "timestamp": "2026-08-13T17:40:57Z",
          "metrics": {
            "reactions": 1,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "P3",
            "sweeper:risk-compatibility",
            "sweeper:blast-contained"
          ],
          "author": "Per0-1",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a45a6ea7d3af7cca8468",
        "signalId": "github:NousResearch/hermes-agent:pull_request:32175",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:32175",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(langfuse): include Anthropic system prompt in generation input",
          "text": "## Summary When using Anthropic (and other providers that split `system` out of `messages`), Langfuse **LLM call** generation observations only showed user/assistant turns — the Hermes system prompt (skills, memory, context) was missing from the trace input. This change: - Passes `system_prompt` from `api_kwargs[\"system\"]` (with fallback when the first message is `role: system`) through the `pre_api_request` hook in `agent/conversation_loop.py` - Prepends a serialized `role: system` message to Langfuse generation input in `plugins/observability/langfuse` - Adds `system_prompt_chars` metadata when a system block is logged - Documents verification in the Langfuse plugin README ## Why Anthropic Messages API does not include the system prompt in `messages`; Hermes already builds `api_kwargs[\"system\"]` separately. The Langfuse plugin only serialized `request_messages`, so hosted traces looked like conversations without the agent instructions — making debugging prompt/cache behavior difficult. ## Test plan - [x] `pytest tests/plugins/test_langfuse_plugin.py` (39 passed locally) - [ ] Enable `observability/langfuse`, run `hermes chat -q \"hello\"`, open **LLM call** in Langfuse and confirm `role: system` appears first in generation input Made with [Cursor](https://cursor.com)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/32175",
          "createdAt": "2026-05-25T17:12:35Z",
          "updatedAt": "2026-08-13T17:40:54Z",
          "timestamp": "2026-08-13T17:40:54Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "comp/plugins",
            "provider/anthropic",
            "P3",
            "sweeper:risk-compatibility",
            "sweeper:blast-contained"
          ],
          "author": "db-aeon",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:72f41dcb7b09c76b89c2",
        "signalId": "github:NousResearch/hermes-agent:pull_request:64292",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:64292",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(langfuse): include system prompt in generation input (Anthropic system + Codex instructions)",
          "text": "## Summary Langfuse **LLM call** generation observations show conversations without the agent's system prompt (skills, memory, SOUL, tool guidance) for every provider that moves the system prompt out of `messages`: - **Anthropic Messages API** → separate `system` kwarg (`agent/anthropic_adapter.py`), reported in #32175 - **Responses/Codex API** → top-level `instructions` (`agent/transports/codex.py:132-139` extracts `messages[0]` into `instructions` and drops it from the payload) — the sibling call path, previously uncovered The `pre_api_request` hook only forwarded `request_messages`, so traces for both paths looked like conversations with no instructions at all — e.g. a Codex-backed `Hermes turn` whose `LLM call 1` shows 18k prompt tokens but only a one-line user message as input. This PR salvages #32175 by @db-aeon onto current `main` (cherry-picked so authorship survives, per the AGENTS.md salvage etiquette), then fixes the whole bug class: - `agent/conversation_loop.py` derives the system prompt the provider actually receives — `api_kwargs[\"system\"]` → `api_kwargs[\"instructions\"]` → `messages[0]` fallback, extracted into a `_system_prompt_for_hooks()` helper — and passes it to `pre_api_request` as `system_prompt` - `plugins/observability/langfuse` prepends a serialized `role: system` entry to the generation input (after the last-12 window, so long conversations never drop it), flattens Anthropic content-block lists, skips the prepend when `messages[0]` already carries the system role (chat_completions), and records `system_prompt_chars` metadata ## Commits - **test(langfuse): system prompt must reach generation input across providers** — RED-first coverage: plugin contract (prepend, block-list flatten, no duplication, window-safe, metadata) **plus the cross-hook-boundary regression the #32175 review asked for** — real `ResponsesApiTransport().build_kwargs()` / `AnthropicTransport().build_kwargs()` output flows through the loop's derivation into the plugin hook with no Hermes internals mocked (only the Langfuse client is faked) - **fix(langfuse): include Anthropic system prompt in generation input** (@db-aeon, cherry-picked from #32175; conflicts with the current middleware/hook block resolved) - **fix(langfuse): carry Codex/Responses instructions into hook system_prompt** — the Codex sibling path + `_system_prompt_for_hooks()` extraction ## Test plan - [x] `tests/plugins/test_langfuse_plugin.py` — 59 passed (48 pre-existing + 9 new + 2 from #32175) - [x] Affected surface: `tests/plugins/`, `tests/hermes_cli/test_plugins.py`, `tests/run_agent/test_run_agent_codex_responses.py` — no regressions vs clean `main` - [x] `scripts/run_tests.sh` full suite — remaining failures reproduce identically on clean `main` (environment-specific, macOS), zero delta from this change - [x] E2E against a self-hosted Langfuse: enabled `observability/langfuse`, ran `hermes chat` on an `openai-codex` model — generation input now starts with `role: system` (102,659 chars, `system_prompt_chars` metadata matches); chat_completions sessions unchanged (single system entry, no duplication) Closes #32175 (supersedes it while preserving its commit; happy to rebase if @db-aeon prefers to land his PR first — this one then shrinks to the Codex path + tests).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/64292",
          "createdAt": "2026-07-14T08:56:10Z",
          "updatedAt": "2026-08-13T17:40:51Z",
          "timestamp": "2026-08-13T17:40:51Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "comp/plugins",
            "P3",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "FnExpress",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:821c8985574e629ece6c",
        "signalId": "github:NousResearch/hermes-agent:issue:85496",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85496",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "auth_middleware rejects desktop /api/ws?token= upgrade with HTTP 401 (boot loop)",
          "text": "# Bug: `auth_middleware` rejects the desktop `/api/ws?token=...` upgrade with HTTP 401 ## Affected version hermes-agent `0.20.0` (install stamp `fa83af3f9a42`, main, local install). Reproduced on macOS arm64. ## Symptom The Electron desktop app fails to boot in a loop: ``` Local Hermes backend is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: WebSocket connection failed. → Desktop boot failed → Restarting ``` ## Root cause `apps/desktop/dist/electron-main.mjs` spawns the backend with: ``` serve --host 127.0.0.1 --port 0 ``` and opens the WebSocket upgrade at `/api/ws?token=<session_token>` (the desktop app's own freshly minted token, set as `HERMES_DASHBOARD_SESSION_TOKEN` in the backend child env). The backend's `auth_middleware` (`hermes_cli/web_server.py`, `auth_middleware`) gates **every** `/api/*` request: ```python if path.startswith(\"/api/\") and path not in _PUBLIC_API_PATHS and not is_mcp_oauth_callback: if not _has_valid_session_token(request) and not _has_valid_query_token(request, path): return JSONResponse(status_code=401, ...) ``` `_has_valid_query_token` only whitelists `/api/files/download` for the `?token=` query param — it does **NOT** include `/api/ws` or `/api/pty`. So `auth_middleware` returns 401 on the WS upgrade **before** the actual `@app.websocket(\"/api/ws\")` handler (`_ws_auth_reason`) ever runs and validates the token against `_SESSION_TOKEN`. The WS handler itself would accept the token; the middleware blocks it first. ## Note on token sync (separate, related footgun) If a stale `HERMES_DASHBOARD_SESSION_TOKEN=...` line is left in `~/.hermes/.env` (e.g. pinned during a remote-tunnel setup), the backend is locked to that fixed token while the desktop app generates a *different* fresh token for the WS → also 401. Removing the `.env` line fixes the sync; the middleware bug above is independent of it. ## Proposed fix (minimal, what we currently run locally) Exempt the WS/PTY upgrade paths from the middleware's 401 so the handler's own check runs: ```python is_ws_or_pty = path in (\"/api/ws\", \"/api/pty\") if path.startswith(\"/api/\") and path not in _PUBLIC_API_PATHS and not is_mcp_oauth_callback and not is_ws_or_pty: if not _has_valid_session_token(request) and not _has_valid_query_token(request, path): return JSONResponse(status_code=401, ...) ``` This does **not** weaken auth: `_ws_auth_reason` still validates the token against `_SESSION_TOKEN` (hmac.compare_digest), so a wrong token is still rejected. It only moves the check from the middleware to the handler, where the desktop app expects it. ## Steps to reproduce 1. Fresh `HERMES_HOME`, no stale `.env` token. 2. `hermes desktop` (or spawn `serve --host 127.0.0.1 --port 0`). 3. Observe the boot loop / `curl -i \"http://127.0.0.1:<port>/api/ws?token=<env token>\"` → 401. 4. A real WS upgrade (`websockets.connect(\"ws://127.0.0.1:<port>/api/ws?token=<env token>\")`) is also rejected by the middleware.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85496",
          "createdAt": "2026-08-13T17:40:49Z",
          "updatedAt": "2026-08-13T17:40:49Z",
          "timestamp": "2026-08-13T17:40:49Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "strohjarvis",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:307435dd541f84af97b4",
        "signalId": "github:NousResearch/hermes-agent:pull_request:39653",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:39653",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(langfuse): surface reasoning_content in traces (#29482)",
          "text": "## Summary Langfuse assistant-message serialization only reads `message.reasoning`. Reasoning models and adapters can expose reasoning under `reasoning_content` or structured `reasoning_details`, which makes Langfuse traces show `reasoning: None` despite available reasoning data. This PR adds a small reasoning extraction helper and uses it during assistant-message serialization. ## Changes - `plugins/observability/langfuse/__init__.py`: serialize reasoning from `reasoning`, `reasoning_content`, or `reasoning_details`. - Tests: add Langfuse serialization coverage for alternate reasoning fields. ## Validation - `python -m pytest tests -k langfuse -v --timeout=0` - `python -m pytest tests/ -v --timeout=60` ## Upstream Closes #29482. Reported by @swanhtet1992.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/39653",
          "createdAt": "2026-06-05T07:36:15Z",
          "updatedAt": "2026-08-13T17:40:47Z",
          "timestamp": "2026-08-13T17:40:47Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "P3",
            "sweeper:blast-contained"
          ],
          "author": "rodboev",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:dac68d09279ec293662f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:42326",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:42326",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(langfuse): guard _get_langfuse() against concurrent-init TOCTOU",
          "text": "## Summary `_get_langfuse()` holds a global `_LANGFUSE_CLIENT` variable with no lock protecting the double-check initialization. Two concurrent first callers (e.g. two gateway sessions both triggering a Langfuse hook at the same time) both pass the `_LANGFUSE_CLIENT is not None` guard, both construct a `Langfuse(**kwargs)` client, and the loser's client leaks an open HTTPS connection and a background flush thread. Under sustained gateway load this accumulates silently. This is the same TOCTOU class fixed for the Honcho and FAL clients in `plugins/plugin_utils.py` (PR #24759 / commit `47d5177a7`). **Changes:** - Add `_LANGFUSE_CLIENT_LOCK = threading.Lock()` alongside the existing `_STATE_LOCK` (deliberately separate — `_STATE_LOCK` is already on the hot path for every hook call and nesting would risk deadlock). - Wrap the entire init body of `_get_langfuse()` in a double-checked lock. - The two outer fast-path checks (`_INIT_FAILED` / `is not None`) remain outside the lock so every hook call after the first stays zero-contention. - All existing `_INIT_FAILED` sentinel semantics (missing SDK, missing creds, placeholder keys, construction exception) are preserved unchanged. ## Test plan - [ ] `tests/plugins/test_langfuse_plugin.py` — 37 existing tests all pass - [ ] Behavior identical to before for the single-threaded case (fast path unchanged, `_INIT_FAILED` cached on any init failure, same log messages) - [ ] No new imports or external dependencies",
          "url": "https://github.com/NousResearch/hermes-agent/pull/42326",
          "createdAt": "2026-06-08T18:47:06Z",
          "updatedAt": "2026-08-13T17:40:43Z",
          "timestamp": "2026-08-13T17:40:43Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "P3",
            "telemetry",
            "sweeper:blast-contained"
          ],
          "author": "nftpoetrist",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:4d06c7d165d755c5da8c",
        "signalId": "github:NousResearch/hermes-agent:pull_request:82332",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:82332",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(langfuse): finalize open root spans at exit so short-lived processes export complete traces",
          "text": "## What Short-lived Hermes processes (kanban workers, `hermes chat -q` one-shots, cron jobs) export **anonymous traces** to Langfuse: no name, no `sessionId`, empty metadata — just a bag of orphaned observations. On a board running a few parallel workers this quickly becomes the majority of traces (33 of the most recent 50 on the instance where this was diagnosed). Root cause: the plugin ends the root span in `_finish_trace()`, which only runs when a turn finalizes (final assistant message with no tool calls). A process that exits while the last LLM response still has tool calls queued never reaches it. The SDK's own atexit flush then exports the ENDED child observations, but the root span never lands — so the backend renders a nameless trace shell around orphaned children. Fix: `_finalize_all_traces()` drains `_TRACE_STATE` and ends every open root span (children first), registered with `atexit` immediately after the Langfuse client is constructed. atexit is LIFO, so it runs BEFORE the SDK's own shutdown hook and the spans ended here are still picked up by the exporter. Idempotent; fail-open per span; no-op when nothing is pending. ## Why - Anonymous traces are unusable for debugging: they can't be found by session, carry no model/platform metadata, and pollute every trace listing. - Any deployment that runs workers/cron alongside the gateway hits this constantly. ## How to test ``` scripts/run_tests.sh tests/plugins/test_langfuse_plugin.py -- -q ``` 29 tests (2 new): `_finalize_all_traces` ends every open root and drains state (idempotent on re-entry), and the atexit hook is registered on client init. Both new tests fail without the fix (verified by stashing it). Live verification on the diagnosed instance: after the fix, a `hermes chat -q` one-shot exports a complete \"Hermes turn\" trace with session id; before, the same flow produced a nameless trace with 17+ orphaned observations. Tested on Linux (aarch64).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/82332",
          "createdAt": "2026-08-09T07:22:07Z",
          "updatedAt": "2026-08-13T17:40:39Z",
          "timestamp": "2026-08-13T17:40:39Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "P3",
            "telemetry"
          ],
          "author": "aldoeliacim",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:2e0eb8ed6f286c88e3a1",
        "signalId": "github:NousResearch/hermes-agent:pull_request:81054",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:81054",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(langfuse): shutdown client on session finalize to avoid interpreter-teardown TypeError",
          "text": "## Problem Quitting a Hermes session with the `observability/langfuse` plugin enabled prints a noisy traceback: ``` Exception ignored in: <generator object Langfuse._create_span_with_parent_context at 0x...> Traceback (most recent call last): File \".../langfuse/_client/client.py\", line 1297, in _create_span_with_parent_context File \".../opentelemetry/trace/__init__.py\", line 597, in use_span TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union ``` ## Root cause The langfuse plugin never called `client.shutdown()`. It relied entirely on the Langfuse SDK's own `atexit.register(self.shutdown)` handler (`resource_manager.py:279`). That atexit handler fires during interpreter finalization — by then module globals (notably `opentelemetry.trace.Span`) may already be torn down to `None`. The SDK's span-finalization path runs `use_span` → `isinstance(span, Span)` (`opentelemetry/trace/__init__.py:597`), and `Span` being `None` raises the `TypeError`. Python suppresses it as \"Exception ignored in: <generator>\". This is cosmetic (no data loss — spans were already queued), but every langfuse-enabled session sees it on quit. ## Fix Register an `on_session_finalize` hook in the plugin that explicitly calls `client.shutdown()` while the interpreter is still alive. `on_session_finalize` fires from the normal CLI exit path (`cli.py:1228` → `lifecycle.finalize_session`), not from atexit — so all modules are intact. `client.shutdown()` flushes pending spans and joins the background export threads. The SDK's own atexit handler then becomes a no-op (it checks `_shutdown` and unregisters itself), so the race with interpreter teardown never starts. ## Test plan - [x] `tests/plugins/test_langfuse_plugin.py` — 24/24 pass (updated manifest assertion for the new hook) - [x] `tests/hermes_cli/test_lifecycle.py` — 3/3 pass - [ ] Manual: quit a Hermes session with the langfuse plugin enabled — the \"Exception ignored in: <generator>\" traceback is gone",
          "url": "https://github.com/NousResearch/hermes-agent/pull/81054",
          "createdAt": "2026-08-07T13:29:05Z",
          "updatedAt": "2026-08-13T17:40:36Z",
          "timestamp": "2026-08-13T17:40:36Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "duplicate",
            "comp/plugins",
            "P3"
          ],
          "author": "bgodlin",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:2bdb8a08b4812b7747d2",
        "signalId": "github:NousResearch/hermes-agent:pull_request:83437",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:83437",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(langfuse): widen tracing to errors, sessions, subagents, and MoA fan-out",
          "text": "Widens the bundled Langfuse plugin from 6 hooks to 11 and fixes two attribution bugs. Also adopts the shutdown/atexit lifecycle fixes from #81054 and #82332 (original authorship preserved) with two interaction-fix follow-up commits. ## Model attribution `on_pre_llm_request` and `on_post_llm_call` read `model`, the agent's attribute at hook time. It goes stale after a mid-session `/model` switch or a provider fallback, so generations file under the wrong model and cost estimation keys off it. Both now prefer the wire value — `request[\"body\"][\"model\"]` and `response_model`. `agent/conversation_loop.py` already passed both and the plugin ignored them. ## Cost total Langfuse does not derive `calculatedTotalCost` from the per-type `cost_details` keys. `_usage_and_cost` wrote the breakdown and no total, so a priced generation read 0 in the dashboard while its components were correct. Both cost paths now send a summed `total`. A priced model that billed no tokens writes no breakdown and still sends no total. ## New coverage **Errors and session lifecycle.** `api_request_error` closes failed generations with `level=ERROR` and retry metadata. `on_session_finalize` and `on_session_end` close still-open traces so tool-only and interrupted turns stop dangling. Adds `HERMES_LANGFUSE_CAPTURE=metadata|sanitized|full`, default `sanitized`. **Subagents.** `tools/delegate_tool.py` emits `subagent_start`/`subagent_stop` and the sibling `nemo_relay` plugin already consumes them, so every delegated child was missing here. The payloads carry `parent_turn_id` but no `task_id`, and `_scope_prefix` prefers `task_id` when the LLM hooks minted the key — so `_state_for_turn` matches on the turn-id suffix instead of rebuilding a key that would miss. **MoA advisors.** A MoA turn runs N advisor models before its aggregator and returns only the aggregator's response, so the fan-out showed up as one generation priced at the aggregator's model. `_RefAccounting` already computes each advisor's usage and dollars, precisely because advisors run on a different provider and cannot be priced at the aggregator's rate. `moa_trace.slot_metrics` renders that and `post_api_request` carries it as `moa_references`. ## MoA plumbing The read is deliberately non-consuming. `post_api_request` fires on a different branch of `run_conversation` than `consume_reference_usage` and `consume_and_save_trace`, so a consuming read would race them. The client holds its last fan-out until the next one, so the plugin fingerprints each fan-out and does not re-emit the same advisors on every API call of a tool-loop turn. The payload derives from the already privacy-redacted `_trace_refs`, so an active privacy mode redacts it too. `slot_metrics` drops `input_messages` rather than crossing the hook boundary with a full transcript per advisor per turn. ## Verification 184 tests pass across the langfuse, MoA, subagent-hook, and relay-metrics suites. `moa_references` cannot reach the nemo relay's metrics payload: `model_call_fields` allowlists `model` and `provider`. The 11 whole-tree pytest collection errors are identical on `main`. ## Adopted lifecycle fixes **#81054 (bgodlin, 2 commits).** Quitting Hermes with the plugin enabled printed an \"Exception ignored in: <generator>\" TypeError traceback. The SDK's own atexit shutdown runs during interpreter finalization, after `opentelemetry.trace.Span` is torn down to `None`, so its span-cleanup `isinstance(span, Span)` check blows up. The fix shuts the client down at session finalize while the interpreter is alive, and exits the root observation's context manager instead of leaving its generator suspended for GC to unwind at teardown. **#82332 (aldoeliacim, 1 commit).** Short-lived processes (kanban workers, `hermes chat -q`, cron jobs) could exit with tool calls still queued, so the root span never ended and the backend showed an anonymous trace with no name, session, or metadata. An atexit finalizer — registered after the SDK client so LIFO ordering runs it before the SDK's own flush — ends every open root span at exit. **Follow-ups (interaction fixes found while composing).** The adopted shutdown fired on every `on_session_finalize`, but that hook also fires on `/new`, `/reset`, and gateway session expiry, where the process lives on — the first rotation would have killed the cached client and silently stopped exports for every later session. The shutdown is now gated on `reason == \"shutdown\"`. The adopted atexit finalizer also skipped subagent observations (added in this PR) and never exited the root context manager, reintroducing the same teardown TypeError it sat next to; both closed. ## Verification 74 langfuse + MoA-bridge tests pass; full `tests/agent/` + `tests/hermes_cli/` sweep is 8018 passed with 8 failures identical on the unmodified base (provider-routing/env, not langfuse). Live smoke against Langfuse Cloud: three sessions (clean turn, rotation-closed dangling turn, shutdown-closed dangling turn) all exported with correct names and session ids; the client survived rotation and shut down cleanly at exit. ## Adopted fixes (tier 2) Adopted with follow-up integration on top; each commit thanks and co-author-credits the original contributor. **#42326 (@nftpoetrist).** `_get_langfuse()` double-checked a global with no lock, so two concurrent first callers could both construct a client and leak the loser's HTTP connection and flush thread. First build is now serialized; the settled fast path stays lock-free. **#39653 (@rodboev).** Reasoning models that expose their scratchpad as `reasoning_content` or structured `reasoning_details` traced as `reasoning: None`. An accessor now checks the three fields in precedence order. Closes #29482. **#64292 (@FnExpress).** Providers that move the system prompt out of `messages` (Anthropic `system`, Codex `instructions`, Bedrock Converse blocks) produced generation inputs with no system prompt at all — no skills, memory, or instructions visible in traces. The loop now forwards the prompt as actually sent and the plugin prepends a `role: system` entry. Extends and supersedes #32175 (@db-aeon), whose Anthropic-only fix is credited in the commit. **#61166 (@Per0-1).** The plugin called `set_trace_io()`, which does not exist in SDK v3 — the AttributeError skipped `root_span.end()`, so traces listed with blank Input/Output columns and no CHAIN root. Trace I/O now uses v3 `update_trace()`, individually fail-open so no export step can block the root end. **#64797 (@NaMinhyeok).** Replaces this PR's earlier sum-of-components total with the canonical Hermes estimate: summing the breakdown undercounts when a component can't be priced or request-level pricing applies. Both cost paths now share one helper; a partial breakdown with no valid estimate exports no total. Together with the original cost-total commit this closes #72661. **#43130 (@liuhao1024).** Subscription-included routes (openai-codex) sent explicit `$0` cost_details, which Langfuse treats as authoritative — blocking its own model-based estimation, so every generation showed `$0` forever. Included routes now send no cost keys at all. Closes #43129. Integration fixes made while composing: system-prompt and reasoning serialization route through `_capture_content` so capture modes and secret redaction govern them; the canonical total adds a zero-guard so a priced model that billed nothing doesn't export an authoritative `0.0`. ## Closes / supersedes Closes #29482, closes #43129, closes #72661. Supersedes (can be closed in favor of this PR): #81054, #82332, #42326, #39653, #64292, #32175, #61166, #64797, #43130. Partially addresses #67544: capture modes (`metadata`/`sanitized`/`full`) plus secret redaction at the export boundary cover the masking half; `user_id` attribution remains open. ## Verification (tier 2) 88 plugin tests pass; full `tests/plugins/` + `tests/agent/` + `tests/hermes_cli/` sweep is 9380 passed with every failure reproduced on the unmodified base or current `main` (env-dependent provider tests, hindsight extra not installed locally, and a `_preset_cache` conftest collection error that exists on `main`). Live smoke against Langfuse Cloud confirmed server-side: system prompt visible in generation input, `reasoning_content` in output, `costDetails.total` matching `calculatedTotalCost`, trace-level Input/Output columns filled.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/83437",
          "createdAt": "2026-08-10T20:39:32Z",
          "updatedAt": "2026-08-13T17:40:27Z",
          "timestamp": "2026-08-13T17:40:27Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/plugins",
            "P3",
            "telemetry"
          ],
          "author": "erosika",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:6a2bdb74291e266554f4",
        "signalId": "github:NousResearch/hermes-agent:issue:29482",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:29482",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: Langfuse plugin shows reasoning: None for reasoning_content models (DeepSeek/Qwen/LM Studio convention)",
          "text": "## Bug Description The bundled `observability/langfuse` plugin's `_serialize_assistant_message` reads only the top-level `reasoning` attribute: ```python # plugins/observability/langfuse/__init__.py (HEAD: 31a010010) def _serialize_assistant_message(message: Any) -> dict[str, Any]: return { \"content\": _safe_value(getattr(message, \"content\", None)), \"reasoning\": _safe_value(getattr(message, \"reasoning\", None)), \"tool_calls\": _serialize_tool_calls(getattr(message, \"tool_calls\", None)), } ``` Providers that emit chain-of-thought via `reasoning_content` (LM Studio, Moonshot, Qwen3 thinking models, DeepSeek) get silently dropped. The transport correctly stores them on `NormalizedResponse.reasoning_content` (a property reading from `provider_data[\"reasoning_content\"]`, see `transports/types.py:115`), but the plugin never reads that field. Every `LLM call N` observation shows `reasoning: None` even though Hermes captures the reasoning fine (visible in CLI/Telegram via `last_reasoning` in `run_agent.py`). Independent of #26320, which fixed the missing `assistant_message` kwarg on the hook payload. The serialization gap is still in upstream HEAD. ## Steps to Reproduce 1. Hermes with LM Studio backend on a Qwen3 thinking model (e.g. `qwen3.6-35b-a3b-uncensored`), `chat_template_kwargs.enable_thinking: true`. 2. Enable `observability/langfuse` + set `HERMES_LANGFUSE_*` env vars. 3. Run any turn that triggers reasoning. 4. Inspect the `LLM call N` generation in Langfuse. Direct LM Studio response confirming `reasoning_content` is the field actually used: ``` Message keys: ['content', 'reasoning_content', 'role', 'tool_calls'] [content] (110 chars): \\n\\nThinking: Adding two to two ... Answer: 4 [reasoning_content] (796 chars): Here's a thinking process:\\n\\n1. **Analyze User Input:** ... ``` ## Expected Behavior `output.reasoning` on each generation observation contains the chain-of-thought. The same text is already extracted by `_extract_reasoning` (`run_agent.py`) and `extract_content_or_reasoning` (`agent/auxiliary_client.py:4404`) for other consumers; Langfuse should get it too. ## Actual Behavior `output.reasoning` is `None` on every generation observation. Live trace `c2acb87a9ac6...` from my deployment, generation `LLM call 12`: ``` content: '[573 chars]' # placeholder; fixed by #26320 in HEAD reasoning: None ``` ## Affected Component Other (plugin: `observability/langfuse`) ## OS / Python / Hermes Version macOS 26.4.1, Python 3.11.15, Hermes v0.13.0. Verified bug is still present on `main` @ `31a010010`. ## Root Cause Analysis In `chat_completions.py:563-583`, the transport splits reasoning across two destinations: - top-level `NormalizedResponse.reasoning` ← `msg.reasoning` (OpenAI convention) - `provider_data[\"reasoning_content\"]` ← `msg.reasoning_content` (DeepSeek convention) `NormalizedResponse.reasoning_content` exposes the second via a property (`transports/types.py:115`). The Langfuse plugin's serializer only reads the first. ## Proposed Fix Extend `_serialize_assistant_message` to walk `reasoning`, `reasoning_content`, `reasoning_details` (deduplicating). Same precedence as `extract_content_or_reasoning` in `agent/auxiliary_client.py`. No inline `<think>` regex fallback in the plugin; that's owned by `_build_assistant_message`. I can put up a PR.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/29482",
          "createdAt": "2026-05-20T21:07:18Z",
          "updatedAt": "2026-08-13T17:40:18Z",
          "timestamp": "2026-08-13T17:40:18Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "P3"
          ],
          "author": "swanhtet1992",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:9ed9475045665ca21039",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85439",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85439",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(langfuse): widen tracing to errors, sessions, subagents, and MoA fan-out",
          "text": "## Summary Widens the bundled Langfuse observability plugin from 6 hooks to 11 and fixes two attribution bugs, salvaged from PR #83437 by @erosika with adopted fixes from 8 prior community PRs. ## Changes - **Model attribution:** `on_pre_llm_request`/`on_post_llm_call` now prefer the wire value (`request[\"body\"][\"model\"]`, `response_model`) over the stale agent attribute after `/model` switch or provider fallback. - **Cost total:** Both cost paths now send a summed `total` alongside the per-type breakdown. Subscription-included routes send no cost keys at all (Langfuse treats explicit `$0` as authoritative). - **New hooks:** `api_request_error` (closes failed generations with ERROR level), `on_session_finalize`/`on_session_end` (closes dangling traces), `subagent_start`/`subagent_stop` (traces delegated children as spans), MoA advisor fan-out (one generation per advisor priced at the advisor's own model). - **Capture modes:** `HERMES_LANGFUSE_CAPTURE=metadata|sanitized|full` (default `sanitized`). Sanitized mode redacts secrets using the project-wide `agent.redact.redact_sensitive_text(force=True)`. - **Adopted lifecycle fixes:** Shutdown client at session finalize when `reason=shutdown` (not on session rotation); atexit finalizer ends open root spans for short-lived processes; root context manager exited to prevent interpreter-teardown TypeError; TOCTOU on `_get_langfuse()` fixed with lock; reasoning_content surfaced; system prompt included in generation input for Anthropic/Codex/Bedrock; SDK v3 `update_trace` replaces `set_trace_io`. ## Follow-up fixes applied during salvage (from /hermes-pr-review + /simplify-code) 1. Replaced `_redact_secrets` with `redact_sensitive_text(force=True)` from `agent/redact.py` — the plugin's 11-pattern list was a strict subset of the 50+ patterns in the existing redactor. Added `pk-lf-` to `_PREFIX_PATTERNS`. 2. Removed dead `not isinstance(client, object)` check in `on_session_finalize`. 3. Fixed `MoAClient.last_reference_metrics()` to call the public method instead of reaching into private `_last_reference_metrics` via `getattr`. 4. Deduplicated `_coerce_request_messages` call in `on_pre_llm_request` — pass `pre_coerced` to avoid double-coercion + double `_capture_content` serialization. 5. Added `HERMES_LANGFUSE_CAPTURE` to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py`. 6. Fixed `test_sanitized_mode_redacts_secrets` test data — old samples were too short to match regex thresholds and never actually tested redaction. ## Attribution Salvaged from PR #83437 by @erosika, with adopted fixes from: - @bgodlin (#81054) — shutdown client on session finalize - @aldoeliacim (#82332) — finalize open root spans at exit - @nftpoetrist (#42326) — guard _get_langfuse() against TOCTOU - @rodboev (#39653) — surface reasoning_content in traces - @FnExpress (#64292, supersedes #32175 by @db-aeon) — include system prompt in generation input - @Per0-1 (#61166) — use update_trace for I/O columns - @NaMinhyeok (#64797) — export canonical generation total - @liuhao1024 (#43130) — omit cost_details for subscription-included providers ## Validation | | Before | After | |---|---|---| | Langfuse + MoA tests | — | 99 passed | | Capture mode tests | — | 9 passed | | E2E smoke | — | All hooks registered, capture modes work, system prompt extraction for 4 provider shapes | ## Closes / supersedes Closes #29482, #43129, #72661. Supersedes #81054, #82332, #42326, #39653, #64292, #32175, #61166, #64797, #43130. Partially addresses #67544 (capture modes + secret redaction; user_id remains open).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85439",
          "createdAt": "2026-08-13T16:51:44Z",
          "updatedAt": "2026-08-13T17:40:17Z",
          "timestamp": "2026-08-13T17:40:17Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/plugins",
            "P3"
          ],
          "author": "kshitijk4poor",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:36d03c99ba6d3398f3b9",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85476",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85476",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(slack): native live cards — chat.startStream reply streaming + opt-in plan/task-card tool progress",
          "text": "## Summary Slack replies now stream as native live-typing messages, and (opt-in) tool progress renders as Slack-native plan/task cards — consolidating the open \"Slack live cards\" PR/issue cluster (#66542 + #29496; closes #29483, addresses #51363's thinking-steps surface). ## Changes - `plugins/platforms/slack/adapter.py`: native streaming via `chat.startStream`/`appendStream`/`stopStream` through the existing draft-streaming interface (`supports_draft_streaming`/`send_draft`); the stream IS the final message, sealed once via `stopStream` (no duplicate final post); feature-gate errors cached → clean fallback to edit-based streaming. Plus `send_native_task_card_progress`/`stop_native_task_card_progress`: workspace-scoped, lock-serialized plan/task-card streams (`task_display_mode: plan`, `task_update` chunks), sealed on disconnect. - `gateway/run.py` + `gateway/turn_context.py`: `platforms.slack.extra.native_task_cards` opt-in wiring on the TurnRunner seam — ID-bearing `tool_start_callback`/`tool_complete_callback` correlate concurrent same-name tool calls by real call ID; native consumer drains the progress queue; on native failure, one continuously edited text fallback keeps progress live; card stream stopped exactly once at turn end. - Docs: `native_task_cards` config key + \"Native Streaming\" and \"Native Task Cards\" sections in the Slack guide; `cli-config.yaml.example` entry. ## Attribution - @ashah360's commit from #66542 cherry-picked (native streaming). - @simonvanlaak's #29496 (earliest surviving task-cards PR, from his own issue #29483) reapplied onto the current TurnRunner/TurnContext seam with authorship preserved. ## Validation | Suite | Result | |---|---| | tests/gateway/test_slack_native_streaming.py | 18 passed | | tests/gateway/test_slack.py + test_run_progress_topics.py + test_stream_consumer_draft.py | 203 passed | | Base freshness | 0 commits behind origin/main at push | ## Infographic ![Slack Live Cards infographic](https://files.catbox.moe/1a9l2s.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85476",
          "createdAt": "2026-08-13T17:25:51Z",
          "updatedAt": "2026-08-13T17:40:09Z",
          "timestamp": "2026-08-13T17:40:09Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/gateway",
            "comp/plugins",
            "platform/slack",
            "P3",
            "needs-decision",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility",
            "area/streaming"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ee995cc9a7ee5ffb93dd",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85451",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85451",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "docs(telegram): document allowed_topics whitelist and free_response gates",
          "text": "Fixes #78770. The forum-topic whitelist (`telegram.allowed_topics` / `TELEGRAM_ALLOWED_TOPICS`) and the two free-response gates (`free_response_chats` / `free_response_topics`) are fully implemented in `plugins/platforms/telegram/adapter.py` but were not discoverable from any documentation. This adds the three env vars to the reference table in `environment-variables.md` and a bullet each in the Telegram user guide, including the whitelist-before-ignore ordering and the General-topic normalization to ID `1` (verified at adapter.py `_telegram_allowed_topics` and `_should_process_message`). Docs-only, no behavior change.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85451",
          "createdAt": "2026-08-13T17:04:44Z",
          "updatedAt": "2026-08-13T17:39:45Z",
          "timestamp": "2026-08-13T17:39:45Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/docs",
            "comp/gateway",
            "platform/telegram",
            "P3"
          ],
          "author": "Luna161",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:6d732307513b160e17ed",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85474",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85474",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(enterprise): deployment packaging — Dockerfile, Helm chart, RBAC, smoke script",
          "text": "## Summary Packaging to run Hermes Enterprise on a cluster: one multi-stage image serving both the controller (`python -m enterprise.cli`) and per-namespace gateways, a Helm chart with least-privilege RBAC scoped to `hermes-*` namespaces, and a guarded smoke script. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `deploy/enterprise/Dockerfile`: python:3.12-slim multi-stage, venv, nonroot, OCI labels. - `deploy/enterprise/helm/`: Chart, values, OCC Deployment + PVC (sqlite state), ServiceAccount + Role/RoleBinding (deployments/serviceaccounts/networkpolicies/namespaces, `hermes-*` only), ingress, NOTES. - `deploy/enterprise/smoke.sh` + README (topology, install, values table). ## Validation | | Result | |---|---| | YAML parse (all templates) | pass | | `bash -n` + shellcheck smoke.sh | pass | | helm lint | skipped — no helm binary on this box (noted, not faked) | ## Infographic ![Deployment packaging](https://files.catbox.moe/vkb02y.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85474",
          "createdAt": "2026-08-13T17:24:28Z",
          "updatedAt": "2026-08-13T17:38:44Z",
          "timestamp": "2026-08-13T17:38:44Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "area/docker",
            "P3",
            "needs-decision"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c6d20b146d7cd5a3e755",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85472",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85472",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(enterprise): K8s sandbox driver — verified containment before harness start",
          "text": "## Summary The v1 `SandboxDriver`: enforces the admitted `SandboxPolicy` via NetworkPolicy + pod securityContext, and independently verifies enforcement by reading the applied objects back before the controller may activate. Partial support is unsupported; unverifiable containment blocks activation. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `enterprise/drivers/sandbox_k8s.py`: `K8sSandboxDriver` (`k8s-baseline`). `supports()` whole-policy truth table (unknown keys ⇒ False, unexpressable egress ⇒ False); `enforce()` default-deny / exact egress-allowlist NetworkPolicies + securityContext patch (nonroot, seccomp RuntimeDefault, drop ALL caps); `verify()` compares readback against policy exactly — drift, missing objects, or kubectl failure ⇒ `DriverError`. ## Validation | | Result | |---|---| | `tests/enterprise/test_sandbox_k8s.py` | 34/34 pass | | ruff | clean | ## Infographic ![Sandbox containment schematic](https://files.catbox.moe/wjkd85.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85472",
          "createdAt": "2026-08-13T17:24:24Z",
          "updatedAt": "2026-08-13T17:38:35Z",
          "timestamp": "2026-08-13T17:38:35Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "P3",
            "needs-decision",
            "sweeper:risk-security-boundary"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:daaa6790d3158d5dd125",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85340",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [
          "text",
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85340",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cron): relay-fronted Slack delivery — synthetic creation-thread capture + preflight fronted-platform blindness",
          "text": "## Summary Two interlocking cron-delivery bugs on relay-fronted Slack deployments (the gateway runs only the relay platform; a connector owns the Slack app). Reported from a live deployment: `/sethome` was set correctly, yet cron jobs created from Slack delivered into the ephemeral thread that Slack spawns around the creation message — and the explicit-target escape hatch `deliver: \"slack:<chat_id>\"` was rejected at preflight with \"delivery platform 'slack' has no gateway credentials configured (not connected)\". ### Bug 1 — synthetic session-keying thread persisted as durable routing Relay-fronted Slack in thread-per-message mode stamps each top-level message's own id as `source.thread_id` (`gateway/relay/adapter.py::_stamp_slack_session_thread`, mirroring native `thread_ts = event.thread_ts or ts`). Its docstring is explicit that the stamp exists for **session keying** — but three consumers persisted it as a delivery address: 1. **Cron origin capture** (`tools/cronjob_tools.py::_origin_from_env`) stored it as `origin.thread_id`, and fire-time resolution replayed it unconditionally (`cron/scheduler.py::_resolve_single_delivery_target`), with a Slack origin-affinity block re-attaching it even onto explicit `slack:<chat_id>` targets. 2. **`/sethome` itself** (`gateway/slash_commands.py`) captured it into the persisted `HomeChannel.thread_id` and its legacy env mirror — pinning the *home target* to the thread around the `/sethome` message, so bare `deliver: \"slack\"` also routed into a stale thread. Found during live validation of fix 1; same root cause, third consumer. **Fix — recognize the stamp at every capture boundary, plus a fire-time repair for jobs persisted before the fix:** - Capture (cron): `_origin_from_env` drops a Slack thread id equal to the creation message's own id (`HERMES_SESSION_MESSAGE_ID`). A genuine in-thread creation (thread id = parent's id, not this message's own) keeps its thread. - Capture (`/sethome`): new `_home_thread_from_source` applies the same recognition before persisting the home target. Running `/sethome` inside a real thread still pins that thread deliberately. Users repair an already-poisoned home target by rerunning `/sethome`. - Fire time: `_origin_thread_is_stale` treats a Slack origin thread as stale when the origin chat is the configured Slack home chat — delivery goes top-level (or to the home target's own configured thread). Non-home chats keep their threads; the explicit-target re-attach is gated on the same predicate. - Slack-scoped throughout: Telegram forum topics legitimately reuse ids and are untouched (covered by negative-control tests). ### Bug 2 — preflight blind to relay-fronted platforms `_preflight_check_delivery` and `cron_delivery_targets` validated deliver prefixes against `get_connected_platforms()`, which only sees **natively configured** platforms — `{relay}` on a relay-only deployment, so `slack:<chat>` was refused before any spend. Fire-time routing already handles this topology correctly (`gateway/delivery.py::resolve_delivery_transport` → `RelayAdapter.fronts_platform`; the delivery loop even bypasses the native enabled-gate for relay transports). Validation and routing were asymmetric; the asymmetry is the bug. **Fix:** new `gateway.relay.relay_fronted_platforms()` — a thin wrapper over the existing `relay_platform_identities()` (env-derived from `GATEWAY_RELAY_PLATFORMS`, the same source that seeds the live adapter's identity set, so the two paths cannot disagree; usable from a standalone scheduler process with no live adapter handle). Unioned into the connected set at both validation sites, only when the relay itself is connected. Native topologies keep the strict credential check unchanged. ## Live validation (relay-fronted staging fleet, 2026-08-13) | Lane | Pre-fix behavior | Post-fix result | |---|---|---| | `deliver: \"origin\"` (job created from Slack DM) | delivered into creation-message thread | **verified: top-level delivery** | | `deliver: \"slack:<chat_id>\"` | killed at preflight (\"no gateway credentials\") | **verified: schedules and delivers top-level** | | `deliver: \"slack\"` (bare → home) | delivered into the old `/sethome` message's thread | **verified: top-level after rerunning `/sethome`** | ## Test evidence RED before fix (6 failed = the new contracts; 8 passed = negative controls): ``` FAILED tests/cron/test_cron_origin_synthetic_thread.py::TestSlackSyntheticThreadCapture::test_synthetic_slack_thread_not_captured FAILED tests/cron/test_cron_relay_delivery_guards.py::TestOriginThreadStaleGuard::test_origin_thread_dropped_when_chat_is_home FAILED tests/cron/test_cron_relay_delivery_guards.py::TestOriginThreadStaleGuard::test_home_thread_config_still_wins FAILED tests/cron/test_cron_relay_delivery_guards.py::TestOriginThreadStaleGuard::test_explicit_target_no_reattach_when_chat_is_home FAILED tests/cron/test_cron_relay_delivery_guards.py::TestPreflightRelayFronted::test_relay_fronted_slack_accepted FAILED tests/cron/test_cron_relay_delivery_guards.py::TestPreflightRelayFronted::test_delivery_targets_include_relay_fronted 6 failed, 8 passed ``` Mutation checks (fix committed first, one guard stubbed at a time, tests must go red, tree restored): - cron capture guard stubbed → `test_synthetic_slack_thread_not_captured` FAILED (1 failed, 3 passed) - `_origin_thread_is_stale` stubbed to `return False` → 3 staleness-guard tests FAILED (3 failed, 7 passed) - fronted-union stubbed to `return set()` → 2 preflight tests FAILED (2 failed, 2 passed) - `/sethome` guard stubbed → `test_synthetic_slack_thread_dropped` FAILED (1 failed, 4 passed) Canonical runner on the final tree: ``` === Summary: 74 files, 715 tests passed, 0 failed, 1 skipped (100% complete) in 13.9s (8 workers) === (scripts/run_tests.sh tests/cron tests/gateway/relay) ``` Full cron + gateway sweep after the `/sethome` commit: 543 passed, 1 skipped. ## Surfaces - No new config keys, no new model-tool surface, no wire/frame changes (`relay_fronted_platforms()` reads the existing `GATEWAY_RELAY_PLATFORMS` deploy stamp). - No connector-side changes required: both bugs are gateway-local (capture/validation); the connector's delivery path was already correct. - Behavior change scope: Slack origins/home targets only for bug 1; relay-connected deployments only for bug 2. ## What this does NOT do - No change to the per-message-session UX — the adapter's session-keying stamp is untouched; only its capture as delivery routing changes. - No `gateway/delivery.py` changes — fire-time routing was already correct. - No standalone-scheduler fire-path hardening (in-gateway fire works via the existing relay transport bypass). - Thread-continuation UX for cron deliveries in thread-per-message mode (a reply under a delivered brief opens an unseeded session) is a separate concern with a fix in progress on `fix-slack-delivery-root-seed`; it will arrive as its own PR once live-validated.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85340",
          "createdAt": "2026-08-13T14:02:55Z",
          "updatedAt": "2026-08-13T17:38:19Z",
          "timestamp": "2026-08-13T17:38:19Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "comp/cron",
            "platform/slack",
            "P2",
            "sweeper:risk-message-delivery"
          ],
          "author": "victor-kyriazakos",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:7b5d3ed9667f9bf13dd6",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85469",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85469",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(enterprise): access gateway — fail-closed identity verification and tenant admission",
          "text": "## Summary The access-gateway boundary: verifies externally-issued identity tokens and admits requests to exactly one installation and (when required) one namespace. Fail-closed on every path; caller claims can never select scope. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `enterprise/oag.py`: `TrustConfig` + `OAGVerifier` (implements `IdentityVerifier`). Stdlib-only HS256 JWT verification (constant-time compare), exact-alg enforcement (rejects `none`/alg-confusion), optional RS256 via `cryptography` (unavailable ⇒ `AdmissionError`, never skipped), iss/aud/exp/nbf/sub/required-claims checks. - Installation always comes from server-side config; tenant claim resolves through an explicit `tenant_map` and must match the required namespace exactly. - Test-support `mint_test_token` (HS256, clearly marked) for dev setups. ## Validation | | Result | |---|---| | `tests/enterprise/test_oag.py` | 15/15 pass (incl. tampered payload, no token leakage in errors) | | ruff | clean | ## Infographic ![Access gateway](https://files.catbox.moe/5hf9vf.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85469",
          "createdAt": "2026-08-13T17:24:17Z",
          "updatedAt": "2026-08-13T17:37:36Z",
          "timestamp": "2026-08-13T17:37:36Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "area/auth",
            "P3",
            "needs-decision",
            "sweeper:risk-security-boundary"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:d6cf6e296bbf2ff27a70",
        "signalId": "github:NousResearch/hermes-agent:pull_request:50456",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:50456",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(web): add web-native-fetch - local HTTP extract provider (no API key)",
          "text": "## Summary Implements the **native extract provider** concept first discussed in issue #19198 — a zero-dependency local HTTP fetcher with readability-lxml main-content extraction. No API keys required. ## What it does - **New plugin** `plugins/web/native` — HTTP GET via `httpx`, content extraction via `readability-lxml`, markdown conversion via `html2text`. Extract-only (`supports_search() -> False`), pair with any search provider (e.g. `ddgs`, `searxng`) - **Infrastructure fix** — `_is_backend_available()` in `tools/web_tools.py` now falls back to checking the web provider registry, enabling custom/user plugins to pass the availability gate. `_ensure_web_plugins_loaded()` moved before `_get_extract_backend()` so plugins are loaded during backend selection - **Dependencies** — `[native-fetch]` extra in `pyproject.toml` pulls in `readability-lxml` and `html2text` ## Usage ```yaml web: search_backend: ddgs extract_backend: native ``` ## Testing - Provider unit tests included - Verified end-to-end: `web_extract` successfully fetches and parses pages via the plugin - All existing web tool tests pass",
          "url": "https://github.com/NousResearch/hermes-agent/pull/50456",
          "createdAt": "2026-06-21T22:49:24Z",
          "updatedAt": "2026-08-13T17:37:35Z",
          "timestamp": "2026-08-13T17:37:35Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/feature",
            "comp/plugins",
            "tool/web",
            "P3",
            "sweeper:risk-security-boundary",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "VHSgunzo",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:0303974ea08b549a8317",
        "signalId": "github:NousResearch/hermes-agent:issue:72884",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:72884",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "file-mutation verifier falsely claims target was unchanged after terminal mutation",
          "text": "## Bug Description The turn-end file-mutation verifier can make a demonstrably false claim when a failed `write_file` or `patch` attempt is followed by a successful mutation through `terminal` in the same turn. The verifier currently says the target **\"was NOT modified this turn\"** and instructs the user to trust that footer over the assistant's summary. However, Hermes intentionally allows approved terminal commands to modify files and does not include `terminal` in the verifier's mutating-tool set. A successful terminal mutation therefore leaves the earlier failed file-tool record unresolved, causing the footer to contradict actual filesystem state. This is distinct from #70719, which covers argument-validation failures that never attempted I/O. Here, a separate tool really does modify the target before the incorrect footer is emitted. ## Steps to Reproduce 1. Enable the default verifier: ```yaml display: file_mutation_verifier: true ``` 2. In one agent turn, call `patch` against a protected path so the direct file tool fails, for example: ```text Write denied: '/home/user/.ssh/config' is a protected system/credential file. ``` 3. With explicit user approval, call `terminal` and modify the same path successfully, for example with a Python script using an atomic replacement. 4. Verify through `terminal`, `read_file`, or `stat` that the target now contains the intended change. 5. Finish the turn. A minimal state-level reproduction against `AIAgent` is: ```python agent = object.__new__(AIAgent) agent._turn_failed_file_mutations = {} agent._turn_file_mutation_paths = set() agent._record_file_mutation_result( tool_name=\"patch\", arguments={\"mode\": \"replace\", \"path\": \"/tmp/example\"}, tool_result='{\"success\": false, \"error\": \"write denied\"}', ) # `terminal` is intentionally not classified as a file-mutating tool, so a # later successful terminal rewrite does not clear the outstanding record. agent._record_file_mutation_result( tool_name=\"terminal\", arguments={\"command\": \"...writes /tmp/example...\"}, tool_result='{\"exit_code\": 0}', ) print(agent._format_file_mutation_failure_footer( agent._outstanding_file_mutation_failures() )) ``` ## Actual Behavior The final response can include: ```text ⚠️ File-mutation verifier: 1 file(s) were NOT modified this turn despite any wording above implying otherwise. ``` That assertion is false: the file was modified successfully through `terminal` and its new state was verified. ## Expected Behavior The verifier should not claim that a target was unchanged unless Hermes can establish that across all write-capable tool surfaces. At minimum, narrow the footer to the fact Hermes actually knows, for example: ```text File-mutation verifier: 1 target still has an unresolved failed write_file/patch attempt. This does not prove the target was unchanged; terminal or another tool may have modified it. Verify current state. ``` Alternatively, represent later opaque write-capable tools as uncertainty and suppress the absolute unchanged claim. ## Root Cause Analysis `agent/tool_result_classification.py` restricts mutation tracking to: ```python _FILE_MUTATING_TOOLS = frozenset({\"write_file\", \"patch\"}) ``` `AIAgent._record_file_mutation_result()` consequently ignores `terminal`. The existing verifier tests explicitly treat terminal as a non-mutating/untracked tool. This is consistent with the verifier's narrow implementation, but inconsistent with the absolute wording of `_format_file_mutation_failure_footer()` and the documentation's instruction to trust that footer. Hermes' security documentation separately and correctly explains that the protected-path guard covers direct file tools while terminal remains an approval-controlled escape path. The verifier copy needs to preserve that distinction. ## Proposed Regression Test Add a test that records: 1. a failed `patch` for a path; 2. a successful `terminal` call capable of changing that same path; and 3. the final verifier footer. Assert that the footer: - still reports the unresolved direct file-tool failure if desired; - does **not** contain `were NOT modified` or any equivalent assertion of unchanged state; and - clearly notes that other tools may have modified the target and current state should be verified. ## Impact This is a trust and safety issue rather than a cosmetic discrepancy. The footer presents itself as authoritative and can override correctly verified operational reporting, causing users to believe an approved configuration change did not happen when it did. ## Related Issues - #70719 — verifier footer fires on argument-missing patch calls; adjacent wording issue, but does not cover successful mutation by another tool. - The ACP alternate-write approval issue is also adjacent, but concerns whether a denied edit may be retried through terminal. This report concerns the verifier's false final statement after an authorized terminal mutation. ## Environment - Hermes Agent: `v0.19.0 (2026.7.20)` - Upstream reported by install: `2b0fb72a` - Local checkout: `0fa5e41c86f022bba147797849f0b44865721476` (`+1 carried commit`) - Python: `3.11.15` - OS: Linux `6.8.0-136-generic` x86_64 - Surface: Discord gateway, though the bug is in shared `AIAgent` turn-finalization logic",
          "url": "https://github.com/NousResearch/hermes-agent/issues/72884",
          "createdAt": "2026-07-27T20:19:26Z",
          "updatedAt": "2026-08-13T17:37:11Z",
          "timestamp": "2026-08-13T17:37:11Z",
          "metrics": {
            "reactions": 0,
            "comments": 5
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "tool/file",
            "P3"
          ],
          "author": "kaitwalla",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:3e7978c4b45c8ae2f550",
        "signalId": "github:NousResearch/hermes-agent:issue:57955",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:57955",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "terminal tool lacks protected-file path validation (bypasses SOUL.md write-protection)",
          "text": "## Bug Description The `terminal` tool has no path validation for protected system files. An agent can bypass the SOUL.md write-protection rules (which block `write_file`/`patch` on protected paths) by executing shell commands like `sed -i \"\" \"s/old/new/\" ~/.hermes/SOUL.md`. ## Steps to Reproduce 1. SOUL.md lists protected files: SOUL.md, AGENTS.md, config.yaml, vault/ files, .env, cron/jobs.json, skills/ SKILL.md 2. `write_file(\"~/.hermes/SOUL.md\")` → correctly blocked 3. `patch(\"~/.hermes/SOUL.md\", ...)` → correctly blocked 4. `terminal(\"sed -i \\\"\\\" ... ~/.hermes/SOUL.md\")` → **executed without any path check** ## Expected Behavior The `terminal` tool should parse file-path arguments in shell commands and reject writes to the same protected-file list used by `write_file`/`patch`. At minimum, warn when a protected path is detected as a write target. ## Environment - Hermes Agent (latest as of 2026-07-04) - macOS ## Current Mitigation (in our config) SOUL.md has a text-level ban (\"禁止用 terminal(sed/awk/perl/tee) 绕过标准入口直改受保护文件\"), but this relies on agent compliance — there is no tool-level enforcement.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/57955",
          "createdAt": "2026-07-03T22:06:09Z",
          "updatedAt": "2026-08-13T17:37:03Z",
          "timestamp": "2026-08-13T17:37:03Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/security",
            "tool/terminal",
            "P3",
            "sweeper:cannot-reproduce",
            "sweeper:risk-security-boundary"
          ],
          "author": "colorcn",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:f0b1908ca620d6794dd3",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85470",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85470",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(enterprise): controller — deploy choreography, rollback, namespace lifecycle, CLI",
          "text": "## Summary The controller: owns namespace lifecycle and the single deployment path — authorize → resolve + per-reference authorize → gateway ready → sandbox supports entire policy → immutable `AgentRevision` snapshot → provision candidate → enforce + verify containment → prepare nonserving route → retire previous → activate → start harness → enable route. Failures before activation leave the previous revision untouched; failures after trigger verified rollback, and an unverifiable rollback leaves the agent inactive (`RollbackError`). Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `enterprise/controller.py`: `Controller` + `GatewayManager` ABC (+ in-memory impl); per-agent deploy serialization; monotonic revision numbering; stable `wi-<agent>` workload identity; every decision audited. - `enterprise/cli.py` + `__main__.py`: `python -m enterprise.cli` — init, ns create/list, harness register, config put, agent create/deploy/rollback, get/list, audit tail. Dev-mode memory drivers gated behind `--driver memory`; no wiring into `hermes_cli` yet (kept zero-footprint until the stack lands). ## Validation | | Result | |---|---| | `tests/enterprise/test_controller.py` | 15/15 pass (exact call-order, all failure stages, rollback, CLI smoke deploy) | | ruff | clean | ## Infographic ![The controller](https://files.catbox.moe/oatdc9.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85470",
          "createdAt": "2026-08-13T17:24:19Z",
          "updatedAt": "2026-08-13T17:37:00Z",
          "timestamp": "2026-08-13T17:37:00Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "P3",
            "needs-decision"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:f56db85eca867ac507ce",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85467",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85467",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(relay): session-span segmentation for continuous sessions (salvage #85009)",
          "text": "## Summary Salvage of #85009 by @victor-kyriazakos: opt-in Relay session-scope segmentation so continuous gateway sessions export session-level telemetry at turn boundaries instead of holding one unexported span open for weeks — plus a LIFO-safety fix and docs on top. Relay export is close-driven; a Telegram/Slack session that never ends means the session root span (and its marks) never exports, and a crash loses the whole open segment. Turn spans already export per-turn (#83517); this handles the session-scope remainder. ## Changes - `agent/relay_runtime.py`: `gateway.telemetry.session_segments.{on_compaction,max_turns}` (both default OFF → byte-identical lifecycle); rotation consumed only in `begin_turn`, rides the bounded scope-op executor (#83514); segments share `session_id` + `hermes.session.segment` / `segment_reason` metadata (contributor's work, authorship preserved) - `agent/conversation_compression.py`: observer-only compaction notification (contributor's work) - **Fix on top:** the legacy rotating-compaction path closed the old session scope immediately — a compaction completing while a turn was still live on the old session would pop the session scope under the live turn scope (LIFO violation). Now defers via `close_pending`, consumed by that turn's `end_turn` after its own scope pops. Sabotage-verified: the new regression test fails without the fix. - **Docs on top:** `observability/nemo_relay` section on the built-in plugins page (the plugin had no section) with the `session_segments` config table; summary mirrored in the plugin README. ## Validation | | Result | |---|---| | PR test suite | 12/12 (contributor's 11 + 1 new mid-turn deferred-close regression test) | | All relay tests | 50/50 | | Sabotage run | new test fails at the exact assertion with the fix reverted | | Defaults-off contract | pinned by `TestDefaultsNeverRotate` | | Attribution audit | clean | ## Infographic ![Relay session-span segmentation](https://files.catbox.moe/mpxfpr.png) Closes #85009.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85467",
          "createdAt": "2026-08-13T17:24:11Z",
          "updatedAt": "2026-08-13T17:36:54Z",
          "timestamp": "2026-08-13T17:36:54Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "P3",
            "telemetry",
            "sweeper:risk-session-state",
            "area/compression"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:0b815e2ad8fe236f6400",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85471",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85471",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(enterprise): KubernetesComputeDriver — candidate provisioning via kubectl, gated harness start",
          "text": "## Summary The v1 `ComputeDriver`: provisions candidate workloads on Kubernetes via `kubectl` (no python k8s dependency). Candidates are created at `replicas: 0` — the harness literally cannot start until the controller activates the revision and the driver scales to 1. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `enterprise/drivers/kubernetes.py`: ServiceAccount + Deployment (JSON on stdin) with `app.hermes/*` labels, workload-identity SA, sandbox resource limits; readiness via observedGeneration; `start_harness` scales 0→1 with bounded polling; `stop_harness` verifies zero; teardown `--ignore-not-found`; any nonzero kubectl exit ⇒ `DriverError` with stderr. - Single `_kubectl()` seam for testability; secrets never appear in manifests. ## Validation | | Result | |---|---| | `tests/enterprise/test_compute_k8s.py` | 13/13 pass (manifest render from a real AgentRevision, ordering, timeouts, error paths) | | ruff | clean | ## Infographic ![Kubernetes compute driver](https://files.catbox.moe/fzsuir.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85471",
          "createdAt": "2026-08-13T17:24:22Z",
          "updatedAt": "2026-08-13T17:36:01Z",
          "timestamp": "2026-08-13T17:36:01Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "P3",
            "needs-decision"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:8d3d46b15c36e34933bf",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85468",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85468",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(enterprise): native IAM — principals, roles, bindings, restrictions",
          "text": "## Summary Native IAM for the Hermes Enterprise control plane: Principals, ServicePrincipals, WorkloadIdentities, Groups, Roles, AccessBindings, and Restrictions, with a deny-by-default `OCCIAMAdapter` implementing the `IAMAdapter` contract from #85461. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `enterprise/iam.py`: `IAMStore` (7 SQLite tables, WAL) + `OCCIAMAdapter` (name `occ-native`). Scope containment installation ⊇ namespace ⊇ exact-resource; group membership resolution; `Restriction` deny patterns (`action:Kind[:name]`) raise `RestrictionError` even over an allow — restrictions narrow, never grant. - Binding confinement enforced at creation: namespace-scoped ServicePrincipals/Groups can't receive installation bindings; a WorkloadIdentity binds only within its own namespace. - `resolve_principal(issuer, subject)` — unknown identity is denied; admission cannot create identities. ## Validation | | Result | |---|---| | `tests/enterprise/test_iam.py` | 29/29 pass | | ruff | clean | ## Infographic ![Hermes Enterprise IAM](https://files.catbox.moe/568w74.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85468",
          "createdAt": "2026-08-13T17:24:15Z",
          "updatedAt": "2026-08-13T17:35:39Z",
          "timestamp": "2026-08-13T17:35:39Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "P3",
            "needs-decision"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:f783a5f89d807e81a674",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85187",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85187",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway): pool-route voice RPCs so STT lazy install can't block WS sends",
          "text": "## Problem Desktop \"send message\" does nothing for minutes after a fresh launch, even though the backend boots fine (`Hermes backend is ready`). Sending a message (WS `prompt.submit`) never reaches the agent. ## Root cause `voice.toggle` (status action) → `check_voice_requirements()` → STT provider auto-detect → `_try_lazy_install_stt()` → `ensure(\"stt.faster_whisper\")` → **synchronous** `subprocess.run(uv pip install ...)` with a **300s timeout**. On the desktop WebSocket this runs **inline on the WS reader thread** (`handle_ws` awaits `dispatch` before reading the next frame), so `prompt.submit` / `session.list` frames queued behind a `voice.toggle` sit unread for minutes — the UI shows the message but nothing happens. The faster-whisper install (~50MB of wheels incl. ctranslate2/onnxruntime) fails or aborts repeatedly, so every launch re-triggers the blocking install. Reproduced with a WS probe against a real `serve` backend: `voice.toggle` → `session.list` times out at 40s+; without the voice RPC both respond in 0.0s. ## Fix Route `voice.toggle` / `voice.record` / `voice.tts` to the RPC pool via `_LONG_HANDLERS` (same bug class as #21123 / #50005: anything that can stall for seconds must stay off the reader thread). Verified end-to-end: after the patch, `voice.toggle` runs in the background while `session.list` responds in 0.0s and `prompt.submit` streams immediately. ## Tests - New regression test `test_voice_handlers_are_pool_routed` (test_protocol.py) asserting the voice RPCs stay pool-routed. - Adapted existing voice handler tests to drive the handler inline via a small `_dispatch_sync` helper (preserving transport-binding semantics) since `dispatch()` now returns `None` for pool-routed methods. - Note: `test_wake_owner_is_sticky_and_routes_detection_to_first_transport` fails on clean `main` too when the full file runs (test-order dependency, pre-existing); it passes in isolation and with `-k` filtering.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85187",
          "createdAt": "2026-08-13T09:32:08Z",
          "updatedAt": "2026-08-13T17:35:36Z",
          "timestamp": "2026-08-13T17:35:36Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/tui",
            "tool/tts",
            "P1"
          ],
          "author": "hustwkr",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:3af294d761a2eb49026a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:67163",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:67163",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(termux): complete native install, TUI, and Desktop support",
          "text": "## What does this PR do? Completes Hermes's native Android/Termux support end to end: immutable Python dependencies, safe update behavior, narrow-terminal TUI UX, and the current Desktop renderer through Termux:X11 plus the phone's normal localhost browser. This is self-contained and rebased directly on current upstream `main`. It does not require another open Hermes PR to land first. ## Native install and update chain The original device failure chain was a combination of Termux moving its system Python beyond Hermes's supported range, Android/Bionic wheel-tag mismatches, optional native accelerators, index leakage, and stale venv/launcher state. This PR keeps `scripts/install.sh` intact for non-Termux hosts and dispatches native Termux installs to `scripts/install-termux.sh`. The native path: - uses Termux packages and `uv`, not Ubuntu/proot or pip; - selects the supported CPython 3.13 Android runtime and preserves the old venv until smoke tests pass; - resolves the curated Termux dependency profile from `uv.lock` with Android markers; - consumes an immutable, SHA-256-pinned Android arm64 wheelhouse instead of compiling native dependencies on every phone; - installs with source distributions disabled and validates the full environment with `uv pip check` + native imports; - preserves the same binary-only invariant during `hermes update`: after a pull, the freshly resolved graph is verified against the immutable wheelhouse before the new checkout is accepted, and the update rolls back on pin/integrity/binary-coverage failure. The current immutable release was built and verified in the canonical `adybag14-cyber/termux-hermes` tooling and is pinned by exact release commit, `SHA256SUMS`, and wheel hashes: - release: `wheelhouse-cp313-android24-arm64-20260811.1` - builder commit: `5184b2059257918e9a98993186b57f6aaf9037b9` - target: CPython 3.13 / `android_24_arm64_v8a` - graph: 90 exact packages, including 10 Android-native wheels - native release workflow: all ten wheels built, binary-only clean-env install passed, native imports passed, and `uv pip check` passed The canonical wheelhouse tooling PR was merged after that native release gate passed. ## Narrow Termux TUI UX The phone-width bounce came from hard minimum geometry floors that could exceed the real terminal width. Termux now uses a terminal-aware floor across the transcript/composer and secondary overlays rather than only fixing the idle chat view. Coverage includes: - transcript/composer and main layout; - session switcher; - prompts and approval/masked-prompt surfaces; - Skills, Plugins, Model and Pet pickers; - Journey, queued messages, branding, grid overlays, and ticker. The regression suite includes deterministic narrow-layout tests plus a native CI smoke that launches the installed `hermes` in a real 48x18 PTY, requires meaningful output without fatal startup markers, sends a terminal Ctrl-C, and verifies clean interactive shutdown. ## Current Desktop on Termux `hermes desktop` now has a native Termux transport instead of attempting to run Electron binaries built for desktop Linux. On Termux it: 1. installs/builds only the real current Desktop Vite renderer with lifecycle scripts disabled, so Electron/node-pty glibc-native install hooks are never executed; 2. installs the official `x11-repo`, `termux-x11-nightly`, and Chromium packages; 3. detects/acquires the official Termux:X11 Android companion when needed, resolving the current GitHub release asset and verifying its published SHA-256 before invoking Android package installation; 4. starts or reuses `DISPLAY=:1` and opens Chromium in app mode under Termux:X11; 5. serves the same Desktop session on loopback, so `http://127.0.0.1:9119` can also be opened directly in the phone browser. The server remains loopback-only. The regression policy forbids wildcard binding, Chromium `--no-sandbox`, privileged PR regression containers, and seccomp-disabled PR containers. ## Browser-hosted Desktop compatibility The real Desktop renderer installs a compatibility bridge only when Hermes's loopback server injected its session token and no Electron preload bridge exists. Electron hosts remain unchanged. The Termux browser host maps the Desktop contract onto the authenticated Hermes backend, including: - `/api/*` and `/api/ws` with profile scoping; - backend filesystem + in-app path selection and default-CWD discovery; - Git/worktree/review operations; - browser clipboard/microphone/notification equivalents; - image attachment persistence through the existing validated chat image-upload API; - safe browser-local HTML preview staging; - a real interactive Desktop terminal rail over a separate authenticated, loopback-only shell PTY WebSocket (`start/write/resize/onData/onExit/cwd/dispose`). The terminal transport buffers early output, streams UTF-8 correctly across split frames, and uses the native Termux shell rather than an Electron/node-pty binary. ## CI / regression coverage A blocking Termux footgun checker is now part of normal Python lint CI. It catches Android-specific regressions such as host package managers, fake platform leakage, accidental Electron execution, public Desktop binding, unsafe Chromium flags, unverified X11 APK acquisition, and privileged Termux PR containers. A dedicated `Termux regression` workflow runs on native `ubuntu-24.04-arm` and the exact pinned official Termux aarch64 Docker image used by the canonical wheelhouse builder. It runs the real native installer and exercises: - installed CLI/version/help; - environment detection; - Desktop renderer-only build through `hermes desktop --build-only`; - Ink/TUI build, typecheck, narrow-layout tests, and real 48x18 PTY launch/shutdown; - Desktop typecheck and browser bridge/filesystem/preview tests; - Termux X11/Chromium package availability; - a live authenticated Desktop server in Termux Chromium at phone dimensions; - zero horizontal overflow/root errors/request failures; - the actual Desktop terminal rail executing a unique marker command and returning its cwd. ## Final validation (current head `a8e647964b`) - **Native Termux regression is green on current head:** run `31593752221`. It completed the real native installer, immutable 90-package wheelhouse verification, 91-package compatibility/import smoke, renderer-only Desktop build, TUI build/typecheck, 9/9 narrow-layout tests, a real 48x18 PTY launch/shutdown, Desktop typecheck + focused UI tests, Termux:X11/Chromium package checks, and sandboxed Termux Chromium at 390x844 and 320x568. The browser-hosted Desktop terminal executed a unique shell marker successfully and returned its native Termux cwd. - **Full CI executable checks are green on current head:** run `31593753330`. The two regressions found during finalization were fixed and revalidated: Python slice 8/12 (managed-runtime resolution policy) is green, and the full Node 26 `ui-tui / check` is green. Desktop UI, Windows/macOS, docs, Ruff/ty, both footgun gates, OSV, supply-chain, lockfile, installer, and the remaining Python/JS jobs are green. - The full CI workflow is marked failed only because `Review label gate / Review label gate` requires the maintainer-owned `ci-reviewed` label; `All required checks pass` inherits that policy failure. The PR also retains the maintainer `needs-decision` label. This change does not attempt to bypass either review gate. - **Docker Build, Test, and Publish is green on current head:** run `31593752252`, including the repository's amd64/arm64 integration path. - Local/Devbox regression work additionally passed the focused Python Termux/update/backend suites, full Termux and Windows footgun scans, workflow/YAML/diff hygiene, and a clean Linux Node 26/npm 11 parity run of `npm run --prefix ui-tui check` with 139 test files / 1,547 tests passed (1 skipped). - The browser smoke no longer imports Playwright (which rejects Node's native Android platform). It drives the installed sandboxed Chromium directly through Chrome DevTools Protocol using Node built-ins; a blocking Termux footgun rule prevents Playwright or `--no-sandbox` from being reintroduced there. - No `uv.lock` or `package-lock.json` drift was introduced. ## Type of change - [x] Bug fix - [x] Feature / platform support - [x] Tests / CI - [x] Security hardening - [x] Documentation ## How to test on a phone ```bash TERMUX_VERSION=0.118 PREFIX=\"$PREFIX\" \\ bash scripts/install.sh \\ --branch fix/termux-native-install-chain \\ --skip-setup hermes --version hermes hermes desktop ``` For Desktop, install/approve the Termux:X11 Android companion if Android prompts on the first run. Hermes opens the real Desktop renderer through Termux:X11 and also prints the loopback browser URL for the phone browser.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/67163",
          "createdAt": "2026-07-18T22:00:32Z",
          "updatedAt": "2026-08-13T17:34:52Z",
          "timestamp": "2026-08-13T17:34:52Z",
          "metrics": {
            "reactions": 0,
            "comments": 10
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/tui",
            "P3",
            "needs-decision",
            "sweeper:risk-compatibility",
            "sweeper:risk-automation",
            "sweeper:blast-contained",
            "comp/desktop",
            "area/install-update"
          ],
          "author": "adybag14-cyber",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:d702aef14289a2ce54eb",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85473",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85473",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(enterprise): secret brokering — workload-verified, value-free secret operations",
          "text": "## Summary Brokered secrets: workloads never receive secret values or backend credentials. Every operation is verified fail-closed — active revision only, matching workload identity, secret in the revision snapshot, IAM allow, exact broker, selected driver (no fallback) — then executed backend-side, returning only derived results (possession proofs, scoped short-lived tokens). Every attempt audited, allow and deny. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `enterprise/secrets.py`: `SecretBrokerService` (8-step verification pipeline; results are scrubbed with the core secret-shape detector — a driver leaking a value raises `SecretAccessError`), `EnvFileSecretDriver` (dev/test), `VaultHttpSecretDriver` (Vault KV v2 via stdlib urllib, token from env at call time). ## Validation | | Result | |---|---| | `tests/enterprise/test_secrets.py` | 23/23 pass (candidate/retired denied, identity mismatch denied, leak-scrub, vault URL/header assertions) | | ruff | clean | ## Infographic ![Secret brokering](https://files.catbox.moe/wdo51x.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85473",
          "createdAt": "2026-08-13T17:24:26Z",
          "updatedAt": "2026-08-13T17:34:48Z",
          "timestamp": "2026-08-13T17:34:48Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "area/auth",
            "P3",
            "needs-decision",
            "sweeper:risk-security-boundary"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a8da4b89f9046626685f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85465",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85465",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(desktop): expose session archive action to plugins",
          "text": "## Summary - expose `host.archiveSession(storedSessionId, { profile? })` to runtime-loaded Desktop plugins - route the action through Desktop's canonical archive workflow so optimistic visibility, pins, selected-session behavior, and native notifications stay consistent - normalize lineage-root IDs, preserve exact profile routing, propagate rollback failures to plugin callers, and destructively clean associated tiles across profiles - document the SDK action and its rejection contract ## Why Desktop plugins can render native session context menus and read session lists, but they cannot currently invoke Desktop's archive lifecycle. Plugin authors must either omit the action or bypass Desktop's state cleanup through a lower-level mutation. This adds a narrow, reusable host action rather than exposing internal stores or adding plugin-specific behavior to core. A concrete consumer is an external work-queue plugin that presents sessions in a docked queue and offers Archive from its native right-click menu. ## Test plan - [x] `npm run test:ui -- src/hermes.test.ts src/sdk/index.test.ts src/app/session/hooks/use-session-actions.test.tsx src/store/session-states.test.ts` - [x] `npm run typecheck` - [x] focused ESLint on all changed Desktop TypeScript files - [x] `git diff origin/main...HEAD --check` - [x] packaged and code-signed the macOS arm64 Desktop candidate locally Tested on macOS arm64. ## Review evidence An independent review inspected the exact pre-rebase staged diff through four iterations. Final verdict: **PASS**, with no concrete blockers. It specifically verified IPC profile-property semantics, selected-session success/failure behavior, lineage identity normalization, active/inactive profile tile cleanup, closed-tab purge, promise rejection handling, and SDK documentation alignment. Residual risk: no live multi-backend Electron smoke test was run for background-profile cleanup; that path is covered through persisted profile-switching tests.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85465",
          "createdAt": "2026-08-13T17:22:21Z",
          "updatedAt": "2026-08-13T17:34:46Z",
          "timestamp": "2026-08-13T17:34:46Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "P3",
            "comp/desktop"
          ],
          "author": "jerryhjones",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c06841b1d1b940b611be",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85477",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85477",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(providers): deepseek reasoning passback condicional + effort off/high/max",
          "text": "## Summary Makes DeepSeek wire replay token- and cache-efficient: `reasoning_content` is only serialized on assistant turns that carried tool calls (the official thinking-mode passback rule), assistant `content` is normalized to `\"\"` (never null), and `reasoning_effort` uses the official DeepSeek vocabulary `off|high|max`. Root cause: Hermes replayed `reasoning_content` on every assistant turn even though the DeepSeek API ignores it on plain turns — every replayed token is billed input. It also passed `low`/`medium` effort values outside the official vocabulary and could serialize an explicit `content: null`, which some gateways reject outright and bricks later turns. ## Changes - `plugins/model-providers/deepseek/__init__.py`: effort mapping — `off`/`none` disable thinking without a wire effort, `minimal`/`low`/`medium` normalize to `high`, `xhigh`/`ultra` to `max`; any other value raises `ValueError` before I/O with a clear message - `agent/message_sanitization.py`: conditional reasoning passback policy + `is_native_deepseek_endpoint` predicate (drop applies only to the native DeepSeek endpoint) - `run_agent.py`: cached `_is_native_deepseek_endpoint()` on `AIAgent` - `agent/agent_runtime_helpers.py`: `copy_reasoning_content_for_api` / `reapply_reasoning_echo_for_provider` plumbing; `sanitize_api_messages` normalizes explicit `None` content to `\"\"` - `agent/transports/chat_completions.py`: `convert_messages` normalizes `None` → `\"\"` on the per-call copy (stored history stays byte-stable) - `tests/plugins/model_providers/test_deepseek_profile.py`, `tests/agent/test_message_sanitization_policy.py`, `tests/agent/transports/test_chat_completions.py`, `tests/run_agent/test_deepseek_reasoning_content_echo.py`, `tests/run_agent/test_message_sequence_repair.py`: coverage for the drop rule (deepseek-only), passback retention on tool-call turns, effort mapping, and null-content normalization ## Validation | Teste | Resultado | |-------|-----------| | `pytest` deepseek profile + message sanitization + transports + run_agent reasoning/sequence suites | 195 passed | | Live API validation (`test_deepseek_v4_thinking_live.py`) | skipped — requires `DEEPSEEK_API_KEY` + `HERMES_LIVE_TESTS=1` | Kimi/Moonshot/MiMo and aggregator-hosted deepseek models (OpenRouter) keep the legacy all-assistant echo — the change is scoped to the native endpoint.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85477",
          "createdAt": "2026-08-13T17:26:37Z",
          "updatedAt": "2026-08-13T17:34:26Z",
          "timestamp": "2026-08-13T17:34:26Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/deepseek",
            "P2",
            "area/usage-cost"
          ],
          "author": "pablo-afterlife",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ba9cfb1fc89c736e16cd",
        "signalId": "github:NousResearch/hermes-agent:pull_request:30232",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:30232",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(custom-provider): preserve base URL for anthropic_messages api_mode",
          "text": "## Problem When a custom provider is configured with `api_mode: anthropic_messages`, the code in `resolve_provider_client()` unconditionally rewrites the base URL via `_to_openai_base_url()`, converting e.g. `http://host/anthropic` → `http://host/v1`. The Anthropic SDK then appends `/v1/messages` to the base URL, resulting in a request to `/v1/v1/messages` → **404**. ## Root Cause The generic `custom` provider branch (~line 3271) lacks the `api_mode` guard that the named custom-provider branch (~line 3380) already has: ```python # named-custom branch (correct) if entry_api_mode == \"anthropic_messages\": # keeps custom_base as-is else: openai_base = _to_openai_base_url(custom_base) ``` The generic branch was missing this check entirely. ## Fix Apply the same guard to the generic `custom` branch: when `api_mode == \"anthropic_messages\"`, keep the original base URL (only strip trailing slash). For all other api_modes, the existing `_to_openai_base_url()` rewrite is preserved. ## Testing Reproduced and verified with a local Anthropic-compatible proxy (`base_url: http://localhost:6655/anthropic`, `api_mode: anthropic_messages`). Before the fix: 404 on every call. After: requests route correctly to `/anthropic/v1/messages`.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/30232",
          "createdAt": "2026-05-22T05:11:24Z",
          "updatedAt": "2026-08-13T17:34:23Z",
          "timestamp": "2026-08-13T17:34:23Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "duplicate",
            "comp/agent",
            "area/config",
            "P2",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "terlanks",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:0367ead5cb6be5b0cdde",
        "signalId": "github:NousResearch/hermes-agent:pull_request:29624",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:29624",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(auxiliary): prevent URL double-rewrite for custom anthropic_messages endpoints",
          "text": "markdown Bug Description Auxiliary tasks (title generation, vision, compression, etc.) fail with HTTP 404 when using a custom provider with api_mode: anthropic_messages (e.g. Zhipu GLM via https://open.bigmodel.cn/api/anthropic). Error: Auxiliary title generation failed: HTTP 404: Error code: 404 - {'status': 404, 'error': 'Not Found', 'path': '/v4/v1/messages'} Root Cause In resolve_provider_client()'s custom + explicit_base_url branch, the URL passes through two contradictory rewrites: 1. _to_openai_base_url() rewrites /api/anthropic → /api/paas/v4 (OpenAI-wire format), because the auxiliary client historically uses the OpenAI SDK. 2. _maybe_wrap_anthropic() detects api_mode=anthropic_messages and builds an AnthropicAuxiliaryClient on the already-rewritten URL. 3. The Anthropic SDK appends /v1/messages to /api/paas/v4, producing /v4/v1/messages → 404. The two steps assume mutually exclusive transports: step 1 assumes OpenAI SDK, step 2 assumes Anthropic SDK. When both fire, the URL is mangled. Fix When api_mode == \"anthropic_messages\", bypass _to_openai_base_url() entirely and build an AnthropicAuxiliaryClient directly from the original explicit_base_url (e.g. https://open.bigmodel.cn/api/anthropic). This mirrors the approach already used in _try_custom_endpoint() (the Step-2 fallback path), which correctly handles anthropic_messages by building the Anthropic client first. On ImportError (anthropic SDK not installed) or other exceptions, falls back to the existing OpenAI-wire path. How to Verify 1. Configure a custom provider with Anthropic-compatible endpoint: yaml model: default: glm-5.1 provider: custom base_url: https://open.bigmodel.cn/api/anthropic api_mode: anthropic_messages api_key: <your-key> 2. Start a new session with hermes 3. Send any message — previously this would log Auxiliary title generation failed: HTTP 404 4. After the fix, title generation succeeds and the session gets a proper title 5. Verify with a unit test: python from agent.auxiliary_client import resolve_provider_client client, model = resolve_provider_client( 'custom', 'glm-5.1', explicit_base_url='https://open.bigmodel.cn/api/anthropic', explicit_api_key='test-key', api_mode='anthropic_messages', ) assert type(client).name == 'AnthropicAuxiliaryClient' assert str(getattr(client, 'base_url', '')).endswith('/api/anthropic') Test Plan - [x] Manual verification with Zhipu GLM Anthropic-compatible endpoint - [x] Module import succeeds (import agent.auxiliary_client) - [x] resolve_provider_client returns correct AnthropicAuxiliaryClient with unrewritten URL - [ ] Existing tests still pass - [ ] Regression test added Risk Assessment Low — The fix adds an early-return branch that only triggers when api_mode == \"anthropic_messages\". All other code paths (OpenAI-wire custom endpoints, named providers, auto-detection without explicit api_mode) are unchanged. The fallback on ImportError ensures no regression if the anthropic SDK is missing.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/29624",
          "createdAt": "2026-05-21T03:50:41Z",
          "updatedAt": "2026-08-13T17:34:21Z",
          "timestamp": "2026-08-13T17:34:21Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "area/config",
            "P2",
            "sweeper:risk-compatibility",
            "sweeper:blast-contained"
          ],
          "author": "yimi128",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:d23ed6cfcaa20cbd9ad5",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85478",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85478",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "test(e2e): regressão de prompt-cache em turns multi-step (DeepSeek)",
          "text": "## Summary Adds a key-gated integration test that proves prompt-cache hits on multi-step DeepSeek conversations: after the first request, every subsequent request must report `cache_read_tokens > 0` because it shares a byte-identical prefix with its predecessor. Root cause: Hermes has no regression guard for prompt-cache stability. A silent prefix-breaking change (tool ordering, injected messages, system-prompt mutation) would multiply every user's cost with no test catching it — the dsh harness guards this exact property with a live e2e test (`request-cache.e2e.ts`). ## Changes - `tests/integration/test_deepseek_prompt_cache_regression.py` (new): two `pytest.mark.integration` tests — (1) multi-step turn with a tool call in the middle, (2) plain follow-up request; both assert `cache_read_tokens > 0` on the second+ request via the OpenAI SDK usage payload; graceful `skipif` when `DEEPSEEK_API_KEY` is absent; 120s client timeouts ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/integration/test_deepseek_prompt_cache_regression.py -m integration -rs` | 2 skipped (`DEEPSEEK_API_KEY not configured`) | | `ruff check` | All checks passed | | Plumbing probe with dummy key | real 401 from `api.deepseek.com` — endpoint and request path correct |",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85478",
          "createdAt": "2026-08-13T17:26:40Z",
          "updatedAt": "2026-08-13T17:34:20Z",
          "timestamp": "2026-08-13T17:34:20Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/test",
            "comp/agent",
            "provider/deepseek",
            "P3"
          ],
          "author": "pablo-afterlife",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:50ad3510bff8d810e15c",
        "signalId": "github:NousResearch/hermes-agent:issue:84185",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:84185",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: Windows: gateway cold-started after 'hermes update' dies silently (no logs, no PID file, no exit record) — offline until manual restart",
          "text": "### Bug Description On Windows, after a successful `hermes update` (triggered by the Desktop app's update hand-off), the updater prints `✓ Starting Windows gateway after update (PID <n>)` — but the spawned gateway process **dies immediately and silently**: it never writes to `gateway.log`, never creates `gateway.pid`, and leaves no exit record in the gateway diagnostics. Telegram stays offline until the gateway is started manually. ### Observed - Gateway installed as a Scheduled Task (`Hermes_Gateway`, logon trigger) with hidden-console launcher scripts (`Hermes_Gateway.cmd` / `.vbs`); the gateway is a deliberate standalone installation (Telegram bot, cron). - Reproduced on 3 consecutive updates (2026-08-11 13:55, 2026-08-12 00:12, 2026-08-12 08:53, local time), each with the same fingerprint: - updater log: `✓ Starting Windows gateway after update (PID 17192)` etc. - gateway diagnostic log: a `gateway.start` entry for the new PID, **no exit entry follows** - `gateway.log`: no `Starting Hermes Gateway...` line at all — the process dies before logging is initialized - `gateway.pid`: never created - Manual recovery works every time: `hermes gateway start` spawns through the same `gateway_windows._spawn_detached()` path and the gateway runs normally (Telegram connects, logs are written, `gateway.pid` is created). ### Expected behavior A gateway cold-started after an update should actually survive, or the updater should report a failure instead of printing `✓ Starting ...`. ### Possible cause (unverified) `_cold_start_windows_gateway_after_update()` calls `_spawn_detached()` from inside the update hand-off process. The spawned process appears to be reaped when the updater process exits (job object / console lifetime). `_spawn_detached()` uses `CREATE_BREAKAWAY_FROM_JOB` but falls back to a non-breakaway spawn on `OSError`; if breakaway is denied, the child stays in the parent's job and is killed during updater teardown. The pre-logging death and the absent exit record are consistent with a hard kill. ### Environment - Windows 10 - hermes-agent 0.20.0 (`e9579a9`), uv-managed venv - Gateway via Scheduled Task (logon trigger), Telegram platform ### Related - #76129 — opposite failure of the same cold-start path: an *unwanted* standalone gateway is spawned and survives when the desktop backend (`hermes serve`) is the runtime. Here the gateway is a deliberate installation and the spawned process does **not** survive. Related but distinct symptoms; likely worth fixing together.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/84185",
          "createdAt": "2026-08-12T01:10:26Z",
          "updatedAt": "2026-08-13T17:34:17Z",
          "timestamp": "2026-08-13T17:34:17Z",
          "metrics": {
            "reactions": 0,
            "comments": 7
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/gateway",
            "P1",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility",
            "sweeper:risk-platform-windows",
            "comp/desktop",
            "platform/windows",
            "area/install-update"
          ],
          "author": "linfeng961",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:054adbcbb5782b085f74",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85479",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85479",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(compression): prune oversized tool results in place before summarization",
          "text": "## Summary Prunes oversized tool results in their own message node — same position, same role, same `tool_call_id` — down to `head + marker + tail`, as a no-LLM pre-pass that runs BEFORE the summarization region is selected. Shrinking oversized tool bodies often makes the transcript fit the tail budget, skipping the summarizer entirely. Root cause: giant tool outputs (web extraction, terminal dumps) inflate the persisted transcript and force LLM summarization, which invalidates the cached prefix. In-place pruning is the most cache-friendly compaction form: the message count, roles, and call IDs never change — only one node's text shrinks. ## Changes - `agent/context_compressor.py`: `PRUNE_MARKER` (pinned verbatim, also doubles as immunity marker for the demote pass), pure `prune_tool_result_content` (code-point safe; supports str and multimodal list bodies, pruning only text parts), `resolve_tool_result_prune_config` (unsatisfiable budget disables the feature), `_tool_prune_boundary` shared by both passes, integration before region selection - `agent/agent_init.py`: config wiring - `hermes_cli/config_defaults.py`: `compression.tool_result_prune` defaults — `enabled: false` (opt-in), `threshold_chars: 8192`, `head_chars: 4096`, `tail_chars: 1024` - `tests/agent/test_tool_result_prune.py` (new): unit coverage of the pure function (threshold, head/tail, marker, unicode, list content, idempotence), config resolution, node-identity/persistence/rollback, and integration before region selection with the feasibility-skip path avoiding the LLM call ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/agent/test_tool_result_prune.py tests/agent/test_context_compressor.py` | 164 passed (incl. 136 existing compressor tests) | | Default behavior | disabled (`enabled: false`) — conservative, opt-in |",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85479",
          "createdAt": "2026-08-13T17:26:42Z",
          "updatedAt": "2026-08-13T17:34:16Z",
          "timestamp": "2026-08-13T17:34:16Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/cli",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-compatibility",
            "area/compression"
          ],
          "author": "pablo-afterlife",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:b95e25520170ce14b34d",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85480",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85480",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(tools): spill oversized tool outputs to session files with preview + locator",
          "text": "## Summary When `tools.spill.enabled` is true and a tool result's UTF-8 size exceeds `tools.spill.max_inline_bytes`, the full text is written ONCE to a session-scoped file and the inline message becomes a bounded head/tail preview plus a locator notice the model follows with `read_file`. Root cause: large unique tool outputs (web extractions, dumps) grow the conversation prefix on every request, pushing the transcript toward compaction and invalidating the provider cache. Spilling at write time keeps every node under a byte cap, so the transcript stays byte-identical between steps and compaction fires far less often. ## Changes - `tools/tool_spill.py` (new): `SpillConfig` (`enabled` default false, `max_inline_bytes` default 100_000), `maybe_spill_tool_result`, byte-safe UTF-8 truncation (no multibyte splits), `_preview_head_tail` (head=ceil/2, tail=floor/2), notice `(N bytes omitted. Full result stored at: <path>)` with its byte cost reserved INSIDE the cap (`budget = cap − notice − 2`, replacement re-checked), traversal-safe session-scoped paths, best-effort storage (write failure keeps the result inline), `read_file` skipped (anti read→spill→read loop) - `agent/tool_executor.py`: integration at both write points (`execute_tool_calls_concurrent` and `execute_tool_calls_sequential`), config resolved once per turn, applied before `make_tool_result_message`; multimodal results untouched - `hermes_cli/config_defaults.py`: `tools.spill` defaults - `tests/tools/test_tool_spill.py` (new): 32 tests — cap applied, notice with path, reserved-budget invariant, best-effort with failing storage, read_file skip, unicode ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/tools/test_tool_spill.py` | 32 passed | | Default behavior | disabled (`enabled: false`) — conservative, opt-in |",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85480",
          "createdAt": "2026-08-13T17:26:45Z",
          "updatedAt": "2026-08-13T17:34:12Z",
          "timestamp": "2026-08-13T17:34:12Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/tools",
            "area/config",
            "P3",
            "sweeper:risk-compatibility"
          ],
          "author": "pablo-afterlife",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ed612bfa06eaaaf325f1",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85481",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85481",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(compression): cache-aware summarization + thinking-off aux titles",
          "text": "## Summary Summarization calls now replay a genuine prefix of the last routed request: the conversation's own system prompt, the real tool schemas, the protected head + compacted region as actual chat messages in order, with the summarization instruction as the final user message. Auxiliary session-title calls force thinking OFF so the titler never burns its budget on reasoning. Root cause: the summarizer built its own standalone prompt, so the auxiliary call started from a cold cache — the provider's KV cache for the conversation prefix was invalidated instead of reused. The dsh harness avoids this by making the auxiliary call a genuine prefix of the last routed request (\"the provider's KV cache is reused instead of invalidated\"). ## Changes - `agent/context_compressor.py`: `_summarizer_wire_message` (role/content/tool_calls/tool_call_id preserved; redaction, think-strip, media/image labels and per-message truncation unchanged), `_bound_summarizer_messages` (structured sibling of `_bound_summary_input`, 160K char cap), `_generate_summary(prefix_messages, tools)`, `compress(tools)` plumbing - `agent/conversation_compression.py`: forward `agent.tools` (the exact request schemas) through `_supported_compression_kwargs` - `agent/title_generator.py`: force thinking OFF via `reasoning_config={\"enabled\": False}` (DeepSeek profile maps it to `extra_body.thinking={\"type\": \"disabled\"}`); minimal input + `max_tokens=64` + caller-owned timeout were already the contract - Tests: `test_compression_cache_aware_input.py` (new) + updated `test_title_generator.py`, `test_compress_focus.py`, `test_compression_small_ctx_threshold_floor.py`, `test_compaction_redaction_boundaries.py`, `test_context_compressor_summary_continuity.py`, `test_pre_compress_memory_context.py` ## Validation | Teste | Resultado | |-------|-----------| | `pytest` (7 compression/title suites) | 84 passed | | Input assembly | system → head → region (chat messages) → instruction LAST — verified by test |",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85481",
          "createdAt": "2026-08-13T17:26:48Z",
          "updatedAt": "2026-08-13T17:34:08Z",
          "timestamp": "2026-08-13T17:34:08Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "provider/deepseek",
            "P3",
            "sweeper:risk-session-state",
            "area/compression"
          ],
          "author": "pablo-afterlife",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:47f43c328ab9010c6c25",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85482",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85482",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(agent): repeat-tool-reminder advisory (loop hygiene)",
          "text": "## Summary Adds advisory loop hygiene: consecutive identical tool calls (same tool name + canonically identical arguments) get a soft reminder appended to the tail of the current tool result when the run length hits a threshold — escalating tone at 3, 5, and 8 repeats. Root cause: models can loop on the same tool call with identical arguments, burning tokens on every repeated step (and each loop step is a new request that can invalidate the cached prefix). A non-blocking reminder lets the model self-correct without vetoing anything. ## Changes - `agent/repeat_tool_reminder.py` (new): `canonicalize_arguments` (reuses `canonical_tool_args` — deep key-sorted JSON), `wildcard_to_regex` (anchored, `*` → `.*`), per-agent chain with lock, thresholds `[3, 5, 8]` gentle→detailed escalation, `gentle_reminder`/`detailed_reminder` with stable `[reminder]` prefix and capped args preview, `reset`; advisory contract — any exception → `None`, never breaks the loop - `agent/tool_dispatch_helpers.py`: `make_tool_result_message(..., append_tail=None)` — appended at the END, AFTER the untrusted-content wrapping (the reminder never becomes untrusted data; cached prefix stays byte-identical) - `agent/tool_executor.py`: `maybe_remind` in both mount points (sequential + concurrent, using `name`/`args` from parsed calls, correct across success/timeout/cancel/block outcomes) - `agent/conversation_loop.py`: reset on real user message (new turn and mid-turn redirect) - `hermes_cli/config_defaults.py` + `cli-config.yaml.example`: `repeat_tool_reminder` section — `enabled` (default true), `thresholds`, `include`/`exclude` wildcards, `arguments_preview_chars` - `tests/agent/test_repeat_tool_reminder.py` (new, 25 tests) + `tests/agent/test_tool_dispatch_helpers.py` updated ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/agent/test_repeat_tool_reminder.py tests/agent/test_tool_dispatch_helpers.py` | 54 passed | | Non-blocking contract | reminder appends only; execution never vetoed (tested) |",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85482",
          "createdAt": "2026-08-13T17:26:51Z",
          "updatedAt": "2026-08-13T17:34:04Z",
          "timestamp": "2026-08-13T17:34:04Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "area/config",
            "P3",
            "sweeper:risk-compatibility"
          ],
          "author": "pablo-afterlife",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:01eeb761300e27c09fb0",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85483",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85483",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(delegation): subagent result never-rejects with stop_reason + partial text",
          "text": "## Summary A failing delegation child (model error, timeout, user cancel, token budget) now settles into a structured result entry instead of surfacing as a lost failure: each `delegate_task` result gains an additive `stop_reason` (`completed|aborted|error|max-tokens`; `refusal` is reserved — Hermes has no refusal signal today) and, when the failure would otherwise discard the child's output, a `partial_output` field preserving its last real assistant text. Root cause: child failures previously settled with `summary=None` and no trace of what the child produced before failing; the parent could not distinguish \"declined\" from \"ran out of tokens\" from \"cancelled\". This mirrors the dsh `SubagentResult` contract (`result` never rejects; the tool maps non-completed stop reasons to `isError` while preserving partial text). ## Changes - `tools/delegate_tool.py`: `stop_reason` + `partial_output` fields per entry; timeout/exception paths capture partial text best-effort from the abandoned worker's session messages; completed payloads only gain the additive `stop_reason` field - `tools/process_registry.py`: `_format_async_delegation` renders partial output and `stop_reason` in re-injected completion blocks - `tools/async_delegation.py`: async completions propagate the new fields - `tests/tools/test_delegate.py`, `tests/tools/test_process_registry.py`: error → structured entry with partial text; cancel → aborted; success → completed (existing behavior preserved) ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/tools/test_delegate.py tests/tools/test_process_registry.py` | 117 passed, 28 skipped | | Pre-existing Windows env failures | 6 failed on this PR AND on clean base `1fd21cbd0` (PTY/POSIX/thread tests) — not introduced by this PR | | Backward compatibility | `summary`/`status`/`error`/`exit_reason` semantics unchanged; `stop_reason` additive |",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85483",
          "createdAt": "2026-08-13T17:26:54Z",
          "updatedAt": "2026-08-13T17:34:01Z",
          "timestamp": "2026-08-13T17:34:01Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "tool/delegate",
            "P3"
          ],
          "author": "pablo-afterlife",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:95e1e1e7032d8287c1cf",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85484",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85484",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(jobs): ferramentas de jobs em background com coleta",
          "text": "## Summary Adds a single `jobs` tool (action enum `start|list|output|kill`) that gives the agent owned background jobs with async collection, reusing the existing process registry (terminal background infrastructure) — no new process-management layer. Root cause: Hermes could run background processes via the terminal tool, but the agent had no owned job lifecycle: no way to start, list, wait, or kill jobs scoped to the current session, and no completion notice channel. The dsh `jobs` family provides exactly this (owner isolation, `wait` with timeout returning `running`, byte-capped output, completion notices). ## Changes - `tools/jobs_tool.py` (new): action enum `start|list|output|kill`; strict owner isolation `(session_key, task_id)` — foreign ids are indistinguishable from unknown ids (no existence leak); `start` spawns via `process_registry.spawn_local` with `notify_on_complete=True` (completion connects to the existing notification channel) and keeps terminal security guards (tirith, dangerous-command detection, hardline); `output` with `wait`/`timeout_ms` — timeout returns status `running` (never an error), output truncated by bytes with head/tail marker; `kill` terminates the job - `toolsets.py`: `jobs` registered in the `terminal` toolset (next to `terminal`/`process`) + 6 static bundles (core, terminal, debugging, coding, hermes-acp, hermes-api-server) - `hermes_cli/config_defaults.py`: `jobs.max_concurrent_per_session` (default 10), `jobs.output_limit_bytes` (default 20000) - `tests/tools/test_jobs_tool.py` (new, 20 tests) + `tests/test_toolsets.py` updated ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/tools/test_jobs_tool.py tests/test_toolsets.py` | 42 passed | | Scenarios covered | start/list, cross-session isolation, wait+timeout → running, post-completion output, kill, byte truncation, concurrency cap |",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85484",
          "createdAt": "2026-08-13T17:26:57Z",
          "updatedAt": "2026-08-13T17:33:57Z",
          "timestamp": "2026-08-13T17:33:57Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/tools",
            "tool/terminal",
            "P3"
          ],
          "author": "pablo-afterlife",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:b0264721608c946a577c",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85485",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85485",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(delegation): continuable children v1 — durable id + settlement notice + report",
          "text": "## Summary Adds an opt-in `continuable` flag to `delegate_task` that gives every child a durable identity without changing the default delegation model: result entries carry `child_session_id` (the child's persisted session id, linked to the parent via `parent_session_id`) plus the ephemeral `subagent_id`, the async completion event renders a settlement notice per child, and the child's transcript is re-readable by id via the existing `session_search` READ shape. Root cause: `delegate_task` is ephemeral — children die with the parent session and there is no durable identity, no settlement channel, and no way to re-read a finished child's work. This is the v1 (safe, additive) slice of the dsh continuable-subagent pattern (stable child id + settlement notice + report), with the heavy features explicitly deferred. ## Changes - `tools/delegate_tool.py`: `continuable` flag (default `False` → output byte-identical); entries (success/timeout/error/crash) gain `child_session_id` + `subagent_id` - `tools/async_delegation.py`: background dispatch payloads name all children up-front (`children` list) - `tools/process_registry.py`: completion block renders `Child <id> finished (status=...)` / `was stopped before it finished` / `failed before it finished` (single + batch shapes) - `run_agent.py`: plumbing - `docs/delegation-continuable-children.md` (new): design + explicitly deferred items - `tests/tools/test_delegate_continuable.py` (new, 25 tests, mock-based — no LLM calls) ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/tools/test_delegate_continuable.py` | 25 passed | | Default mode | byte-identical — no id fields emitted unless `continuable=true` (tested) | | Deferred (documented) | `send_message` to child, `interrupt`, cold resume by parent | ## Limitation `child_session_id` is only as durable as the session row (retention pruning or a crash before the first turn loses the transcript; the id is still returned).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85485",
          "createdAt": "2026-08-13T17:26:59Z",
          "updatedAt": "2026-08-13T17:33:53Z",
          "timestamp": "2026-08-13T17:33:53Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "tool/delegate",
            "P3",
            "sweeper:risk-session-state"
          ],
          "author": "pablo-afterlife",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e1304653bfc897782fe4",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85486",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85486",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(delegation): workflow batch orchestration with semaphore and caps",
          "text": "## Summary Extends `delegate_task` with a `workflow` mode: the parent describes an ordered list of steps, each either a `parallel` fan-out (independent items under a semaphore) or a `pipeline` of sequential stages (output of stage N is appended to the context of stage N+1's prompts). Item failure never kills the batch — a failed item becomes a structured error entry and the rest continues. Root cause: batch fan-out is capped at `max_concurrent_children` with no composition layer — the parent must orchestrate multi-stage work turn-by-turn, multiplying turns and context. The dsh workflow worker-thread runtime (agent/parallel/pipeline, FIFO semaphore, maxTotalAgents backstop, fatal-vs-item semantics) is the pattern reference. ## Changes - `tools/delegation_workflow.py` (new): step engine — `parallel`/`pipeline` step types, semaphore (max_concurrent = `min(8, delegation.max_concurrent_children)`), `max_items = 32` total across all steps, pipeline context chaining (stage N output appended to stage N+1 prompts), flat per-item result array with `status: completed|error`, ordered by `task_index` - `tools/delegate_tool.py`: `workflow` schema on the existing batch tool (no new core tool — narrow waist) - `run_agent.py`: workflow plumbing - `tests/tools/test_delegate_workflow.py` (new, 21 tests, stubbed runner — no real LLM): concurrency cap, per-item error isolation, pipeline context chaining, failed-stage continuation, caps, schema ## Validation | Teste | Resultado | |-------|-----------| | `pytest tests/tools/test_delegate_workflow.py` | 21 passed | | Reuses | exact child build/run/finalize machinery of the flat batch — summary budgets, memory manager, subagent_stop hooks, cost rollup apply unchanged | ## Limitation v1 runs synchronously (pipeline depends on prior stage output); no live transcripts; semaphore is the authoritative cap but steps never interleave in v1.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85486",
          "createdAt": "2026-08-13T17:27:02Z",
          "updatedAt": "2026-08-13T17:33:50Z",
          "timestamp": "2026-08-13T17:33:50Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "tool/delegate",
            "P3",
            "needs-decision"
          ],
          "author": "pablo-afterlife",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:98734205a7908ac9cb28",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85475",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85475",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "docs(enterprise): overview, concepts, deployment, installation, security",
          "text": "## Summary Docs-site section for Hermes Enterprise: five pages (overview, concepts, deployment, installation, security) registered as a new **Enterprise** sidebar category. All pages carry a draft-status admonition since they document the in-flight `ent/*` series. Stacked on #85461 (`ent/core`). Part of the one-wave Enterprise draft series. ## Changes - `website/docs/enterprise/`: architecture + choreography mermaid diagrams, resource-model and IAM reference tables grounded in the `ent/core` contracts, trust-boundary table, explicit v1 non-goals. - `website/sidebars.ts`: Enterprise category registered (pages reachable). ## Validation | | Result | |---|---| | `npx docusaurus build` | SUCCESS (only pre-existing zh-Hans anchor warnings) | ## Infographic ![Enterprise docs](https://files.catbox.moe/8tprcw.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85475",
          "createdAt": "2026-08-13T17:24:31Z",
          "updatedAt": "2026-08-13T17:33:42Z",
          "timestamp": "2026-08-13T17:33:42Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/docs",
            "P3",
            "needs-decision",
            "comp/dashboard"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:9d6002d01d509ca363bb",
        "signalId": "github:NousResearch/hermes-agent:pull_request:74983",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:74983",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(auxiliary): preserve raw base_url for anthropic_messages in anonymous custom branch",
          "text": "## Summary When `api_mode: anthropic_messages` is set and the custom provider's `base_url` ends in `/anthropic` (e.g. `https://ai.hatz.ai/v1/anthropic`), the anonymous custom provider branch in `resolve_provider_client()` unconditionally calls `_to_openai_base_url()` which strips the `/anthropic` suffix and appends `/v1`, producing a mangled URL like `https://ai.hatz.ai/v1/v1`. The Anthropic SDK then appends its own `/v1/messages`, resulting in `https://ai.hatz.ai/v1/v1/v1/messages` → **404**. ## Root cause `agent/auxiliary_client.py:` `resolve_provider_client()`, anonymous custom branch (line `~5430`): ```python # Before (buggy — unconditionally rewrites /anthropic → /v1): custom_base = _to_openai_base_url(explicit_base_url).strip() ``` The named custom provider branch (line `~5554`) already has the correct guard: ```python if entry_api_mode == \"anthropic_messages\": openai_base = custom_base raw_base_for_wrap = custom_base else: openai_base = _to_openai_base_url(custom_base) raw_base_for_wrap = custom_base ``` This patch applies the identical guard to the anonymous branch. ## Fix ```python # After (preserves raw URL for anthropic_messages): if api_mode == \"anthropic_messages\": custom_base = explicit_base_url.strip().rstrip(\"/\") else: custom_base = _to_openai_base_url(explicit_base_url).strip() ``` ## Affected - MoA reference slots using an `anthropic_messages` custom provider - Auxiliary tasks (`title_generation`, `compression`, `vision`, `web_extract`, `session_search`) when routed through a custom provider with `anthropic_messages` wire format - Any custom endpoint whose `base_url` ends in `/anthropic` with `api_mode: anthropic_messages` ## Verification Both paths tested against a live Hatz.AI Anthropic-compatible gateway: | Mode | Before | After | |------|--------|-------| | `anthropic_messages` | `AnthropicAuxiliaryClient(base=https://ai.hatz.ai/v1/v1)` ❌ | `AnthropicAuxiliaryClient(base=https://ai.hatz.ai/v1/anthropic)` ✅ | | `chat_completions` | `OpenAI(base=https://ai.hatz.ai/v1/v1)` (correct — will never hit /anthropic) | `OpenAI(base=https://ai.hatz.ai/v1/v1)` (unchanged) ✅ | ## References - Closes #19753 - References #17086 (duplicate) - References #41211 (same root cause, double-/v1 URL path) - Previous partial fixes: #17467, #19772",
          "url": "https://github.com/NousResearch/hermes-agent/pull/74983",
          "createdAt": "2026-07-30T19:11:56Z",
          "updatedAt": "2026-08-13T17:33:32Z",
          "timestamp": "2026-08-13T17:33:32Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "duplicate",
            "comp/agent",
            "provider/anthropic",
            "P2",
            "sweeper:risk-compatibility",
            "sweeper:blast-contained"
          ],
          "author": "0xr00tf3rr3t",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:8998a36cb49afee219ac",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85466",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85466",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(auxiliary): keep /anthropic base_url for anthropic_messages custom endpoints (salvage #64891)",
          "text": "## Summary Auxiliary tasks (titles, compression, vision) work again on `custom` providers whose base_url ends in `/anthropic` (MiniMax, Zhipu, LiteLLM proxies). Salvage of #64891 by @AlexanderPrendota onto current main, authorship preserved. Fixes #16254 (dup #17086). Root cause: the explicit-base branch of `resolve_provider_client()` rewrote `/anthropic` → `/v1` unconditionally before the Anthropic-wrap decision, so the Messages wrapper was built against a `/v1` URL and every auxiliary call 404'd. ## Changes - `agent/auxiliary_client.py`: when `api_mode == \"anthropic_messages\"`, `_wrap_if_needed` gets the raw `/anthropic` base; the plain-OpenAI client and SDK-unavailable fallback keep the `/v1`-rewritten base — same guard the named-custom-provider branch already has - `tests/agent/test_auxiliary_explicit_base_anthropic.py`: 3 regression tests (wrapper keeps `/anthropic`, fallback stays `/v1`, non-anthropic rewrite preserved) ## Validation | | Before | After | |---|---|---| | aux calls vs Messages-only gateway | 404 on `/v1/chat/completions` | wrapped on real endpoint | | OpenAI-mode custom base | `/v1` rewrite | unchanged | | targeted tests | — | 3/3 pass | Sibling paths (#60753 vision, #61333 class-wide guard) tracked as a separate cluster decision. ## Infographic ![Auxiliary anthropic base_url fix](https://files.catbox.moe/2qf6pc.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85466",
          "createdAt": "2026-08-13T17:23:22Z",
          "updatedAt": "2026-08-13T17:33:31Z",
          "timestamp": "2026-08-13T17:33:31Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/anthropic",
            "area/config",
            "P3",
            "sweeper:risk-compatibility"
          ],
          "author": "teknium1",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e8ab9ee68c9cbff62236",
        "signalId": "github:NousResearch/hermes-agent:issue:85464",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85464",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Prompt caching is disabled for Anthropic-compatible providers when Claude is selected through a bare alias",
          "text": "## Summary Prompt caching is silently disabled when a Claude model is selected through a **bare runtime alias** (for example `fable` or `opus`) on a third-party provider using `api_mode: anthropic_messages`. The downstream gateway resolves those aliases to canonical Claude models (`claude-fable-5`, `claude-opus-5`), but Hermes evaluates its prompt-cache policy before that downstream resolution. The current policy uses the runtime model string: ```python model_lower = eff_model.lower() is_claude = \"claude\" in model_lower ... if is_anthropic_wire and is_claude: return True, True ``` Therefore: ```text fable / opus -> (False, False) claude-fable-5 / claude-opus-5 -> (True, True) ``` No `cache_control` breakpoint is generated for the bare aliases, even though the request is sent over the native Anthropic Messages transport and the gateway ultimately selects a cache-capable Claude model. This is a follow-up to #8294 and #17332. #8294 was fixed by #12846 for third-party Anthropic transports with Claude-named models, but the remaining model-name gate does not account for aliases that are canonicalized downstream. Companion OmniRoute issue: https://github.com/diegosouzapw/OmniRoute/issues/10276 That issue covers the router-side part of the compatibility gap: its `auto` policy does not defensively add breakpoints when a normal Anthropic-compatible client supplies none. ## Affected topology ```text Hermes runtime model alias: fable or opus provider: named custom provider api_mode: anthropic_messages | | no cache_control emitted v Anthropic-compatible gateway/router alias resolves downstream: fable -> claude-fable-5 opus -> claude-opus-5 | | ordinary request, no cache breakpoint v Claude API / model service cache_creation_input_tokens = 0 cache_read_input_tokens = 0 ``` Responsibility is split across three layers: 1. **Hermes** constructs the stable agent prefix and decides where to emit `cache_control` breakpoints. 2. **The gateway/router** preserves, translates, or optionally injects those markers. 3. **The model service** creates and reads the actual server-side KV/prompt cache and reports usage. Hermes does not store the KV cache itself; the issue is that it fails to declare cacheable prefix boundaries for this alias shape. ## Minimal reproduction Use a custom Anthropic-compatible provider whose router understands short aliases: ```yaml model: default: fable provider: custom:anthropic-proxy base_url: http://127.0.0.1:PORT providers: anthropic-proxy: base_url: http://127.0.0.1:PORT transport: anthropic_messages api_key: \"[REDACTED]\" models: fable: context_length: 1000000 opus: context_length: 1000000 ``` At the router, configure: ```text fable -> claude-fable-5 opus -> claude-opus-5 ``` Then: 1. Start a new Hermes session using `fable`. 2. Send two or more turns with a sufficiently large, unchanged prefix (system prompt + tool definitions + prior history). 3. Capture only cache-policy diagnostics and usage fields; do not publish the request text. 4. Repeat using the runtime model name `opus`. 5. As controls, repeat with `claude-fable-5` and `claude-opus-5` as the model names visible to Hermes. The policy can also be reproduced directly: ```python from types import SimpleNamespace from agent.agent_runtime_helpers import anthropic_prompt_cache_policy agent = SimpleNamespace( provider=\"custom:anthropic-proxy\", base_url=\"http://127.0.0.1:PORT\", api_mode=\"anthropic_messages\", model=\"\", ) for model in [\"fable\", \"claude-fable-5\", \"opus\", \"claude-opus-5\"]: print(model, anthropic_prompt_cache_policy(agent, model=model)) ``` Observed: ```text fable: (False, False) claude-fable-5: (True, True) opus: (False, False) claude-opus-5: (True, True) ``` ## Sanitized request evidence The following summaries were extracted from request artifacts. Request text, system prompts, user content, credentials, headers, and connection identifiers are intentionally omitted. ### Hermes -> bare alias `fable` -> canonical `claude-fable-5` ```json { \"client\": \"Hermes\", \"canonicalModel\": \"claude-fable-5\", \"claudePromptCache\": { \"applied\": false, \"totalBreakpoints\": 0, \"systemBreakpoints\": [], \"toolBreakpoints\": [], \"messageBreakpoints\": [] }, \"usage\": { \"cacheRead\": null, \"cacheWrite\": null } } ``` ### Hermes -> bare alias `opus` -> canonical `claude-opus-5` ```json { \"client\": \"Hermes\", \"canonicalModel\": \"claude-opus-5\", \"inputTokens\": 26448, \"claudePromptCache\": { \"applied\": false, \"totalBreakpoints\": 0 }, \"usage\": { \"cacheRead\": null, \"cacheWrite\": null } } ``` ### Control: another client -> same router -> `claude-opus-5` ```json { \"canonicalModel\": \"claude-opus-5\", \"inputTokens\": 78802, \"claudePromptCache\": { \"applied\": true, \"totalBreakpoints\": 3 }, \"usage\": { \"cacheRead\": 78553, \"cacheWrite\": 247 } } ``` The control is important: it shows that the same router and upstream can preserve/use Claude prompt caching. The missing-cache samples are not evidence that Fable or Opus lacks server-side cache support; the marker was absent before the request reached that layer. ## Production impact observed One long-running Hermes session using the bare `fable` alias produced: ```text API calls: 88 cumulative input: 28,624,144 tokens cumulative output: 137,051 tokens largest single input: 459,339 tokens cache read: 0 cache creation/write: 0 ``` This is cumulative per-call input, not a claim that all 28.6M tokens were unique or cacheable. In an agent loop, the system prompt, tool schemas, and conversation prefix are repeatedly sent, so failure to emit breakpoints causes the reusable portion to be processed at the uncached input rate on every turn. ## Theoretical cost impact using public API prices This section is an API-price counterfactual only. It does not describe the account type or commercial route used for the observed traffic. Anthropic's public Claude Fable 5 pricing currently lists: - base input: **$10 / MTok** - 5-minute cache write: **$12.50 / MTok** - cache hit/read: **$1 / MTok** - output: **$50 / MTok** Source: https://platform.claude.com/docs/en/about-claude/pricing At public base-input pricing, the observed 28,624,144 input tokens correspond to: ```text 28.624144 MTok * $10/MTok = $286.24 of theoretical uncached input processing ``` The 137,051 output tokens would add about `$6.85`, but output cost is unaffected by prompt caching and is not part of the cache-loss estimate. The exact avoidable amount cannot be inferred from cumulative input alone because each turn has a different cacheable boundary, cache writes cost 1.25x for the 5-minute TTL, and new conversation suffixes remain uncached. A transparent sensitivity range is more appropriate. If `p` is the fraction of cumulative input that could have been served as cache reads, the gross read-vs-uncached difference before cache-write overhead is: ```text 28.624144 MTok * p * ($10 - $1) / MTok ``` | Reusable/readable share `p` | Gross theoretical difference | |---:|---:| | 50% | $128.81 | | 75% | $193.21 | | 90% | $231.86 | These are **not claimed savings**; they are bounded scenarios illustrating why silent cache disablement is operationally expensive. Actual savings would be lower after cache-write premiums, uncached suffixes, TTL expiry, prefix changes, and cold starts. For a stable prefix of `S` tokens reused over `R` calls within a 5-minute cache lifetime: ```text without caching = R * S * $10 / 1M with caching = S * $12.50 / 1M + (R - 1) * S * $1 / 1M ``` The API documentation notes that a 5-minute write pays back after one cache read. ## Expected behavior A third-party provider using `anthropic_messages` should not silently lose caching merely because the model visible at the Hermes boundary is an alias. At least one of these should be supported: 1. Resolve aliases to a canonical model/capability before evaluating prompt-cache policy. 2. Allow custom provider/model declarations such as `prompt_caching: true` or `supports_cache_control: true`. 3. Treat `anthropic_messages` as cache-capable when the provider explicitly opts in, independent of model spelling. 4. Emit a warning when a large Anthropic-wire request is sent with no cache breakpoints. A pure `api_mode == anthropic_messages` default may be too broad because not every compatible endpoint guarantees cache support; an explicit capability override plus known-provider defaults would be safer than a string heuristic. ## Suggested regression tests 1. `anthropic_messages` + runtime alias `fable` + canonical capability `claude-fable-5` enables native cache layout. 2. `anthropic_messages` + runtime alias `opus` + canonical capability `claude-opus-5` enables native cache layout. 3. Explicit `prompt_caching: true` works for a non-Claude alias. 4. Explicit `prompt_caching: false` remains authoritative. 5. Unknown Anthropic-compatible providers do not change behavior unless capability is declared, if backward compatibility requires that default. 6. A two-turn integration test verifies the second response reports `cache_read_input_tokens > 0` when the upstream supports it. ## Acceptance criteria A fixed Hermes -> gateway test should show all of the following: ```text client = Hermes canonical model = claude-fable-5 or claude-opus-5 claudePromptCache.applied = true totalBreakpoints > 0 second turn cache_read_input_tokens > 0 ``` Checking only that `cache_control` exists is insufficient; the second-turn usage field is the end-to-end acceptance signal. ## Related work - #8294 — third-party `anthropic_messages` prompt caching - #12846 — transport/provider fix that closed #8294 - #17332 — model-name gating excluded cache-capable non-Claude model IDs - https://github.com/diegosouzapw/OmniRoute/issues/813 — proxy-side automatic prompt-cache injection and observability - https://github.com/diegosouzapw/OmniRoute/pull/708 — preserving `cache_control` in Claude passthrough - https://github.com/diegosouzapw/OmniRoute/issues/1712 — prefix mutation breaking cache hits ## Environment - Hermes Agent: v0.20.0 - Host: macOS - Provider type: named custom provider - Transport: `anthropic_messages` - Gateway used for the observed artifacts: OmniRoute v3.8.49 No credentials or complete request artifacts are attached because those artifacts contain private system prompts, tools, and conversation content.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85464",
          "createdAt": "2026-08-13T17:21:33Z",
          "updatedAt": "2026-08-13T17:33:17Z",
          "timestamp": "2026-08-13T17:33:17Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/anthropic",
            "P0",
            "sweeper:risk-caching"
          ],
          "author": "xiaoyaner0201",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:d5bc11502d4a3b67d89f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:64891",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:64891",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(auxiliary): keep /anthropic base_url for anthropic_messages custom endpoints",
          "text": "## What does this PR do? The `custom` + `explicit_base_url` branch of `resolve_provider_client()` (in `agent/auxiliary_client.py`) rewrote a trailing `/anthropic` to `/v1` via `_to_openai_base_url()` and then passed that rewritten base to the wrap-decision (`_wrap_if_needed` → `_maybe_wrap_anthropic`) — **even when `api_mode` was `anthropic_messages`**. The Anthropic wrapper therefore never saw the real `/anthropic` path, so for any `custom` provider whose `base_url` ends in `/anthropic` (a proxied Anthropic gateway — MiniMax, Zhipu/GLM, a LiteLLM proxy, or a self-hosted LLM proxy) every auxiliary task (title generation, compression, vision, `web_extract`, `session_search`) hit `.../v1/chat/completions` on a Messages-only endpoint and failed. ## Fix Guard the wrap base on `api_mode`: for `api_mode == \"anthropic_messages\"` the Anthropic wrapper is built on the **raw** `/anthropic` base, while the plain OpenAI client — and the OpenAI-wire fallback taken when the `anthropic` SDK is unavailable — still uses the `/v1`-rewritten base so it never lands on `/anthropic/chat/completions`. The change is scoped: it adds a `wrap_base` that equals `custom_base` on every existing path (including the `main_runtime` named-custom path) and only differs for the explicit-base `anthropic_messages` case. Non-anthropic paths are unchanged. Ships with focused regression tests (`tests/agent/test_auxiliary_explicit_base_anthropic.py`): raw `/anthropic` base is preserved for the wrapper, the OpenAI-wire fallback stays on `/v1`, and the non-anthropic `/anthropic`→`/v1` rewrite is untouched. ## Related Root cause: #16254 (duplicate: #17086). A few open PRs already target the same `_to_openai_base_url()` `/anthropic`→`/v1` rewrite: - #61333 — skip the rewrite when `api_mode` is `anthropic_messages` (closest to this PR) - #60753 — preserve `/anthropic` base_url for the custom vision provider with `anthropic_messages` - #61394 — skip the rewrite for sub-application paths like `/apps/anthropic` **How this PR differs:** it guards the wrap base in the `custom` + `explicit_base_url` branch (the path `_resolve_auto` Step 1 uses for a `custom` main provider), and it **keeps** the `/v1` rewrite for the OpenAI-wire fallback so that fallback never lands on `/anthropic/chat/completions`. Refs #16254 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/64891",
          "createdAt": "2026-07-15T10:07:47Z",
          "updatedAt": "2026-08-13T17:33:07Z",
          "timestamp": "2026-08-13T17:33:07Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/anthropic",
            "area/config",
            "P3",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "AlexanderPrendota",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:9703a4ff1f9acf94ae04",
        "signalId": "github:NousResearch/hermes-agent:pull_request:74658",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:74658",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(slack): read real SDK responses instead of gating on isinstance dict",
          "text": "## What does this PR do? Slack user names, channel names and bot detection are silently broken on `main`: the agent receives `[U0BCE4NRVKN | Slack user <@U0BCE4NRVKN>]` instead of `[Nikita | Slack user <@U0BCE4NRVKN>]`, every user resolves as a non-bot, and some send paths report success as failure. **Root cause.** `slack_sdk` Web API calls return `SlackResponse` / `AsyncSlackResponse`. Those objects are mapping-like (they expose `.get()` and `.data`) but they are **not** `dict` subclasses. Commit `3f08201ba` (\"Fix Slack peer bot status routing loops\", #51627) added `isinstance(result, dict)` guards around those responses, so at runtime the guard is **always** False and every guarded call site takes its \"unexpected shape\" degradation branch: - `_resolve_user_name` → the name becomes the raw user id, and that wrong value is cached for the lifetime of the gateway process. This is the user-visible symptom. - `_resolve_user_is_bot` → every user resolves as a non-bot, which defeats the `allow_bots` peer-bot loop guard that `3f08201ba` was written to add. - `_resolve_channel_name` → the channel name degrades to `C0…`. - `_post_ephemeral_fallback` → a successful ephemeral reply is reported as an `unexpected_response` failure. - `_standalone_upload_file` and the standalone `chat.postMessage` path → `message_id` is lost (breaking threading of follow-up sends) and the caption fallback is never marked delivered. **Fix.** Normalize every Slack response through one helper, `_slack_response_payload()`: a plain `dict` passes through, an SDK response yields `.data`, and anything else (including a binary `.data`, which `files_*` can return) yields `{}` so callers keep their existing fallbacks. This fixes the whole bug class rather than the one reported site — all eight guarded call sites are converted. **Why the test suite did not catch it.** The existing Slack tests inject plain `dict`s into the mocked client, so the guard is True in tests and False in production. The new suite parametrizes every behavioral case to run against a real `AsyncSlackResponse` as well, which is what makes the defect (and any future recurrence) visible. The premise was verified against the runtime, not assumed: - `SlackResponse`/`AsyncSlackResponse` MRO ends at `object` in both `slack_sdk` 3.40.1 and 3.43.0 (the version pinned in `pyproject.toml`); the return type is fixed in the SDK signatures (`users_info(...) -> AsyncSlackResponse`), and Bolt hands out that same `AsyncWebClient`. No bot setting, OAuth scope, or Slack-side API change can flip this. - The symptom was captured on a live gateway (the request dump sent to the model contained the id-for-name prefix), and a direct `users.info` with the same bot token returned the correct `display_name`, ruling out scopes/transport. - Pre-fix behavior was reproduced against a real `AsyncSlackResponse`: `_resolve_user_name` returned `U_HUMAN` instead of `Nikita`; after the fix it returns the name. ## Related Issue No open issue — the symptom (\"Slack display names became user IDs\") does not appear to be reported. The only PR with the same diagnosis, #72062, was closed by its own author without maintainer review; it also bundled two unrelated changes (`_apply_yaml_config` and `success_reaction` / `other_agent_patterns`). This PR is the isolated fix, extended to the remaining call sites of the same bug class and to a test that exercises the real SDK response shape. Fixes the regression introduced by #51627 (`3f08201ba`). ## Type of Change - [x] 🐛 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 - `plugins/platforms/slack/adapter.py`: new module-level `_slack_response_payload()` normalizer; all Slack Web API call sites read through it — `_resolve_user_name`, `_resolve_user_is_bot`, `_resolve_channel_name`, `_post_ephemeral_fallback`, the thread seed-post `ts` read, `_standalone_upload_file`, and the standalone `chat.postMessage` path including the caption fallback. Every `isinstance(resp, dict)` gate on an SDK response is removed. - `tests/gateway/test_slack_sdk_response.py` (new, 23 tests): the normalizer's contract (dict passthrough, `.data`, binary `.data` → `{}`, unknown shape → `{}`), the identity/channel resolution paths, and the send paths — each parametrized over a hand-rolled stand-in **and** a real `AsyncSlackResponse` (skipped automatically when the `slack` extra is not installed). Includes an explicit assertion of the bug's premise (the runtime object fails an `isinstance` dict gate) and keeps coverage for the intended degradation on a genuinely unreadable response. ## How to Test 1. With a real Slack workspace, run the gateway in a shared channel on `main` and send a message: the text handed to the model is prefixed with `[U… | Slack user <@U…>]` — the display name has been replaced by the id. 2. Apply this PR, restart the gateway (the wrong name is cached per process) and start a new session: the prefix becomes `[Alice | Slack user <@U…>]`. 3. Automated: `scripts/run_tests.sh tests/gateway/test_slack_sdk_response.py tests/gateway/test_slack.py tests/gateway/test_slack_mention.py tests/tools/test_send_message_slack.py -q` → **212 passed, 0 failed** (branch cut from current `main`). ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(slack): …`) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate — closest is the author-closed #72062, see \"Related Issue\" - [x] My PR contains **only** changes related to this fix (no unrelated commits) — 1 commit, 2 files - [x] I've run the affected suites via `scripts/run_tests.sh` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — N/A (restores documented behavior; the new helper is documented in its docstring) - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A (no config keys) - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A - [x] I've considered cross-platform impact (Windows, macOS) — N/A (pure Python response handling) - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A (no tool schema changed) ## Screenshots / Logs **Before** (prefix the model receives, from a live gateway's request dump): ``` [U0BCE4NRVKN | Slack user <@U0BCE4NRVKN>] ... ``` **After:** ``` [Nikita | Slack user <@U0BCE4NRVKN>] ... ``` Behavioral side effect worth calling out for review: with the guard removed, `_resolve_user_is_bot` actually recognizes bots again, so the `allow_bots` policy starts enforcing as #51627 intended.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/74658",
          "createdAt": "2026-07-30T06:53:51Z",
          "updatedAt": "2026-08-13T17:32:18Z",
          "timestamp": "2026-08-13T17:32:18Z",
          "metrics": {
            "reactions": 0,
            "comments": 6
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "platform/slack",
            "P3",
            "sweeper:risk-message-delivery",
            "sweeper:blast-moderate"
          ],
          "author": "nikitaBarkov",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:78809d6668da86351cfe",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85463",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85463",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(slack): read real SDK responses instead of gating on isinstance dict (salvage #74658)",
          "text": "## Summary Slack adapter now reads real slack_sdk response objects — user name resolution, bot detection, ephemeral send confirmation, and thread `ts` reads all work again. Salvage of #74658 by @nikitaBarkov onto current main, authorship preserved. Root cause: commits from #69483/#70196/#69479 added `isinstance(resp, dict)` gates on Web API responses, but `SlackResponse`/`AsyncSlackResponse` are not dict subclasses — every gate was always False at runtime while dict-injecting tests stayed green. ## Changes - `plugins/platforms/slack/adapter.py`: new `_slack_response_payload()` normalizer (dict passthrough → `.data` if dict → `{}`), routed through all 8 gated call sites — whole-class fix - `tests/gateway/test_slack_sdk_response.py`: 18 tests parametrized over a stand-in AND a real `AsyncSlackResponse`, including the handoff-thread seed-ts and standalone media-send paths flagged in review ## Validation | | Before | After | |---|---|---| | users.info result | discarded, raw ID cached forever | name resolved | | bot detection | always False | works | | ephemeral send | reported as failure | confirmed | | targeted tests | — | 18/18 pass | ## Infographic ![Slack adapter SDK response fix](https://files.catbox.moe/wnii9i.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85463",
          "createdAt": "2026-08-13T17:20:56Z",
          "updatedAt": "2026-08-13T17:32:11Z",
          "timestamp": "2026-08-13T17:32:11Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "platform/slack",
            "P3"
          ],
          "author": "teknium1",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c8624018dfb9879f614a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85022",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85022",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(kanban): emit memory on_session_end deterministically for -Q goal-mode workers",
          "text": "## Problem A kanban worker's memory-provider `on_session_end` fires only when the process reaches `_run_cleanup` -> `shutdown_memory_provider`. - The non-goal `-q` worker reaches it from `_finalize_single_query`'s `finally`. - The fully-quiet `-Q` goal-mode worker relied **solely on `atexit`**. `atexit` is bypassed by the kanban `os._exit(0)` signal handler, by the exit watchdog, and by hard kills. So for `-Q` workers the provider's session-end hook never runs. ## Scope (deliberately stated narrowly) This is a **lifecycle correctness fix, not a memory-loss fix**. On our setup the configured provider's `on_session_end` performs a clean shutdown rather than an ingest, so the practical effect of the bug is that the provider is never closed cleanly on `-Q` worker exits. Providers that *do* ingest at session end would lose their final turns, which is the more general case this guards. A hook documented to run at session end should run on every exit path, not only the ones that happen to reach `atexit`. ## Fix Emit the session boundary deterministically at the worker session boundary, on both the success and failure paths. Existing `_cleanup_done` guarding makes it idempotent, and a raising provider is swallowed by `MemoryManager`, so a failing plugin cannot fail the card. Interactive behaviour is unchanged. ## Tests `tests/cli/test_worker_session_end_emission.py` — 5 tests: emission on success, on failure, exactly once, hook exceptions do not propagate, and interactive paths unchanged. Scope is minimal: 24 added lines in `cli.py` plus the test file, no deletions and no unrelated changes. --- Diagnosed, implemented and tested autonomously by the HSCC cluster (a self-hosted DGX Spark fleet running hermes-agent).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85022",
          "createdAt": "2026-08-13T03:53:25Z",
          "updatedAt": "2026-08-13T17:32:04Z",
          "timestamp": "2026-08-13T17:32:04Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/cron",
            "tool/memory",
            "P3"
          ],
          "author": "pom11",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a0c2566fa519782ea981",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85370",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85370",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(hooks): add emit_waterfall — Cordis-style around-middleware dispatch",
          "text": "## Summary Adds `HookRegistry.emit_waterfall()` — a Cordis-style **around-middleware** dispatch mode for gateway event hooks. This is the Hermes port of the waterfall primitive DeepSeek Harness uses for its tool execution pipeline (`tools/pre-execute` → `tools/execute` → `tools/post-execute`), where cooperative listeners mutate a shared request and delegate via `next()`, while a policy listener that owns a decision returns without delegating. ## Background Hermes gateway hooks previously had exactly two dispatch modes: | Mode | API | Handler signature | What a handler can do | |------|-----|-------------------|----------------------| | Observe | `emit` | `handle(event_type, context)` | Side effects only; return ignored | | Collect | `emit_collect` | `handle(event_type, context)` | Return a value; caller interprets all results | Both are flat fan-out: every handler sees the same input, and there is no way for one handler to **rewrite the value before the next handler sees it**, nor to **stop the chain** (a deny decision that should prevent later handlers from running). The gateway command-dispatch path works around this by returning `{\"decision\": ...}` dicts and re-interpreting them in `gateway/run.py` — a pattern that cannot express \"the previous handler rewrote the command, now re-check the rewritten command\". DeepSeek Harness solves this with Cordis waterfall dispatch: a handler receives `(...args, next)`; calling `next()` delegates (optionally with a replaced value), returning without `next()` short-circuits. This is the core of their tool policy pipeline. ## What this PR adds `gateway/hooks.py` — `emit_waterfall(event_type, value, context)`: - **Waterfall participants** (4-arg handlers: `handle(event_type, value, context, next_fn)`): - `await next_fn(new_value=...)` → delegate downstream, optionally replacing the value; - return without calling `next_fn` → **short-circuit**; the handler's return value becomes the waterfall result. - **Legacy observers** (existing 2-arg handlers) keep working on the same event: run in order, return ignored, cannot rewrite or short-circuit — no breakage for current hook users. - **Fail-closed containment**: a throwing waterfall participant stops the chain; a throwing observer is contained (chain continues). - Sync and async handlers both supported; wildcard (`command:*`) matching applies. `tests/gateway/test_hooks_waterfall.py` — 11 regression tests: delegate, rewrite propagation, short-circuit, observer compat (both directions), fail-closed, contained observer, async observer, empty chain, wildcard matching, and filesystem-discovered waterfall handlers. `website/docs/user-guide/features/hooks.md` — new \"Waterfall Handlers (around-middleware)\" section with a policy example, semantics table, and lineage note. ## Why this approach - **Smallest footprint**: a single new method on the existing `HookRegistry`; zero changes to `emit`/`emit_collect` or any call site. Existing hooks are untouched. - **Backward compatible by construction**: handler arity inspection distinguishes waterfall participants from legacy observers, so a mixed event (observer + waterfall handlers) behaves correctly without a config flag. - **Real consumer ready**: the gateway `command:*` dispatch path (and future tool-policy hooks) can migrate from `emit_collect` + caller-side dict interpretation to a true middleware chain — rewrite decisions propagate downstream instead of being re-parsed by the caller. - **Inspired by prior art, not reinvented**: the waterfall contract (delegate vs short-circuit, value propagation through `next()`) matches Cordis semantics as documented by deepseek-ai/deepseek-harness (`docs/cordis-primer.md`). ## Who should enable this - Hook authors building **policy / guardrail / rewrite** chains (allow-deny, command rewriting, rate limiting, request mutation) who need one handler's output to feed the next, or a deny to stop later handlers. - Gateway integrators who previously had to hand-roll decision interpretation over `emit_collect` return lists. ## Platform compatibility | Platform | Support | |----------|---------| | Linux | ✅ pure-Python, no native deps | | macOS | ✅ | | Windows | ✅ | | CLI / Gateway / TUI / Desktop | ✅ (gateway event-hook surface) | ## Quick-start guide ```python # ~/.hermes/hooks/command-policy/handler.py async def handle(event_type: str, value, context: dict, next_fn): command = context.get(\"command\", \"\") if command in {\"dangerous\", \"wipe\"}: return {\"decision\": \"deny\", \"message\": f\"/{command} is blocked by policy.\"} return await next_fn(new_value=value) # delegate; rewrite if needed ``` The host calls `emit_waterfall` (instead of `emit`) for events that support policy chains. Short-circuit returns are the final result; delegated values propagate to later handlers. ## Troubleshooting | Symptom | Cause | Fix | |---------|-------|-----| | My existing hook still runs but my new 4-arg handler never fires | Handler arity: a 2-arg handler is treated as an observer | Declare all four params: `def handle(event_type, value, context, next_fn)` | | Chain stopped unexpectedly | A waterfall participant returned without calling `next_fn` (intentional short-circuit) or threw (fail-closed) | Check the `[hooks] Error in waterfall handler` log line; ensure policy handlers delegate when they should | | Value not rewritten downstream | `next_fn()` called without `new_value=` | Pass `await next_fn(new_value=...)` to replace the value | ## Verification ```bash venv/bin/python3 -m pytest tests/gateway/test_hooks_waterfall.py tests/gateway/test_hooks.py -q # 18 passed venv/bin/python3 -m ruff check gateway/hooks.py tests/gateway/test_hooks_waterfall.py # All checks passed! ```",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85370",
          "createdAt": "2026-08-13T14:53:12Z",
          "updatedAt": "2026-08-13T17:31:38Z",
          "timestamp": "2026-08-13T17:31:38Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/gateway",
            "P3"
          ],
          "author": "x7peeps",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:3573e6c2a7d5ebd3f886",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85372",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85372",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(doctor): report Hermes source checkout state",
          "text": "## Summary Add a non-failing `Source Checkout` section to `hermes doctor` so users can tell whether the running Hermes install is clean upstream, behind/ahead of its upstream, or dirty with local live patches. The section reports: - current branch / detached state and short HEAD - upstream ahead/behind counts when an upstream is configured - tracked local modifications count - untracked file count with a first-path sample This is meant to prevent confusion when a live install has local patches applied, especially when debugging behavior that differs from upstream or an open PR. ## Verification Unit tests: ```text python -m pytest tests/hermes_cli/test_doctor_source_state.py -q 2 passed ``` Lint: ```text python -m ruff check hermes_cli/doctor.py tests/hermes_cli/test_doctor_source_state.py All checks passed! ``` Direct helper smoke in this checkout: ```text ('info', 'Source checkout', 'fix/doctor-source-state @ fa83af3f9a42') ('ok', 'Upstream origin/main', 'behind 0, ahead 0') ('warn', 'Source checkout has local modifications', '1 tracked file(s) changed') ('info', 'Untracked source files', '1 file(s); first: tests/hermes_cli/test_doctor_source_state.py') ``` Note: a full `python cli.py doctor` invocation exceeded 300s in my local environment before reaching this section, due to unrelated slow doctor checks. The new logic is covered as a pure helper and wired into `run_doctor`.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85372",
          "createdAt": "2026-08-13T14:54:29Z",
          "updatedAt": "2026-08-13T17:31:30Z",
          "timestamp": "2026-08-13T17:31:30Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "P3"
          ],
          "author": "patrykkopycinski",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:9aec36746f94b7a049a1",
        "signalId": "github:NousResearch/hermes-agent:pull_request:74743",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:74743",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(junie-acp): JetBrains Junie via ACP — with the self-improvement loop intact (supersedes #69207)",
          "text": "## What does this PR do? Adds **JetBrains Junie** as a native coding-agent provider (`junie-acp`), driven over the **Agent Client Protocol** using the `agent-client-protocol` SDK (already vendored as the `[acp]` extra). Hermes spawns `junie --acp=true` and talks to it as an ACP *client*, exposing Junie through the standard chat surface so it can be selected like any other model/provider. Junie is an autonomous agent that runs its **own** tools inside the ACP session, and this integration still treats it that way: its native `tool_call` notifications are never fabricated as *pending* OpenAI `tool_calls`, so Hermes never re-runs finished work. What changed since #69207 is that this no longer costs Hermes its own agentic surface: - Hermes' agent-level tools (the memory/skills self-improvement loop, `todo`) now reach Junie through the ACP **text bridge** — the pattern `copilot-acp` already used, factored out so both clients share it. - Junie's completed work is **projected into the transcript** as `assistant(tool_calls=[…])` + `tool(result)` rows, so the background-review fork replays real calls, results and errors instead of a one-line activity feed. - The skill-review nudge counter now moves for iterations that happen inside the provider, and the review fork either works or doesn't spawn. That addresses @teknium1's review on #69207 in full (details under *Changes Made*). This PR **supersedes #69207**, which was auto-closed under the in-tree-provider policy while the review fixes were being written — see the last section. Rebased on current `main`. ## Related Issue N/A — new optional provider integration. Supersedes #69207 (closed by hermes-sweeper, not by review). Happy to open a tracking issue if preferred. ## Type of Change - [x] ✨ New feature (non-breaking change that adds functionality) ## Changes Made **Provider integration (as in #69207)** - **New provider client** `agent/junie_acp_client.py`: drives `acp.ClientSideConnection` from a dedicated background asyncio loop, bridged to Hermes' synchronous chat-completions call path. Persistent subprocess reuse and a no-replay-after-prompt-dispatch retry guard (Junie may already have applied side effects). - **Safety bridge** (client-side ACP callbacks): deny-by-default `session/request_permission` (configurable); sandboxed `fs/read_text_file` + `fs/write_text_file` (path-within-cwd enforcement, write-deny for protected files, secret redaction on read). - **Provider wiring**: `plugins/model-providers/junie-acp/` profile + aliases (`junie`, `jetbrains-junie-acp`, `junie-acp-agent`); routing in `agent/agent_runtime_helpers.py` / `agent/auxiliary_client.py`; setup / model-picker / dashboard in `hermes_cli/` (`models.py`, `providers.py`, `model_setup_flows.py`, `model_switch.py`, `setup.py`, `web_server.py`, `auth.py`, `model_normalize.py`, `runtime_provider.py`, `main.py`); `agent/model_metadata.py`, `agent/agent_init.py`, `agent/conversation_loop.py`. - **Model handling**: `-m <id>` forwarded to Junie via `session/set_config_option{model}`; optional Brave Mode override. The `/model` picker lists Junie's **live** advertised models (read from `session/new` `config_options`) layered over the provider sentinel — **without** adding real ids to the static `_PROVIDER_MODELS` reverse-map, so `detect_provider_for_model` still resolves `claude/gemini/gpt` ids to their real providers. The live catalog is cached per account (hashed token, never stored raw) and negatively cached in-memory to avoid re-spawning the JVM on every picker open. - **Observability/robustness**: token usage from `PromptResponse.usage` (with a ~4-chars/token estimate fallback so the context gauge + compressor work), 50 MB stdio buffer, a process-death watcher that rejects in-flight requests instead of hanging, per-call `session/new` + `set_config_option` timeouts, and captured subprocess stderr in failure messages. - **Skill**: adds a `junie` autonomous-ai-agents delegation skill and cross-links it from the sibling skills' `related_skills`. **Review fixes from #69207 — keeping the self-improvement loop alive** - **Hermes tools are reachable again.** `_create_chat_completion` no longer drops `tools` or pins `tool_calls=[]`. Schemas travel into the prompt as text and `<tool_call>` blocks are parsed back out. The copilot-acp bridge moved to a shared **`agent/acp_openai_bridge.py`** (text bridge + stream-chunk shaping); `agent/copilot_acp_client.py` loses ~180 lines of duplication and now imports it. - **Forwarding is an allowlist**, not the whole toolset: Junie keeps running its own read/edit/execute tools, so re-offering those would make Hermes re-run finished work. The set covers the background-review fork's *entire* whitelist, reads included — a review that can call `skill_manage` but not `skills_list`/`skill_view` could only create skills blindly instead of updating the right one. A test keeps the two in sync. `HERMES_JUNIE_ACP_FORWARD_TOOLS` widens/narrows it (`all` forwards everything). - **Transcript projection**: the per-`toolCallId` detail already folded in the client (`rawInput`/`rawOutput`, status, locations) is projected into `messages` as completed `assistant(tool_calls)` + `tool(result)` pairs, mirroring `agent/transports/codex_event_projector.py`. Projected ids are namespaced by session (a raw per-session `t1` repeats across turns and collides where calls pair with results by id), and projected tool names are namespaced `junie_*` so they can never be mistaken for Hermes tools to dispatch. - **Skill-nudge counter**: new `agent/provider_projection.py::splice_provider_projection` splices those rows and ticks `_iters_since_skill` with the iterations that happened inside the provider — what `agent/codex_runtime.py` already does for the codex app-server path. Called once in `agent/conversation_loop.py`; no-op for ordinary providers. - **Review-fork guard** (`agent/background_review.py`): a client that cannot carry Hermes tool calls declares `SUPPORTS_HERMES_TOOL_CALLS = False` and the fork is skipped with a log line pointing at `auxiliary.background_review.{provider,model}`, instead of spawning an agent that could only no-op. Capability-based, so any future agent-as-provider integration inherits it. `hermes_cli/model_setup_flows.py` now surfaces that override during setup. - **Session reuse within a turn**: a Hermes tool call splits one user turn into several ACP prompts. The first opens a fresh session with the full transcript; continuations reuse it and carry only the results, so Junie doesn't restart the task and redo every edit. Reuse is bound to the `tool_call` ids Junie actually requested, so a history that merely *ends* with a tool row (resume, host-fed transcript) is never delivered as \"here are your results\". - **Reaped-session recovery**: a reused session Junie no longer knows used to fail the turn hard. It now falls back to a fresh session with the full transcript — but only when Junie sent nothing at all for that prompt (no update, no permission request, no fs call), so a prompt it acted on is still never replayed. - **`stream=True`** (MoA / auxiliary paths) returned a bare completion and would have silently dropped the projection; both ACP clients now share one chunk converter whose carrier keeps response-level extras. Configuration is env-driven: `HERMES_JUNIE_ACP_COMMAND` / `JUNIE_CLI_PATH`, `JUNIE_API_KEY`, `HERMES_JUNIE_ACP_PERMISSION` (`deny` default / `allow`), `HERMES_JUNIE_ACP_BRAVE` (`on`/`off`), `HERMES_JUNIE_ACP_FORWARD_TOOLS` (allowlist override). ## How to Test 1. Install the ACP extra and the JetBrains Junie CLI: `pip install 'hermes-agent[acp]'`; ensure `junie` is on `PATH` and authenticated (or set `JUNIE_API_KEY`). 2. Provider + review-loop suites: `pytest tests/agent/test_junie_acp_client.py tests/hermes_cli/test_junie_acp_setup.py tests/agent/test_agent_as_provider_projection.py tests/agent/test_background_review_tool_call_guard.py -q` → **84 passed**. The junie client suite runs end-to-end against a real SDK-based fake ACP agent subprocess, including the tool bridge, session reuse and the reaped-session fallback. 3. No regression to the sibling: `pytest tests/agent/test_copilot_acp_client.py tests/agent/test_copilot_acp_deprecation.py -q` → 25 passed (copilot-acp now uses the shared bridge). 4. Live: `hermes --provider junie-acp -m junie-acp chat`, ask a coding question, confirm Junie answers over ACP and that `/model` lists its live catalog. 5. Live, self-improvement loop: ask Junie to remember something (`memory` reaches it via the bridge and the write lands), then check the session transcript — Junie's file/command work appears as `junie_*` tool call/result rows, not just as reasoning text. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat(junie-acp): ...`, `fix(junie-acp): ...`) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass <!-- tests/agent, tests/run_agent and tests/agent/transports run green relative to main: tests/agent gives the SAME 87 failures on this branch as on a clean upstream/main worktree (pre-existing, order-dependent), with +73 passing; tests/run_agent + tests/agent/transports = 1479 passed. Did not get the entire tests/ tree green in my environment — it isn't green on main either. --> - [x] I've added tests for my changes (24 added in the review-fix commit: bridge parse, forwarded allowlist, continuation detection + both fallbacks, session reuse, reaped-session recovery, projection shape and id uniqueness, streaming carrier, fork guard, loop wiring, allowlist/whitelist sync) - [x] I've tested on my platform: macOS 15 (Darwin), Python 3.13 ### Documentation & Housekeeping - [x] I've updated relevant documentation (`junie` skill `SKILL.md` documents the forwarded-tool set and the review-routing knob; module docstrings for the new `acp_openai_bridge.py` / `provider_projection.py` carry the rationale) — README/`docs/` N/A - [x] cli-config.yaml.example — N/A (provider is env-var driven, no new config keys; the review fix documents the existing `auxiliary.background_review` key rather than adding one) - [x] CONTRIBUTING.md / AGENTS.md — N/A (no architecture/workflow change) - [x] I've considered cross-platform impact — POSIX + Windows HOME/`pwd` fallback handled; stdio transport via the SDK - [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A (no Hermes tool schema changed; only which of them are described to the ACP agent) ## Note on the automated closure of #69207 #69207 was closed by hermes-sweeper under the in-tree-provider policy. `AGENTS.md:797-813` does name `plugins/model-providers/junie-acp/`, so I'm not arguing the rule — but the suggested remedy (\"publish standalone, nothing special is needed in core\") isn't reachable today for an ACP provider, and I'd rather ask than guess: - `ProviderProfile` (`providers/base.py`) has no hook for a client/transport, and `VALID_HOOKS` in `hermes_cli/plugins.py` has none either. - Which client to construct is hardcoded on provider name: `agent/agent_runtime_helpers.py:1865,1876`, `agent/agent_init.py:658-660,1103`, `agent/conversation_loop.py:1455-1457` (streaming), `agent/auxiliary_client.py:5282,5301`, `agent/model_metadata.py:51`. - `HERMES_OVERLAYS` / `ALIASES` in `hermes_cli/providers.py:94,105` are in-tree dicts with no registration API — and that's where `acp://junie` and `auth_type=\"external_process\"` live. - The plugin docs describe `external_process` as \"`copilot-acp` plugin only\", and copilot-acp — the only existing ACP provider — is itself in-tree and spread across those same files. So a user-installed plugin can register a profile but can't make Hermes speak ACP to a subprocess. If standalone is the preference, I'm glad to do the enabling work first: a vendor-neutral PR replacing those hardcoded provider literals with profile data (`client_factory`, api-mode/streaming flags, plugin-registerable overlays), with the in-tree `copilot-acp` migrated onto it as proof, and then ship Junie as its own repo against that seam. The three core pieces here (`acp_openai_bridge.py`, `provider_projection.py`, the review-fork guard) are provider-neutral and would stay in core either way. Tell me which direction you want and I'll follow it.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/74743",
          "createdAt": "2026-07-30T09:41:42Z",
          "updatedAt": "2026-08-13T17:31:30Z",
          "timestamp": "2026-08-13T17:31:30Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/cli",
            "comp/acp",
            "comp/plugins",
            "needs-decision",
            "sweeper:risk-session-state",
            "sweeper:risk-security-boundary",
            "sweeper:risk-compatibility",
            "sweeper:risk-platform-windows",
            "sweeper:blast-broad",
            "P4"
          ],
          "author": "AlexanderPrendota",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:5af6c1aabcd9ba2807ff",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85388",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [
          "updatedAt",
          "metrics",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85388",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(pricing): apply DeepSeek peak/off-peak rate card (#72662)",
          "text": "## Summary Implements #72662: DeepSeek's official rate card switches to peak/off-peak billing at **2026-08-16T16:00 UTC** (announced on https://api-docs.deepseek.com/quick_start/pricing): - Peak hours: **01:00–04:00 and 06:00–10:00 UTC** (all other hours off-peak) - Peak = 2× off-peak on every billing item (cache-hit input, cache-miss input, output) ## Changes ### `agent/usage_pricing.py` - **Snapshot updated to the 2026-08-16 OFF-PEAK rates**: deepseek-v4-flash $0.007 / $0.22 / $0.66 per 1M (cache-hit / cache-miss / output), deepseek-v4-pro $0.022 / $0.66 / $1.98. `pricing_version` bumped to `deepseek-pricing-2026-08-16`. The deepseek-chat / deepseek-reasoner aliases keep sharing flash's rates. - **`estimate_usage_cost` peak/off-peak selection**: before the switchover instant the legacy flat card (`deepseek-pricing-2026-07`) applies; after it, calls during peak hours bill at 2× the off-peak rates and the `CostResult` carries a \"peak-hour rate applied\" note. Peak windows are read as half-open intervals → hours 1, 2, 3 and 6, 7, 8, 9. - The legacy flat card lives in a small temporary module dict (`_DEEPSEEK_LEGACY_FLAT_RATES`) so estimates stay accurate during the transition window; it is marked for removal after the switchover. ### `tests/agent/test_usage_pricing.py` 7 new tests: - peak-hour boundary matrix (hours 0–11, 23) incl. exact window edges (00:59:59 → 01:00:00, 03:59:59 → 04:00:00, 09:59:59 → 10:00:00) - official-table amounts for both models (1M in + 1M out: flash $0.88 off-peak / $1.76 peak; pro $2.64 / $5.28) - cache-read tokens scale at peak ($0.007 → $0.014 flash) - pre-switchover legacy flat rates (incl. a would-be peak hour) - switchover instant boundary (15:59:59Z legacy, 16:00:00Z new card) - non-DeepSeek providers unaffected during peak hours - updated `test_deepseek_v4_pro_pricing_entry_exists` to the new card ## Testing - `tests/agent/test_usage_pricing.py`: 20 passed - All usage_pricing consumers (insights, model cost guard, usage command, MoA loop, context engine): 100 passed total - A large combined batch run shows ~120 failures in unrelated suites (title generator, secret-scope migration, codex transport, etc.) that pass in isolation on both main and this branch — known order-dependent batch interference in this repo, unrelated to this change. ## Notes - **Boundary semantics**: peak windows are treated as half-open [01:00, 04:00) and [06:00, 10:00) UTC, matching the 09:00–12:00 / 14:00–18:00 Beijing framing (clean 12:00–14:00 lunch gap). Hour 4 (04:00–05:00) is off-peak. - **Transition window**: rates are selected at call time (post-request), matching DeepSeek's per-request timestamp billing. Before 2026-08-16T16:00Z the legacy flat card is used, so estimates don't overstate during the transition. - Supersedes the earlier attempt #72756, whose 2×-on-current-rates approach no longer matches DeepSeek's published pricing (the official off-peak rates themselves differ from the current flat card). - **Credit**: feature request and the peak-window UTC mapping are from @dominicelayda's issue #72662; @webtecnica's earlier attempt (#72756) established the multiplier approach and surfaced the need for the official rate card. This PR is a fresh implementation against the official card. Closes #72662",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85388",
          "createdAt": "2026-08-13T15:24:17Z",
          "updatedAt": "2026-08-13T17:31:10Z",
          "timestamp": "2026-08-13T17:31:10Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "provider/deepseek",
            "P3",
            "area/usage-cost"
          ],
          "author": "DavidMetcalfe",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:4ff763b7bbd8ca50914c",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85401",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [
          "updatedAt",
          "metrics",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85401",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(title): support deterministic-only auto titles",
          "text": "Allows automatic titles to use the deterministic path without enabling later model-based title upgrades. The default behavior remains unchanged.\\n\\nValidation: canonical scripts/run_tests.sh; 35 title tests passed.\\n\\nRelated: #85194",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85401",
          "createdAt": "2026-08-13T15:46:10Z",
          "updatedAt": "2026-08-13T17:30:45Z",
          "timestamp": "2026-08-13T17:30:45Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "P3",
            "area/sessions"
          ],
          "author": "lightcloud00",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:efa21857254049f24bbd",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85403",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [
          "updatedAt",
          "metrics",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85403",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(whatsapp): pair into the session dir the gateway reads, and reject empty creds",
          "text": "## What Fixes two WhatsApp pairing bugs from #85391 that together strand users in a restart loop against an \"enabled but not paired\" precondition no restart can satisfy. Fixes #85391 (Bugs 1 & 2). Bug 3 in the issue is a minor packaging/UX note (dependency-less bridge copy under the install root) and is left for a follow-up. ## Bug 1 — wizard paired into a directory nothing reads The gateway adapter and the dashboard both resolve the session dir via: ```python get_hermes_dir(\"platforms/whatsapp/session\", \"whatsapp/session\") ``` but the `hermes whatsapp` wizard hard-coded the **legacy** path: ```python session_dir = get_hermes_home() / \"whatsapp\" / \"session\" ``` `get_hermes_dir(new, old)` returns the legacy path *only while it exists with content* (an empty stub does not count — see the `#27602` note in `hermes_constants.py`). So the two agree only while `whatsapp/session` is populated. The moment it's emptied — a natural \"start clean\" move — the wizard keeps writing to `whatsapp/session` while the gateway reads `platforms/whatsapp/session`. Re-pairing then has no effect: the gateway replays the old (revoked) creds and loops on `Logged out`. **Fix:** route the wizard through a shared `_whatsapp_session_path()` helper that mirrors the reader's resolver, so writer and reader can never diverge. ## Bug 2 — a 0-byte `creds.json` counted as \"paired\" The start preflight was existence-only: ```python creds_path = self._session_path / \"creds.json\" if not creds_path.exists(): ``` In `--pair-only` mode the bridge writes `creds.json` **after** emitting the `connected` event and then exits on its own, so a supervisor that terminates the bridge on `connected` truncates the file. The reporter saw exactly this: `connected` fired, **826** key files were written, yet `creds.json` was **0 bytes** — and existence-only passed, so the gateway proceeded on unusable credentials. **Fix:** add `_has_valid_creds()` — non-empty, parseable JSON, carrying the Baileys `noiseKey`/`signedIdentityKey` — and gate `start()` on it instead of bare existence. The \"not paired\" log line now says *missing, empty, or truncated*. ## Tests - `tests/gateway/test_whatsapp_creds_validation.py` — new: missing / 0-byte / truncated / non-object / keyless creds all rejected; a valid creds dict accepted. - `tests/hermes_cli/test_whatsapp_session_path.py` — new: fresh install and an **empty legacy stub** both resolve to the consolidated path; a populated legacy dir is still honored; and the wizard agrees with the adapter's default. - Existing `test_whatsapp_connect.py` / `test_whatsapp_stale_bridge.py` fixtures updated to write valid creds (and bypass the preflight where they exercise `connect()`'s downstream paths), since the preflight now validates content, not just existence. Preflight (`windows-footguns`, `ruff`, affected tests) green vs `upstream/main`. Pre-existing bare `write_text` calls in the two touched test files were given `encoding=\"utf-8\"` to satisfy the windows-footgun gate. The known arm64-fork-Docker CI job failure is unrelated to this change.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85403",
          "createdAt": "2026-08-13T15:50:08Z",
          "updatedAt": "2026-08-13T17:30:24Z",
          "timestamp": "2026-08-13T17:30:24Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/plugins",
            "platform/whatsapp",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-compatibility"
          ],
          "author": "PRATHAMESH75",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:b4af56d353a1e12eacc7",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85405",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [
          "updatedAt",
          "metrics",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85405",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): passive update check must compare against the official repo, not the local origin",
          "text": "## Problem The desktop app's passive update check (\"Settings → About → Check now\", and the background auto-check) can report **\"You're on the latest version\" while the checkout is thousands of commits behind the official repo.** `checkUpdates()` in `apps/desktop/electron/main.ts` only probed the official repo when the local git origin was the **official SSH** URL (`git@github.com:NousResearch/hermes-agent.git`). For every other origin — including the official **HTTPS** remote and any **fork** — it ran `git fetch origin <branch>` and compared `HEAD` against *that* origin's branch. A fork origin is common on contributor machines. If the fork's `main` trails the local `HEAD`, the comparison reads as \"0 behind\" and the app concludes it's current — even when official `main` is far ahead. Updates are then silently never offered, and the auto-updater never recovers on its own. ### Reproduction (real install) - Local checkout: official `main` @ `01a1037d1` (2026-08-05), origin pointed at a fork (`33hodl/hermes-agent`, fork `main` @ 2026-07-04) - Local `HEAD` was **6,431 commits ahead** of the fork's `main` → `behind = 0` - App reported \"You're on the latest version\" while official `main` was **~1,500 commits ahead** of `HEAD` - The About panel's git metadata confirmed the checkout was a source install (branch/commit shown), i.e. exactly the path that uses this check ## Fix The passive check now **always probes the official repo first** via anonymous HTTPS `ls-remote` — the same read-only, no-auth, no-SSH/FIDO2-prompt mechanism the code already used for official SSH remotes (`update-remote.ts`). Only when that probe fails (offline / GitHub unreachable) does it fall back to the old behavior: - official SSH origins → fail-fast `fetch-failed` (unchanged; avoids the hardware-touch prompt) - all other origins → the previous `git fetch origin` comparison path (unchanged) So a fork or stale-mirror origin can no longer masquerade as \"latest\". The verdict is always about the official repo's branch, which is the question a user is actually asking. New testable helper `isOfficialRemote()` in `update-remote.ts` classifies SSH/HTTPS official remotes for the fallback decision. ## Why not an alternative - **Fix the origin instead** — doesn't scale: forks and mirrors are a legitimate, documented setup for contributors and organizations; the check itself should be honest regardless of origin. - **Make apply pull from the official repo too** — out of scope: active apply flows are deliberately left unchanged (installs that intentionally track a fork keep pulling from their own origin). This PR only fixes the *check's* source of truth. - **Require a release-tag comparison** — `main`-tracking installs (the default) check against the branch they track, not the last release; comparing against the official branch keeps the existing semantics, just with the correct reference. ## Validation - `npx vitest run electron/update-remote.test.ts` → **7/7 passing** (6 existing + new `isOfficialRemote` coverage) - `esbuild` parse check on the modified `main.ts` → clean - Production observation: the reproduction above — after this change, the same install compares against official `main` and reports the true behind state instead of \"latest\" No speculative surface: single-purpose diff (3 files, +62/−22), behavior preserved for every previously-correct path.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85405",
          "createdAt": "2026-08-13T15:53:13Z",
          "updatedAt": "2026-08-13T17:30:14Z",
          "timestamp": "2026-08-13T17:30:14Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "P2",
            "comp/desktop",
            "area/install-update"
          ],
          "author": "33hodl",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:ca4f4f4684baea1d1960",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85409",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [
          "updatedAt",
          "metrics",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85409",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(memory): extend embedded Hindsight install timeout",
          "text": "## What Give embedded Hindsight package installs a 600-second budget while keeping the existing 120-second timeout for lightweight memory-provider SDKs. This applies consistently to: - `hermes memory setup` for `local_embedded` - active-provider restoration during `hermes update` - the Intel macOS slim embedded stack proposed in #81530 ## Why Hermes already treats Hindsight as a supported shipped memory provider. For `local_embedded`, setup installs the full runtime and `hermes update` restores it after rebuilding the managed venv. That supported repair path currently passes the full Hindsight stack to `install_specs(..., timeout=120)`. A real managed update on Apple Silicon exceeded that generic budget, logged a warning, continued successfully, and left the configured embedded provider absent. The same forced dependency restoration completes when allowed a realistic multi-minute budget. This follows up the provider-healing work merged in #72363. It also addresses the 120-second embedded install timeout reported during #7718 without moving Hindsight to an external service or adding private dependency pins. ## Scope - Detect package names from versioned/extra-qualified specs. - Use 600 seconds only when the dependency set includes `hindsight-all`, `hindsight-all-slim`, or `hindsight-api-slim`. - Preserve 120 seconds for `hindsight-client` cloud/external mode and other providers. - Reuse the same timeout selector at both setup and update restoration call sites. This PR deliberately does not address the stale `hindsight-client==0.6.1` runtime pin. That known issue is already covered by #80390 and the broader existing fix in #80517. ## Verification - Focused timeout/update tests: 5 passed. - Ruff: clean on all four changed files. - `git diff --check`: clean. - `uv.lock`: unchanged. - Sabotage test: restoring the historical fixed 120-second selector makes all three embedded-runtime regression assertions fail; restoring this fix makes them pass. - Live operational acceptance on the affected installation after dependency restoration: embedded daemon healthy, PostgreSQL connected, retain accepted, exact nonce recalled on first structured recall, disposable bank deleted, client closed. ## Related - Follow-up to #72363 - Related to #7718 - Compatible with #81530",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85409",
          "createdAt": "2026-08-13T16:06:29Z",
          "updatedAt": "2026-08-13T17:30:01Z",
          "timestamp": "2026-08-13T17:30:01Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "tool/memory",
            "P3"
          ],
          "author": "neilinger",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:eac35829ab6e5d791a58",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85415",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [
          "updatedAt",
          "metrics",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85415",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(security): block writes to sensitive paths in managed-files API",
          "text": "## Summary The dashboard's managed-files API protected sensitive files (`.env`, `config.yaml`, credential stores) on the **read** side only — the **write** endpoints (`/api/files/upload`, `/api/files/upload-stream`, `/api/files/mkdir`, delete) would happily **overwrite** those same files. Security gap. ## Change (fail-closed at the chokepoint) - `hermes_cli/web_server.py::_resolve_managed_path()`: added `if for_write and _is_sensitive_path(resolved): raise HTTPException(403, \"Cannot write to a sensitive path\")`. The check uses the **post-symlink-resolution** path, so aliases/symlinks pointing at `.env`/`config.yaml` are also blocked. Covers upload, upload-stream, mkdir (already passed `for_write=True`). - `delete_managed_file()`: now passes `for_write=True` → destructive deletes also refuse sensitive paths (404 semantics preserved). - `_is_sensitive_path()` docstring updated (no longer claims \"read-side only\"). ## Verification - `tests/hermes_cli/test_web_server_files.py`: **10 passed** (4 new: upload .env → 403, upload-stream config.yaml → 403, mkdir sensitive → 403, symlink-to-.env → 403). Normal file ops unchanged. Closes #85387",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85415",
          "createdAt": "2026-08-13T16:10:09Z",
          "updatedAt": "2026-08-13T17:29:24Z",
          "timestamp": "2026-08-13T17:29:24Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/security",
            "area/auth",
            "P3",
            "sweeper:risk-security-boundary",
            "comp/dashboard"
          ],
          "author": "webtecnica",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:31e449654a9d27759d14",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85421",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [
          "updatedAt",
          "metrics",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85421",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(telegram): rebind TypeHandler in the deferred SDK import (fixes #85272)",
          "text": "Root cause for #85272, plus a three-line fix. ## What happens `check_telegram_requirements()` re-imports python-telegram-bot after a lazy install and rebinds the module-level aliases that the top-level `except ImportError` block set to `typing.Any`. **`TypeHandler` is missing from all three places**: the `global` declaration, the `from telegram.ext import (...)` list, and the assignment block. It is set to `Any` on line 265 along with everything else, so it is the one alias never restored. Whenever the top-level import fails and the deferred path runs, every other alias is restored and `TELEGRAM_AVAILABLE` flips to `True` — while `TypeHandler` stays `Any`. The adapter then instantiates it while registering handlers and raises `TypeError: Any cannot be instantiated`: ``` WARNING [Telegram] Discovering Telegram API fallback IPs via DNS-over-HTTPS… ERROR [Telegram] Failed to connect to Telegram: Any cannot be instantiated WARNING Gateway started with no connected platforms — 1 platform(s) queued for retry ``` The 22.6 → 22.8 pin bump named in #85272 is the trigger, not the defect: it makes the top-level import fail, which is what sends the module down the deferred path where the missing rebinding has always been. ## Repro macOS 15.5 (Apple Silicon), Hermes v0.20.0, python-telegram-bot 22.8, CPython 3.11. 1. Configure a Telegram gateway with a valid bot token 2. `hermes gateway run` Network and credentials are not involved — `getMe` against the same token returns `ok: true`, and `api.telegram.org` resolves and connects from the same host. With the patch applied the gateway connects and long-polls normally; Telegram then reports `Conflict: terminated by other getUpdates request` to any second poller, confirming the adapter holds the connection. ## Note The three sibling aliases in the same block (`Update`, `Bot`, `Message`, `Application`, `HTTPXRequest`, …) are all handled correctly — this is an omission for one name rather than a design problem, so the patch just completes the existing pattern.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85421",
          "createdAt": "2026-08-13T16:19:30Z",
          "updatedAt": "2026-08-13T17:29:03Z",
          "timestamp": "2026-08-13T17:29:03Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "platform/telegram",
            "P3"
          ],
          "author": "lesyuk",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:61c1f0df7fa831f28a1d",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85428",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85428",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix: reset SSL ciphers to DEFAULT for WeChat CDN handshake (#84394)",
          "text": "## What does this PR do? WeChat's CDN (novac2c.cdn.weixin.qq.com) rejects Python's default restrictive cipher list with SSLV3_ALERT_HANDSHAKE_FAILURE, causing all media file downloads (documents, images) to fail. Text messages work fine because they don't hit the CDN. The root cause is in gateway/platforms/weixin.py::_make_ssl_connector(). It creates an ssl.create_default_context() but never resets the cipher list. Python's default context negotiates a restrictive set of ciphers that WeChat's CDN doesn't support. curl and other tools use OpenSSL's DEFAULT cipher list and connect fine. The fix adds ssl_ctx.set_ciphers(\"DEFAULT\") after creating the context. This resets to OpenSSL's default cipher list (the same one curl uses) without weakening certificate verification — the CA bundle from certifi is still used for verification. The try/except guards against environments where the DEFAULT alias isn't available. The issue reporter verified this exact fix works: after adding set_ciphers(\"DEFAULT\"), aiohttp connects to novac2c.cdn.weixin.qq.com successfully. ## Related Issue Fixes #84394 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Testing - Added TestWeixinSSLCipherFix::test_ssl_connector_sets_default_ciphers regression test that verifies the SSL context has ciphers available after set_ciphers(\"DEFAULT\") is called. - All 31 existing weixin tests still pass.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85428",
          "createdAt": "2026-08-13T16:40:51Z",
          "updatedAt": "2026-08-13T17:28:38Z",
          "timestamp": "2026-08-13T17:28:38Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "platform/wecom",
            "P2",
            "sweeper:risk-message-delivery"
          ],
          "author": "rkfshakti",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:3e99498835c73a164449",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84925",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [
          "text",
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84925",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway): honor per-platform session isolation overrides",
          "text": "## Summary - honor per-platform `group_sessions_per_user` and `thread_sessions_per_user` session-key overrides - preserve nested `<platform>.extra` values while loading config - keep authorization, persisted `/resume` scoping, Discord prospective-thread attribution, and sender attribution aligned with the same effective per-platform isolation policy ## Test plan - `scripts/run_tests.sh tests/gateway/test_session.py tests/gateway/test_resume_command.py tests/gateway/test_config.py tests/gateway/test_whatsapp_text_batching.py tests/gateway/test_whatsapp_group_gating.py -q` - `ruff check gateway/config.py gateway/run.py gateway/session.py gateway/slash_commands.py tests/gateway/test_config.py tests/gateway/test_session.py tests/gateway/test_resume_command.py` - `git diff --check` Adapted from #81208 while preserving original authorship in the first two commits.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84925",
          "createdAt": "2026-08-13T00:53:25Z",
          "updatedAt": "2026-08-13T17:27:53Z",
          "timestamp": "2026-08-13T17:27:53Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "area/config",
            "P2",
            "sweeper:risk-session-state",
            "sweeper:risk-compatibility"
          ],
          "author": "Diaspar4u",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:334c9616ebe17b7da8e4",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84926",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [
          "text",
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84926",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(whatsapp): observe authorized group context before response",
          "text": "## Summary - optionally retain authorized unmentioned WhatsApp group traffic in the shared transcript - invoke the agent only when trusted native WhatsApp mention metadata targets the bot - keep observed chatter context-only on later addressed turns - canonicalize Baileys device-qualified IDs so native bot mentions match reliably - bypass plain-text heuristics, wake-word regexes, slash commands, quoted replies, and free-response exemptions in observation mode - check authorization and group allowlists before persistence - retain sender attribution, cached attachment paths, and native mention metadata ## Test plan - `scripts/run_tests.sh tests/gateway/test_session.py tests/gateway/test_resume_command.py tests/gateway/test_config.py tests/gateway/test_whatsapp_group_gating.py tests/gateway/test_whatsapp_formatting.py tests/gateway/test_whatsapp_text_batching.py tests/gateway/test_whatsapp_cloud.py tests/gateway/test_75349_whatsapp_multiplex_secret_scope.py tests/gateway/test_replay_entry_fields.py tests/gateway/test_telegram_group_gating.py -q` - `ruff check gateway/config.py gateway/run.py gateway/session.py gateway/slash_commands.py gateway/platforms/whatsapp_common.py plugins/platforms/whatsapp/adapter.py tests/gateway/test_config.py tests/gateway/test_session.py tests/gateway/test_resume_command.py tests/gateway/test_whatsapp_group_gating.py` - `node --check scripts/whatsapp-bridge/bridge.js` - `node scripts/whatsapp-bridge/bridge.native.test.mjs` - `git diff --check` ## Dependency Stacked on #84925. Review and merge #84925 first; this PR will be rebased onto `main` after it merges.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84926",
          "createdAt": "2026-08-13T00:54:03Z",
          "updatedAt": "2026-08-13T17:27:52Z",
          "timestamp": "2026-08-13T17:27:52Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/feature",
            "comp/gateway",
            "comp/plugins",
            "platform/whatsapp",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility"
          ],
          "author": "Diaspar4u",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:ba23f2e1ee09a5469d30",
        "signalId": "github:NousResearch/hermes-agent:pull_request:72638",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:72638",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(openai): configure Responses verbosity",
          "text": "## What does this PR do? Adds first-class `agent.text_verbosity` configuration for OpenAI Responses requests. Valid values are `low`, `medium`, and `high`; empty, unset, or malformed values preserve the provider default. The setting is emitted as top-level `text.verbosity` only for supported GPT-5 requests through Codex OAuth or the exact `api.openai.com` host. It is not sent to chat-completions, xAI/Grok, GitHub Models, custom endpoints, non-GPT models, or unrelated providers. This is a corrected continuation of #63543, not an independent rewrite. Its original two commits and author attribution are preserved. The follow-up commit aligns the config key with #20203, handles malformed config safely, and fails closed across provider-routing edge cases. ## Related Issue Fixes #20203 Related implementations reviewed while preparing this version: #20258, #29574, #59844, #63543. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] 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 - Added centralized parsing and provider capability checks in `agent/text_verbosity.py` and agent initialization. - Merged `text.verbosity` into Responses requests without replacing `request_overrides`, `service_tier`, or sibling `text` fields. - Added Responses adapter validation and gateway cache invalidation for config changes. - Documented the new setting in `cli-config.yaml.example` and the configuration guide. - Added transport, initialization, adapter, config, and gateway-cache regression coverage. ## How to Test 1. Run the focused Responses/config/cache suite: ```bash python -m pytest -q \\ tests/agent/transports/test_codex_transport.py \\ tests/agent/test_codex_responses_adapter.py \\ tests/run_agent/test_run_agent_codex_responses.py \\ tests/hermes_cli/test_config.py \\ tests/gateway/test_agent_cache.py ``` 2. Run the initialization and provider-impact coverage: ```bash python -m pytest -q tests/run_agent/test_run_agent.py -k TextVerbosityConfig python -m pytest -q \\ tests/run_agent/test_provider_parity.py \\ tests/run_agent/test_strict_api_validation.py \\ tests/honcho_plugin/test_async_memory.py \\ tests/honcho_plugin/test_session.py ``` 3. Set the configuration below and confirm a GPT-5 OpenAI Responses request contains `text: {\"verbosity\": \"low\"}`. Confirm the field is omitted when unset and for xAI/Grok, GitHub Models, custom endpoints, non-GPT models, and chat-completions requests. ```yaml agent: text_verbosity: low ``` Tested on macOS 26.5.2 with Python 3.13.11. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't an unnoticed duplicate - [x] My PR contains only changes related to this feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass (focused and impact suites pass; a broader run encountered six unrelated failures reproducible on current `main` in credential/Anthropic tests) - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 26.5.2, Python 3.13.11 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - configuration guide updated - [x] I've updated `cli-config.yaml.example` because this adds a config key - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows - N/A - [x] I've considered cross-platform impact (Windows, macOS) - no platform-specific behavior - [x] I've updated tool descriptions/schemas if I changed tool behavior - N/A ## Screenshots / Logs N/A - this changes request construction and configuration rather than UI.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/72638",
          "createdAt": "2026-07-27T12:12:28Z",
          "updatedAt": "2026-08-13T17:26:47Z",
          "timestamp": "2026-08-13T17:26:47Z",
          "metrics": {
            "reactions": 0,
            "comments": 7
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/cli",
            "comp/gateway",
            "provider/openai",
            "area/config",
            "P3",
            "needs-decision",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility",
            "sweeper:risk-caching",
            "sweeper:blast-contained"
          ],
          "author": "Diaspar4u",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:77dff2e163df4e9d3625",
        "signalId": "github:NousResearch/hermes-agent:pull_request:74823",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:74823",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(buzz): discover newly joined channels dynamically",
          "text": "## What does this PR do? Fixes two gaps in Buzz channel discovery: 1. When no explicit channel allowlist is configured, startup now lists only channels where the agent is a member. 2. A WebSocket membership event now discovers and subscribes to newly joined ordinary channels, not only new DM conversations. An explicit `BUZZ_CHANNELS` configuration remains a strict allowlist for ordinary channels. The membership cursor is captured before the startup member-channel snapshot, so a join racing with channel seeding is covered by either the snapshot or the WebSocket subscription overlap. If member-channel rediscovery fails transiently, the handler preserves the previous cursor and raises into the existing WebSocket reconnect loop instead of silently consuming the only membership trigger. ## Related Issue No linked issue. Reproduced against current `main`. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `plugins/platforms/buzz/adapter.py` - Use `channels list --member` for automatic startup discovery. - Capture the membership cursor before the startup channel snapshot. - Rediscover joined ordinary channels when a membership event arrives. - Subscribe immediately from the membership event timestamp with the existing one-second inclusive overlap. - Preserve explicit channel allowlists. - Reconnect and retry when member-channel discovery fails transiently. - `tests/gateway/test_buzz_adapter.py` - Verify startup excludes unjoined channels. - Verify the membership cursor precedes the startup snapshot. - Verify live subscription to newly joined ordinary channels. - Verify explicit channel allowlists remain strict. - Verify transient discovery failure preserves the cursor for retry. ## How to Test 1. Run `pytest tests/gateway/test_buzz_adapter.py -q`. Expected: `27 passed`. 2. Run `ruff check plugins/platforms/buzz/adapter.py tests/gateway/test_buzz_adapter.py`. Expected: `All checks passed!` 3. Run `git diff --check`. Expected: no output and exit status 0. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains only changes related to this fix - [ ] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on Linux The full repository test suite has not been run locally. The complete focused Buzz adapter test file passes. ### Documentation & Housekeeping - [x] Documentation update: N/A; no user-facing configuration changed - [x] `cli-config.yaml.example`: N/A; no config keys changed - [x] `CONTRIBUTING.md` or `AGENTS.md`: N/A; no architecture or workflow changed - [x] Cross-platform impact considered; the change uses existing Python and CLI paths - [x] Tool descriptions/schemas: N/A ## Screenshots / Logs ```text 27 passed in 0.30s All checks passed! ```",
          "url": "https://github.com/NousResearch/hermes-agent/pull/74823",
          "createdAt": "2026-07-30T12:35:51Z",
          "updatedAt": "2026-08-13T17:24:47Z",
          "timestamp": "2026-08-13T17:24:47Z",
          "metrics": {
            "reactions": 0,
            "comments": 6
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "P3",
            "needs-decision",
            "sweeper:risk-message-delivery",
            "sweeper:blast-moderate"
          ],
          "author": "cmyk",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:b95ea87960724a2f419e",
        "signalId": "github:NousResearch/hermes-agent:issue:71760",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:71760",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "hermes cron run reports 'Ran now: failed.' for a run whose agent session completed cleanly",
          "text": "**Version:** Hermes Agent v0.19.0 (2026.7.20), upstream 0ce9022e, macOS. **What happened** Created a one-shot agent job and triggered it manually: ``` hermes cron add \"2027-01-01T00:00:00Z\" \"<prompt>\" --name probe --skill my-local-skill --deliver local --repeat 1 hermes cron run <job-id> # => Triggered job: <job-id> (<job-id>) # => Ran now: failed. ``` But the run actually succeeded. `agent.log` for the spawned cron session shows a clean single-turn completion: ``` INFO [cron_<id>_…] agent.conversation_loop: API call #1: … in=14342 out=213 … INFO [cron_<id>_…] agent.conversation_loop: Turn ended: reason=text_response(finish_reason=stop) … response_len=947 … ``` and the session export contains the full, correct assistant answer. The one-shot job then removed itself as expected. **Guesses** Either the 'failed' verdict conflates delivery status with run status for `--deliver local` manual triggers, or the one-shot job's self-removal races the status read after the run. Both fit the observed 'failed-but-actually-fine' output. **Expected** `cron run` should report the run's real outcome (and, if delivery is what failed, say 'run ok, delivery failed: <reason>'). A false 'failed.' sends the operator hunting a healthy pipeline.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/71760",
          "createdAt": "2026-07-26T04:36:34Z",
          "updatedAt": "2026-08-13T17:23:38Z",
          "timestamp": "2026-08-13T17:23:38Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/cron",
            "P2"
          ],
          "author": "optionshift",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:957f34f29e82ab6b521c",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85317",
        "event": "changed",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85317",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(mcp): skip startup discovery for disabled servers",
          "text": "## Summary - Treat disabled native MCP server entries as inactive configuration. - Prevent startup discovery from opening when all configured servers are disabled. - Preserve agent-plugin MCP detection. ## Test plan - `uv run --with pytest python -m pytest -q tests/hermes_cli/test_mcp_startup.py` - Result: 4 passed, 1 warning. The change is based directly on the current upstream `main`.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85317",
          "createdAt": "2026-08-13T13:28:19Z",
          "updatedAt": "2026-08-13T17:23:37Z",
          "timestamp": "2026-08-13T17:23:37Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "tool/mcp",
            "area/config",
            "P3",
            "sweeper:risk-compatibility"
          ],
          "author": "Wahrheitssuchender",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:b4a7ddf794dfee32c1b0",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85456",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85456",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(slack): add channel approval gate",
          "text": "## Summary - add an opt-in Slack shared-channel approval gate before normal message dispatch, including Slack slash-command dispatch - store local approval/pending/revoked channel state per Hermes profile with team-scoped keys where Slack team IDs are available - honor configured approval owners/agent aliases with exact `approve <agent> here` / `revoke <agent> here` phrases - document the new Slack config/env settings and cover approval, revoke, exact command, slash-command interception, team scoping, and config bridge behavior in tests ## Test Plan - `scripts/run_tests.sh tests/gateway/test_slack_mention.py tests/gateway/test_slack.py tests/gateway/test_config.py -q` - `git diff --check` - `python3 -m py_compile plugins/platforms/slack/adapter.py tests/gateway/test_slack_mention.py tests/gateway/test_config.py gateway/config.py hermes_cli/config_defaults.py`",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85456",
          "createdAt": "2026-08-13T17:11:50Z",
          "updatedAt": "2026-08-13T17:23:36Z",
          "timestamp": "2026-08-13T17:23:36Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/gateway",
            "comp/plugins",
            "platform/slack",
            "area/auth",
            "P3",
            "sweeper:risk-compatibility"
          ],
          "author": "psc239",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c33e9fc2646f02369d71",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85459",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85459",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(providers): add OpenCode Free provider with keyed auth and opencode User-Agent",
          "text": "## Summary Adds an **OpenCode Free** model-provider plugin. Free model discovery uses models.dev (`cost.input == 0 AND status != \"deprecated\"`), matching the opencode CLI's exact filter logic. The free tier requires a real account API key and throttles third-party clients by User-Agent (requests that don't identify as the opencode client get `429 FreeUsageLimitError`; missing/anonymous keys get `401 AuthError`): - **With `OPENCODE_FREE_API_KEY` configured** — the key is sent as a Bearer token and requests identify as `opencode/latest`. - **Without a key** — the keyless fallback strips the OpenAI SDK's always-injected empty `Authorization` header and still sends the opencode User-Agent. - The credential resolver no longer blanks `OPENCODE_FREE_API_KEY` unconditionally (stale keyless-tier assumption), and credential-pool exhaustion no longer surfaces the misleading `Set OPENCODE_FREE_API_KEY` message. ## Files - `plugins/model-providers/opencode-free/` — provider plugin + manifest - `hermes_cli/auth.py` — provider registry entry, alias, credential resolver - `hermes_cli/runtime_provider.py` — keyless fallback for opencode-free - `agent/agent_runtime_helpers.py` — auth keep/strip + opencode User-Agent - `hermes_cli/models.py`, `hermes_cli/model_setup_flows.py`, `hermes_cli/providers.py`, `hermes_cli/main.py`, `hermes_cli/setup.py` - tests: `tests/agent/test_opencode_free_provider.py`, `tests/run_agent/test_opencode_free_client_headers.py`, `tests/hermes_cli/test_runtime_provider_resolution.py` ## Verification - 191 tests pass (provider plugin, client-header regression, runtime resolution, API-key providers). - E2E: real `resolve_runtime_provider` → `AIAgent` → `chat.completions.create` against opencode.ai returns HTTP 200 with `Bearer sk-…` + `User-Agent: opencode/latest`; keyless path sends no Authorization header but keeps the opencode User-Agent. Co-authored-by: Jean-François <jfm@laposte.net>",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85459",
          "createdAt": "2026-08-13T17:14:35Z",
          "updatedAt": "2026-08-13T17:23:28Z",
          "timestamp": "2026-08-13T17:23:28Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/cli",
            "comp/plugins",
            "area/auth",
            "P3",
            "sweeper:risk-security-boundary"
          ],
          "author": "rudrakshchahal",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:bed1c2a12a0f9ad618b6",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85460",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85460",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(search): guide empty timed-out searches",
          "text": "## Summary - keep existing partial-result behavior for timed-out file searches - add explicit path/file_glob scoping guidance when a timeout emitted no usable results - cover file-name and all content output modes ## Verification - `scripts/run_tests.sh tests/tools/test_search_budget_truncation.py tests/tools/test_search_error_guard.py tests/tools/test_search_zero_match_and_multipath.py` (34 passed) - `.venv/bin/ruff check tools/file_operations.py tests/tools/test_search_budget_truncation.py` - real home-wide ShellFileOperations search returned `limit_reason: search_timeout` plus scoping warning",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85460",
          "createdAt": "2026-08-13T17:14:46Z",
          "updatedAt": "2026-08-13T17:23:24Z",
          "timestamp": "2026-08-13T17:23:24Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/tools",
            "tool/file",
            "P3"
          ],
          "author": "ajoslin",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:b67bfaeeaec82fbe7d78",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85461",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85461",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(enterprise): control-plane core — resource model, store, audit, driver contracts",
          "text": "## Summary Adds the foundation of **Hermes Enterprise**: a standalone control-plane core (resource model, fail-closed resource store, audit log, and driver contracts) for deploying and operating fleets of Hermes agents multi-tenant on Kubernetes — with the hermes-agent runtime as the Harness. Part of the one-wave Enterprise draft series; this PR defines the contracts every sibling PR builds against (`ent/iam`, `ent/oag`, `ent/controller`, `ent/compute-k8s`, `ent/sandbox`, `ent/secrets`, `ent/deploy`, `ent/docs`). ## Changes - `enterprise/resources.py`: v1 resource model — `Namespace`, `Configuration`, `Agent`, `AgentRevision` (immutable snapshot), `Harness`, `Channel`, `Secret`/`SecretBroker` (never carry values), `SandboxPolicy`, `Restriction` (narrows, never grants). DNS-1123 names so resources back K8s objects 1:1; per-kind spec validation; secret-shape detector rejects embedded credentials in configs. - `enterprise/store.py`: SQLite store enforcing unique identity, namespace containment, same-namespace reference resolution (cross-namespace refs denied), optimistic concurrency via generations, AgentRevision spec immutability, dependent-blocking deletes + namespace drain. - `enterprise/audit.py`: append-only attributable audit log; refuses secret-like payloads at write time. - `enterprise/contracts.py`: `ComputeDriver` / `SandboxDriver` / `SecretDriver` / `IAMAdapter` / `IdentityVerifier` ABCs + single-selection `DriverRegistry` (unselected capability fails closed; no self-selection). Standalone package: nothing in the core agent imports it, zero model tools, no runtime coupling, stdlib only. ## Validation | | Result | |---|---| | `tests/enterprise/test_core.py` | 31/31 pass | | ruff | clean | | Core-agent footprint | zero (no imports from agent code) | ## Infographic ![Hermes Enterprise control plane core](https://files.catbox.moe/hk10pj.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85461",
          "createdAt": "2026-08-13T17:15:13Z",
          "updatedAt": "2026-08-13T17:23:21Z",
          "timestamp": "2026-08-13T17:23:21Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "P3",
            "needs-decision"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:b7164fe874b24483fe31",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85462",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85462",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "docs(delegation): document delegate_task live orchestration actions",
          "text": "## Summary Docs for #85232: the delegation feature page now documents the model-facing `delegate_task` control actions (`action='list'/'steer'/'stop'`) alongside the existing TUI/gateway `subagent.steer` RPC section. ## Changes - `website/docs/user-guide/features/delegation.md`: \"Steering a Running Subagent\" split into model-facing (delegate_task action param, ownership scoping, spawn-cap exemption, partial-result-on-stop) and session-facing (existing RPC content, unchanged) subsections. ## Validation Docs-only; content matches the merged behavior in #85232 (live-tested there). ## Infographic Docs companion to #85232 — infographic lives on the feature PR: https://files.catbox.moe/j8qzff.png",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85462",
          "createdAt": "2026-08-13T17:16:28Z",
          "updatedAt": "2026-08-13T17:23:16Z",
          "timestamp": "2026-08-13T17:23:16Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/docs",
            "tool/delegate",
            "P3"
          ],
          "author": "teknium1",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:95c4ce9ab03ce53cd5c4",
        "signalId": "github:NousResearch/hermes-agent:issue:85458",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85458",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Fail closed on duplicate plugin identities and attest the loaded source",
          "text": "## Problem Hermes core owns directory and entry-point discovery, collision precedence, and module loading. A plugin-side preflight can validate its own source tree and refuse similarly prefixed sibling directories, but it cannot prove which manifest/module the live PluginManager selected. A generic unsafe case is two enabled directory candidates with different directory keys but the same manifest identity, such as a canonical checkout beside a preserved rollback checkout. Key-based winner selection does not provide an exact source contract for the manifest identity, and plugin hooks can run without a live-bound, read-only source attestation. ## Bounded core requirement Please add a core-owned fail-closed contract that: - rejects more than one enabled candidate for the same manifest plugin identity before any candidate registers hooks, commands, tools, or middleware; - supports an exact configured canonical source directory and immutable expected build identity/revision for a plugin, or otherwise exposes an equivalent deterministic selector; - exposes a public read-only diagnostic/API bound to the actually loaded module, not merely current disk state, with bounded match/mismatch results suitable for pre-start and live verification; - distinguishes directory and entry-point sources without silently applying source-order precedence when an exact source was required; - never mutates, deletes, renames, updates, restarts, or disables a candidate during diagnosis. ## Regression coverage - canonical directory plus a similarly prefixed rollback/backup sibling is rejected deterministically; - duplicate manifest identities across directory and entry-point sources are rejected when exact selection is configured; - the loaded-source result stays bound to the module bytes/path selected at load time even if disk state changes later; - diagnostics do not include manifest payloads, credentials, profile state, session data, or unrelated filesystem paths. This is intentionally a Hermes-core issue: a plugin cannot safely override or attest the discovery decision that loaded it.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85458",
          "createdAt": "2026-08-13T17:12:30Z",
          "updatedAt": "2026-08-13T17:23:09Z",
          "timestamp": "2026-08-13T17:23:09Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/plugins",
            "P3",
            "needs-decision"
          ],
          "author": "yuvalfis",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:1e0777db447dbb0ff07d",
        "signalId": "github:NousResearch/hermes-agent:pull_request:37684",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:37684",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(model_metadata): drop stale cache entries for qwen3.6-plus, grok-4-fast, grok-4.20",
          "text": "## Summary Three model families gained explicit `DEFAULT_CONTEXT_LENGTHS` entries after some users may have already cached lower (incorrect) values through earlier resolution paths. This PR adds stale-cache guards for each, mirroring the pattern used for Kimi (≤32K), MiniMax-M3 (≤204,800), and Grok-4.3 (≤256K) in prior PRs. ### qwen3.6-plus (1M context) The `qwen3.6-plus` entry was added on 2026-05-17 (`d9abbe7fa`). Prior builds resolved it via the generic `qwen` catch-all (131,072) and could have persisted that value via the context-overflow cache path. Guard threshold: `cached ≤ 131,072`. ### grok-4-fast and grok-4.20 (2M context each) Both entries were added on 2026-04-10 (`b57769718`). Before that date, these slugs had no explicit entry and fell through to `DEFAULT_FALLBACK_CONTEXT` (256,000) or lower probe tiers. Guard threshold: `cached ≤ 256,000`. ## Changes - `agent/model_metadata.py`: add `_model_name_suggests_qwen3_6_plus()` and `_model_name_suggests_grok_4_fast_or_4_20()` helper predicates; add three `elif` branches in the step-1 stale-cache guard block inside `get_model_context_length()`. - `tests/agent/test_model_metadata.py`: add `TestQwen36PlusStaleCacheGuard` and `TestGrokFastAndGrok420StaleCacheGuard` (12 tests total) — helper unit tests, stale-drop-and-re-resolve, correct-cache-preserved, no-clobber for sibling slugs. ## Test plan - [ ] `pytest tests/agent/test_model_metadata.py::TestQwen36PlusStaleCacheGuard tests/agent/test_model_metadata.py::TestGrokFastAndGrok420StaleCacheGuard -x -q` — 12 tests pass",
          "url": "https://github.com/NousResearch/hermes-agent/pull/37684",
          "createdAt": "2026-06-02T22:11:16Z",
          "updatedAt": "2026-08-13T17:22:20Z",
          "timestamp": "2026-08-13T17:22:20Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/xai",
            "provider/qwen",
            "P3",
            "sweeper:risk-compatibility",
            "sweeper:blast-contained"
          ],
          "author": "AhmetArif0",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:2e3d09e4c586a58818f9",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84341",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84341",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(xai): drop stale 256K grok-4.6 context cache",
          "text": "## Summary Official card ([docs.x.ai/developers/models/grok-4.6](https://docs.x.ai/developers/models/grok-4.6)): **500,000** context. [#84661](https://github.com/NousResearch/hermes-agent/pull/84661) landed the catalog. `main` already lists native `grok-4.6` on the xAI picker. This PR is **only** the leftover cache guard. Pre-catalog builds resolved `grok-4.6` via the `grok-4` catch-all (**256K**) and persisted it. After the catalog land the table is 500K, but a leftover cache entry still wins. Same pattern as the existing grok-4.3 guard. ## Not in this PR - No catalog restatement - No picker / extras restatement (already on `main`) - No `xhigh` special-case - No tool-default flip `4.5` → `4.6` ## Test plan - [x] `uv run --extra dev pytest tests/agent/test_model_metadata.py::TestGrok46StaleCacheGuard tests/agent/test_model_metadata.py::TestGrok43StaleCacheGuard tests/agent/test_reasoning_stale_timeout_floor.py -q` → 40 passed - [ ] CI green",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84341",
          "createdAt": "2026-08-12T06:41:31Z",
          "updatedAt": "2026-08-13T17:22:18Z",
          "timestamp": "2026-08-13T17:22:18Z",
          "metrics": {
            "reactions": 0,
            "comments": 5
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "comp/cli",
            "provider/xai",
            "P2",
            "sweeper:risk-compatibility"
          ],
          "author": "Julientalbot",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:2781a74b7831c2618967",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85434",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85434",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(xai): drop stale 256K grok-4.6 context cache + generalize pre-catalog guard",
          "text": "## Summary Users who ran `grok-4.6` before its 500K catalog entry landed keep a stale persisted 256K context length forever — this drops that leftover so it re-resolves, and generalizes the guard so the whole class (MiniMax-M3, Grok-4.3/-4.6/-4-fast/-4.20, qwen3.6-plus) is covered by one rule instead of per-model patches. Salvages #84341 by @Julientalbot (cherry-picked, authorship preserved), then widens per the never-patch-predicates rule; also absorbs the guards requested in #37684 by @AhmetArif0. ## Changes - `agent/model_metadata.py`: cherry-picked grok-4.6 guard (@Julientalbot), then replaced the three per-model `_model_name_suggests_*` stale-cache predicates with one generic `_stale_pre_catalog_cache_entry()` driven by `_PRE_CATALOG_STALE_KEYS`. A cached value is dropped only when the model resolves (longest-key-first, same as step 8) to a listed catalog key AND the cached value is at or below what the old resolution path could have produced (largest shorter matching catch-all, or the 256K fallback). Probe-derived values above that threshold are never dropped. `_model_name_suggests_minimax_m3` kept for its two non-cache callers. - `tests/agent/test_model_metadata.py`: contributor's `TestGrok46StaleCacheGuard` E2E test preserved; predicate unit tests migrated to the generic guard; new `TestGenericPreCatalogStaleGuard` covering qwen3.6-plus / grok-4-fast / grok-4.20 incl. an E2E stale-drop-and-re-resolve. ## Validation | | Result | |---|---| | `tests/agent/test_model_metadata.py` + `test_minimax_provider.py` | 93/93 pass | | Sabotage run (guard disabled) | 3 E2E tests fail as expected, restore → green | | Stale-base gate | 0 behind origin/main, 2-file diff | ## Infographic ![Stale context cache guard — one generic rule replaces per-model patches](https://files.catbox.moe/wmwc6u.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85434",
          "createdAt": "2026-08-13T16:43:50Z",
          "updatedAt": "2026-08-13T17:21:53Z",
          "timestamp": "2026-08-13T17:21:53Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/xai",
            "P3"
          ],
          "author": "teknium1",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:b631e2df31ced1d756d0",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85452",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85452",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(honcho): isolate clients per profile, resolve baseUrl/apiKey, enforce saveMessages containment",
          "text": "## Summary Consolidates and salvages three Honcho plugin PRs by @erosika (#83500, #83508, #83525) onto latest main, with review follow-up fixes applied on top. All three address distinct bug classes in the Honcho memory plugin that cause silent data corruption in multi-profile/self-hosted setups. ## What this PR makes true The Honcho plugin correctly isolates per-profile clients, resolves self-hosted baseUrl/apiKey configs, and respects saveMessages=false — fixing cross-tenant memory bleed, silent cloud routing, and ignored write containment. ## Changes ### From PR #83525 — client identity isolation (closes #69123, #74065) - Replaces process-wide client singleton with per-identity caching (host, workspace, base_url, environment, provenance, timeout, credential fingerprint) - Configs capture provenance (config_path, hermes_home) at resolution time - `spawn_context_thread()` carries contextvars into all 9 plugin daemon threads - Credential fingerprint hashes OAuth refresh token (stable across access-token rotation, changes on re-auth) - Stale same-identity slots evicted on replacement - Path-keyed timeout memo (was single-slot, thrashed between profiles) - MemoryManager background dispatch wraps with caller's contextvars (general fix for ALL memory providers) - **Follow-up fix:** replaced unreadable double-lambda with `functools.partial(ctx.run, fn)` - **Follow-up fix:** `from_env()` now sets `config_path` so `bound_config_path()` doesn't re-resolve from ContextVar on daemon threads ### From PR #83508 — baseUrl/apiKey resolution (closes #43800, #37436, #76414; addresses #36098, #66125) - Reads `endpoint.baseUrl` (SDK-native, what Claude Desktop writes) - Accepts `HONCHO_URL` env var as fallback - Host-block baseUrl takes precedence over flat root - Uses `_host_block()` for dot-form legacy host key fallback in local-auth check - Honors top-level `apiKey` for local URLs - Warns loudly when keyless profile doesn't inherit default host's key - `raise_errors=True` in `dialectic_query` for explicit tool calls - `_sanitize_url()` drops non-printable chars before client init - Peers command uses `profile_host_key()` (underscore form) ### From PR #83500 — saveMessages containment (closes #35209) - Gates all four automatic write paths on `saveMessages` (sync_turn, on_memory_write, on_session_end, shutdown) - Rejects machine-generated gateway notifications from being persisted as user turns - Routes `sync_turn` through `manager.save()` so `writeFrequency` batching applies - Provider shutdown calls `manager.shutdown()` (flush + join writer thread) or `manager.stop_async_writer()` (join only, no flush) - Owner-gates memory-file migration so non-owner users in shared channels don't get the owner's MEMORY.md/USER.md - **Follow-up fix:** tracks and joins the `honcho-memwrite` thread in `shutdown()` (was fire-and-forget, the exact problem the PR fixes for the async writer) ## Attribution Adopted with original authorship preserved. Contributors credited: - @dtownsel — saveMessages gates on sync_turn/on_memory_write + gateway-internal-turn rejection (#82130) - @strzhao, salvaging @Matroskin86 — saveMessages gates on session-end + shutdown flush (#81214, #67559) - @menhguin — non-owner migration skip (#82038) - @cfdude — endpoint.baseUrl + HONCHO_URL + fallback logging (#43803) - @LeonSGP43 — host-block baseUrl precedence (#14489) - @Morad37 — _host_block dot-form fallback (#37671) - @Bartok9, salvaging @teyrebaz33 — non-printable base_url sanitization (#62757, #2757) - @spfcraze — peers command underscore host keys (#76455) - @NaMinhyeok, @angel12 — per-identity cache approach (#69142, #81401) ## Validation | | Before | After | |---|---|---| | Tests | 0 honcho isolation tests | 363 passed across honcho_plugin/, test_honcho_*, test_honcho_client_config, test_honcho_cli_peers | | E2E smoke | N/A | 9 combined smoke tests passed (saveMessages gate, gateway regex, memwrite tracking, _sanitize_url, endpoint.baseUrl, provenance, bound path, from_env config_path) | | ruff | — | clean | | Cross-tenant bleed | #69123 live | per-identity cache + contextvar propagation | Closes #83500, #83508, #83525, #69123, #74065, #43800, #37436, #76414, #35209",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85452",
          "createdAt": "2026-08-13T17:05:39Z",
          "updatedAt": "2026-08-13T17:21:35Z",
          "timestamp": "2026-08-13T17:21:35Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "comp/plugins",
            "tool/memory",
            "P3",
            "needs-decision",
            "sweeper:risk-session-state",
            "area/memory"
          ],
          "author": "kshitijk4poor",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:0c7f522a3d6290c1d80a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:41440",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:41440",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(research): default tokenizer trust_remote_code to False to prevent RCE",
          "text": "## What does this PR do? Closes a critical remote-code-execution hole in the trajectory compressor. `CompressionConfig.trust_remote_code` defaulted to `True`, and that value was passed straight into `AutoTokenizer.from_pretrained(..., trust_remote_code=...)`. HuggingFace honors that flag by importing and running arbitrary Python shipped in the tokenizer repo at load time — before a single trajectory is touched. The blast radius is large because this subsystem is built around *shared* configs: it ships examples in `datagen-config-examples/`, and both the tokenizer name and the flag come unvalidated from a YAML file (or the `--tokenizer` override). Anyone who runs `python trajectory_compressor.py --config <theirs>.yaml` (or points at a malicious tokenizer repo) was one constructor call away from code execution on their machine. The same dangerous default was hardcoded in `scripts/sample_and_compress.py`. The fix makes the behavior secure-by-default, matching HuggingFace's own posture: remote code is off unless the operator explicitly opts in for a tokenizer they trust, and the opt-in can never happen silently — we print a loud warning naming the exact repo before any code runs. Legitimate opt-in (e.g. the Kimi tokenizer that genuinely needs it) still works via an explicit `trust_remote_code: true` in your own config. ## Related Issue N/A ## Type of Change - [x] 🔒 Security fix ## Changes Made - `trajectory_compressor.py`: flipped `CompressionConfig.trust_remote_code` to default `False`; `_init_tokenizer()` now emits a prominent SECURITY warning (naming the tokenizer repo) whenever the flag resolves to `True`, so remote code execution is never silent. - `scripts/sample_and_compress.py`: `_init_tokenizer_worker()` no longer hardcodes `trust_remote_code=True`; it defaults to `False` and warns when enabled. The flag is threaded through `sample_from_datasets()` and `main()` as an explicit, off-by-default option. - `datagen-config-examples/trajectory_compression.yaml`: documented the RCE risk inline so the example's explicit `true` reads as a conscious opt-in for the Kimi tokenizer rather than a normalized default. - `tests/test_trajectory_compressor.py`: added `TestTrustRemoteCodeSecurity` covering the safe default, that an omitted YAML key stays `False`, that an explicit `true` is still honored, and that `_init_tokenizer()` forwards the flag and warns loudly only when enabled. ## How to Test 1. `pytest tests/test_trajectory_compressor.py -k TrustRemoteCode -q` — the new security tests pass (5 passed). 2. `pytest tests/test_trajectory_compressor.py -q` — full file green (40 passed). 3. Sanity-check the default: `python -c \"from trajectory_compressor import CompressionConfig; print(CompressionConfig().trust_remote_code)\"` prints `False`. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin 25.5) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/41440",
          "createdAt": "2026-06-07T17:56:47Z",
          "updatedAt": "2026-08-13T17:20:02Z",
          "timestamp": "2026-08-13T17:20:02Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/security",
            "P2",
            "sweeper:risk-security-boundary",
            "sweeper:risk-compatibility",
            "sweeper:blast-contained"
          ],
          "author": "rrevenanttt",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:83a110bdedc20ac1f8d6",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85429",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85429",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): re-emit session.info when approvals config changes out of band",
          "text": "Turning YOLO on in the settings page flips the real switch: approvals stop immediately. But the status-bar pill never updates, and switching sessions repaints it from a stale cached \"off.\" The broken indicator then baits you into clicking the toggle again, which turns the bypass you already had **off**, and approvals mysteriously come back. From the user's chair the setting just doesn't stick. The pill only repaints on a `session.info` event. The `config.set` RPC re-emits one after a mode flip, but the settings page saves through REST `PUT /api/config`, which wrote `config.yaml` and emitted nothing. Enforcement follows the file per-command, so switch and indicator parted ways. The raw YAML editor and the gateway's `/approvals` slash command had the same gap. All three now get the RPC path's contract: `broadcast_session_info()` in `tui_gateway/server.py` (snapshots `_sessions` under `_sessions_lock`), called from the REST handlers via a `sys.modules` guard (a process that never imported the gateway has no sessions to notify), and only when the save actually changed the `approvals` block on the process's own profile. Bare read-only `/approvals` and unrelated autosave churn emit nothing. Scope: reaches the in-process gateway (`hermes serve` / `hermes dashboard`, the topologies the desktop app talks to). A spawned `tui_gateway.entry` child gateway is its own process; its TUI statusbar already reconciles every turn. ## Test plan - [x] `scripts/run_tests.sh tests/hermes_cli/test_web_server_approvals_broadcast.py` with 10 new contracts: broadcast on change (both REST paths, plus deleting the approvals block in the raw editor), silence on non-approvals / no-op / other-profile saves, gateway-absent path asserts no import, `/approvals` broadcasts on write but not bare read - [x] Neighbors green: 195 (web_server, config offloop, protocol) + 555 (tui_gateway server, compress lock) - [x] Root cause confirmed live pre-fix: CDP probe showed the REST save changing enforcement while `$yoloActive` stayed stale Note: the slice 9 CI failure (`test_primary_runtime_restore.py::test_allowed_for_nous_anthropic_messages`) is red on main's own push CI with the identical signature and is unrelated to this diff.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85429",
          "createdAt": "2026-08-13T16:42:21Z",
          "updatedAt": "2026-08-13T17:16:29Z",
          "timestamp": "2026-08-13T17:16:29Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/tui",
            "area/config",
            "P2",
            "sweeper:risk-compatibility"
          ],
          "author": "yoniebans",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:d48cebf23a58d46bcfd8",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85453",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85453",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "docs(telegram): document TELEGRAM_WEBHOOK_HOST and other undocumented adapter env vars",
          "text": "Fixes #78697. `TELEGRAM_WEBHOOK_HOST` is read in `plugins/platforms/telegram/adapter.py` and controls the webhook bind host — critical for IPv6-only deployments (Fly.io 6PN) — but appeared nowhere in `website/docs/`. A scan of the adapter shows it reads ~22 `TELEGRAM_*` and `HERMES_TELEGRAM_*` env vars, of which several were undocumented. This PR adds: - `TELEGRAM_WEBHOOK_HOST` to the env-var reference table **and** the Webhook Mode section of `telegram.md` (with the IPv6 rationale) - `HERMES_TELEGRAM_HTTP_POOL_SIZE` / `POOL_TIMEOUT` / `CONNECT_TIMEOUT` / `READ_TIMEOUT` / `WRITE_TIMEOUT` (PTB request tuning) - `HERMES_TELEGRAM_INIT_TIMEOUT` (bootstrap timeout) - `HERMES_TELEGRAM_DISABLE_FALLBACK_IPS` - `TELEGRAM_ALLOW_BOTS`, `TELEGRAM_GUEST_MODE`, `TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES` (with their `config.yaml` equivalents) All defaults verified against the code on `main`. Docs-only, no behavior change.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85453",
          "createdAt": "2026-08-13T17:08:03Z",
          "updatedAt": "2026-08-13T17:15:33Z",
          "timestamp": "2026-08-13T17:15:33Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/docs",
            "comp/gateway",
            "platform/telegram",
            "P3"
          ],
          "author": "Luna161",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:1051fec5db51f82469bc",
        "signalId": "github:NousResearch/hermes-agent:issue:76508",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:76508",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Add parent-scoped list, cancel, and steer controls for live async delegations",
          "text": "## Problem A parent agent can dispatch background work with `delegate_task` and receives a complete `deleg_...` handle, but it has no model-facing way to enumerate, cancel, or redirect that live delegation afterward. Operator surfaces such as `/agents` and the TUI can provide visibility or controls, but a user speaking to the parent through Telegram, another gateway, or the CLI cannot simply ask the parent to list, steer, or cancel its own live work through an authorized in-process path. A terminal command or skill cannot safely bridge this gap: cancellation and steering depend on in-memory callbacks owned by the running Hermes process. Exposing those callbacks without a strict parent authority boundary would create a confused-deputy control surface. ## Proposed contract Add one deferred model-facing control surface for live asynchronous delegations: ```text delegate_control(action=\"list\") delegate_control(action=\"cancel\", id=\"deleg_...\") delegate_control(action=\"steer\", id=\"deleg_...\", message=\"...\") ``` List, cancel, and steer belong together because they share authority resolution, target semantics, lifecycle races, and delegation-ID handling. ### Authority - Capture ownership only from trusted runtime provenance at dispatch. - Define an owner as `(canonical profile namespace, compression-only parent-conversation root)`. - Preserve authority across genuine context-compression continuations, while rejecting branches, delegate/subagent/tool sessions, siblings, ambiguous ancestry, cycles, and foreign profiles. - Never accept profile, owner, session, lineage, or cursor values from model arguments as authority. - Treat a complete native `deleg_<8 lowercase hex>` ID as an actionable handle, not authorization. - Return the same opaque `not_found` response for malformed, foreign, missing, terminal, and otherwise inaccessible IDs. ### List - Return only live delegations owned by the current parent. - Reuse the existing async-delegation registry rather than creating a parallel lifecycle store. - Paginate explicitly and report `total_live`, `truncated`, and `next_cursor`. - Allowlist only the complete delegation ID, live status, batch flag, dispatch time, and bounded goal/role previews. - Exclude prompts, context, results, errors, routes, models, owner/session fields, callbacks, credentials, and delivery internals. - Never claim, consume, suppress, replay, or duplicate terminal completion delivery. ### Cancel - Request cooperative interruption; do not imply hard thread termination. - Serialize cancellation and steering per live delegation. - Report `pending` while a competing control callback is in flight. - Report `accepted` only after interruption was successfully requested. - If a callback may have applied an irreversible partial interruption before raising, report a stable non-retriable `indeterminate` state rather than retrying blindly or claiming success. - Ensure session shutdown, `/stop`, and stale-lifecycle interruption cannot miss a child temporarily executing a steering callback. ### Steer - Queue bounded guidance through the existing safe steering seam. - Enforce both per-message and cumulative per-live-delegation limits at runtime. - For batches, target only active children and report `accepted` only when at least one child actually accepts while the delegation remains steerable. - Let lifecycle cancellation supersede an overlapping steer so both operations cannot report acceptance. - Preserve native completion behavior when guidance arrives too late to be consumed. ### Footprint This capability requires structured access to live in-process callbacks and cannot be implemented safely through `terminal` plus a skill. To keep the core narrow, register it in the existing `delegation` toolset while keeping it outside `_HERMES_CORE_TOOLS`, allowing normal progressive tool disclosure through `tool_search`. Parent/orchestrator conversations may receive the capability according to existing delegation-toolset policy. Leaf agents must not inherit parent control authority. ## Related work - #70899 adds TUI Mission Control visibility and live steering. This proposal is adjacent rather than duplicative: it provides a model-facing, parent-authorized surface usable from Telegram and other agent conversations. Shared accepted steering seams should be reused where practical. - #76390 is a Telegram-specific, read-only activity-panel proposal. Informational visibility remains separate from authority-bearing lifecycle control. - Existing `/agents`, subagent interruption, `/stop`, shutdown, and native terminal-result delivery remain intact. ## Acceptance criteria 1. A parent can list only its own live asynchronous delegations and receives complete native delegation IDs. 2. Authority uses trusted canonical profile provenance plus a proven compression-only parent-conversation root. 3. Genuine compression preserves authority; branches, delegate/subagent/tool sessions, siblings, cycles, ambiguous lineage, and foreign profiles do not inherit it. 4. Malformed, foreign, missing, terminal, and inaccessible targets are indistinguishable through the control API. 5. Listing is bounded, allowlisted, and explicitly paginated without exposing sensitive internals. 6. Cancellation and steering are serialized without invoking callbacks under global registry or child-lifecycle locks. 7. Cancellation callback failures cannot trigger blind retries or false success after potentially partial interruption. 8. Session/global lifecycle interruption supersedes overlapping steering and cannot leave an active child orphaned. 9. Steering enforces runtime message and cumulative budgets and targets only active batch children. 10. Terminal completion delivery remains native and independent of listing/control. 11. The tool is deferred in the existing `delegation` toolset, absent from `_HERMES_CORE_TOOLS`, and unavailable to leaf agents. 12. Focused race, authority, pagination, disclosure, lifecycle, and real temporary-`HERMES_HOME` integration tests cover the complete contract. ## Non-goals - No TUI panel, Telegram-specific command, or transport-specific control implementation. - No model-facing internal `subagent_id` API. - No hard thread termination. - No pause/resume scheduler or global spawn manager. - No terminal-result retrieval or history API. - No persistence or restart resurrection of live callbacks. - No new configuration key, environment variable, dependency, database migration, or parallel delegation registry.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/76508",
          "createdAt": "2026-08-02T01:31:31Z",
          "updatedAt": "2026-08-13T17:15:31Z",
          "timestamp": "2026-08-13T17:15:31Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/feature",
            "comp/tools",
            "tool/delegate",
            "P3",
            "needs-decision"
          ],
          "author": "0xbWy",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:17a5a51235fa93c7d091",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85449",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85449",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway): pin active-session lease to its acquire-time registry (#85431)",
          "text": "## What does this PR do? Fixes #85431: in a native multiplex gateway, an active-session lease routed to a named profile leaks its slot on release, so after `max_concurrent_sessions` routed turns every new session is rejected — *\"Hermes is at the active session limit (N/N)\"* — even though no agent turn is running. ## Root cause `try_acquire_active_session()` writes the lease entry to the registry resolved from the **root** `HERMES_HOME`. But `release_active_session()` and `transfer_active_session()` re-resolved `_state_path()` / `_lock_path()` from the *current* `HERMES_HOME`. A turn routed to a named profile runs under `_profile_runtime_scope(profile_home)`, and the agent cleanup path calls `release()` inside that scope — so the release looked under `…/profiles/<name>/runtime/active_sessions.json` instead of the root registry where the lease was created. The root entry survived (its `pid` is the still-running gateway, so pruning keeps it), the in-memory lease was marked `released=True`, and the slot was never reclaimed. The issue's deterministic repro (temp dir, no credentials) reproduces it with Hermes' own profile-override and active-session primitives. ## The fix Pin the registry to acquire time. `ActiveSessionLease` gains `state_path` / `lock_path`, set when the lease is created; `release_active_session()` and `transfer_active_session()` use those pinned paths (falling back to the live resolver only for a disabled/no-op lease). Release/transfer therefore always act on the registry the lease was written to, regardless of the `HERMES_HOME` scope active at cleanup. ## Tests `tests/hermes_cli/test_active_sessions.py`: - `test_release_under_named_profile_scope_frees_the_root_slot` — the issue's exact scenario: acquire against root, release inside a `set_hermes_home_override(profile)` scope, assert the root slot is freed and no stray profile registry was written. Verified failing before the fix, passing after. - `test_transfer_under_named_profile_scope_updates_the_root_registry` — the same pinning for `transfer_active_session()`. Affected-test, ruff, and windows-footgun gates pass locally. Fixes #85431",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85449",
          "createdAt": "2026-08-13T17:01:36Z",
          "updatedAt": "2026-08-13T17:15:29Z",
          "timestamp": "2026-08-13T17:15:29Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/gateway",
            "P2",
            "sweeper:risk-session-state",
            "area/profiles"
          ],
          "author": "PRATHAMESH75",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c480052d80f472ec6e99",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85454",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85454",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "docs(telegram): document media routing tables and batch-timing env vars",
          "text": "Fixes #78710. Telegram inbound media routing is table-driven (`_TELEGRAM_IMAGE_EXTENSIONS`, `_TELEGRAM_IMAGE_MIME_TO_EXT`, `_TELEGRAM_IMAGE_EXT_TO_MIME`, `_TELEGRAM_VOICE_EXTS`, `_TELEGRAM_AUDIO_ATTACHMENT_EXTS`) and the three batch-debounce knobs (`HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS`, `HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS`, `HERMES_TELEGRAM_TEXT_BATCH_SPLIT_DELAY_SECONDS`) are read by the adapter, but none of this was documented. This PR: - Adds a \"Media routing and batching\" subsection to `telegram.md` (right after the MEDIA extensions table) describing the routing tables and the three debounce env vars with their defaults and clamps - Mirrors the three vars in `environment-variables.md` Defaults (`0.8`, `0.3`, `1.0`) and clamps (`0.08`–`2.0`, ≤`4.0`) verified against `plugins/platforms/telegram/adapter.py` on main. Docs-only.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85454",
          "createdAt": "2026-08-13T17:10:04Z",
          "updatedAt": "2026-08-13T17:15:26Z",
          "timestamp": "2026-08-13T17:15:26Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/docs",
            "comp/gateway",
            "platform/telegram",
            "P3"
          ],
          "author": "Luna161",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:61c59f12cfbd97a4a674",
        "signalId": "github:NousResearch/hermes-agent:issue:85446",
        "event": "discovered",
        "observedAt": "2026-08-13T17:43:20.785491Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85446",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: kimi-coding streaming request blocks forever in httpcore header read — timeout never applied, fallback never engages",
          "text": "## Bug Description A `kimi-coding` request never returns. The process sends the HTTP request, then blocks **forever** waiting for response headers — no error, no timeout, no fallback. The run has to be killed externally. This is not a slow response: the call is still blocked in `httpcore`'s socket read after 200 s, and the configured per-provider timeout does not bound it. The same one-shot on another provider returns in 14 s, and the Kimi endpoint itself answers unauthenticated probes in ~0.5 s — so neither the CLI path nor the remote endpoint is at fault. ## Steps to Reproduce ```bash hermes -z \"Reply exactly: PONG\" --provider kimi-coding -m k3 ``` Reproduced **4/4 times**, killed at 120 s, 130 s, 180 s and 200 s. `k3` is a valid model for this provider per the models.dev catalogue (`k3`, `k3-256k`, `kimi-for-coding`, `kimi-for-coding-highspeed`). **Control (works):** ```bash hermes -z \"Reply exactly: PONG\" --provider copilot -m gpt-4.1 # → PONG in 14 s ``` ## Blocked stack (captured with `PYTHONFAULTHANDLER=1` + `SIGABRT`) ``` Thread (most recent call first): File \"httpcore/_backends/sync.py\", line 128 in read File \"httpcore/_sync/http11.py\", line 217 in _receive_event File \"httpcore/_sync/http11.py\", line 177 in _receive_response_headers File \"httpcore/_sync/http11.py\", line 106 in handle_request File \"httpcore/_sync/connection.py\", line 103 in handle_request File \"httpcore/_sync/connection_pool.py\", line 236 in handle_request File \"httpx/_transports/default.py\", line 250 in handle_request File \"httpx/_client.py\", line 1014 in _send_single_request File \"httpx/_client.py\", line 979 in _send_handling_redirects File \"httpx/_client.py\", line 942 in _send_handling_auth File \"httpx/_client.py\", line 914 in send File \"openai/_base_client.py\", line 1005 in request File \"openai/_base_client.py\", line 1297 in post File \"openai/_utils/_utils.py\", line 286 in wrapper File \"agent/chat_completion_helpers.py\", line 3365 in _open_stream File \"agent/relay_llm.py\", line 424 in __init__ File \"agent/relay_llm.py\", line 343 in stream File \"agent/chat_completion_helpers.py\", line 3427 in _call_chat_completions File \"agent/chat_completion_helpers.py\", line 4082 in _call ``` The request was **sent**; it is `_receive_response_headers` that never completes. During the hang, `ss -tnp` shows an ESTABLISHED TLS connection from the process to `api.kimi.com`. ## `request_timeout_seconds` does not bound it Adding an explicit per-provider timeout does **not** stop the hang — same run still killed at 200 s: ```yaml providers: kimi-coding: request_timeout_seconds: 60 ``` `get_provider_request_timeout()` (`hermes_cli/timeouts.py`) reads exactly this key, so it is being resolved, but it does not appear to reach the streaming chat-completions client at `chat_completion_helpers.py:3365`. With no configuration at all it returns `None`, and the OpenAI client is then created without any request timeout — which matches the unbounded block above. ## What I ruled out | Hypothesis | Test | Result | |---|---|---| | Endpoint down / slow | `curl https://api.kimi.com/` | TLS OK, 1.3 s | | Endpoint doesn't answer | `POST /coding/v1/chat/completions`, no auth | **401 in 0.56 s** | | Wrong protocol path | `POST /coding/v1/messages`, no auth | **401 in 0.56 s** (both exist) | | Server stalls on bad model | `POST` with a nonexistent model | 401 in 0.53 s | | Streaming-specific server behaviour | `POST` with `\"stream\": true`, no auth | 401 in 0.53 s | | CLI one-shot path broken | `--provider copilot -m gpt-4.1` | PONG in 14 s | | Base URL not resolved | `KIMI_BASE_URL=https://api.kimi.com/coding/v1` | still hangs | | Auxiliary model resolution | forced `auxiliary.compression` onto a working provider | still hangs | ## Possibly related - **#77256** — notes that `api.kimi.com/coding` is `anthropic_messages` while the `kimi-for-coding` overlay declares `transport=\"openai_chat\"`. My probes show *both* `/coding/v1/chat/completions` and `/coding/v1/messages` exist and return 401 unauthenticated, so this may be a contributing factor rather than the whole story — but a protocol mismatch on an authenticated request is a plausible way to get a response that never arrives. - **#36776** (closed) — same *shape* of defect on another provider: \"hangs indefinitely — no overall timeout on search calls\". ## Suggested direction Independently of the Kimi-specific root cause: a provider that accepts the connection and never responds should surface as an error, not as an unbounded block. A default read/TTFB deadline on the streaming path would turn this into a normal failure — and would let `fallback_providers` engage, which currently cannot happen because no error is ever raised. ## Environment - hermes-agent **0.20.0**, commit `1fd21cbd0f51` (confirmed still reproducing after updating to current `main`) - Python 3.11.15, Linux x86_64 - Provider auth: API key, resolved from the environment (valid — the same credential returns HTTP 200 with 12 models against the Moonshot platform API) - Not run: `hermes debug share`. It would upload environment and configuration off-box, and this deployment has a standing rule against that; everything it would have carried is inlined above. Happy to provide anything specific on request.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85446",
          "createdAt": "2026-08-13T16:57:29Z",
          "updatedAt": "2026-08-13T17:15:19Z",
          "timestamp": "2026-08-13T17:15:19Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/kimi",
            "P2"
          ],
          "author": "AlekTkT",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:81a36f55f0907841186a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85487",
        "event": "changed",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [
          "updatedAt"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85487",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(kanban): explicit notify/wake delivery modes with faithful wake session routing (salvage #37865)",
          "text": "## Summary Kanban notify subscriptions gain explicit delivery modes — `notify`, `notify+wake`, `wake` — and woken turns now land in the creator's REAL session instead of a context-less parallel one. Salvage of #37865 by @verybigdog onto current main (authorship preserved; original commits carried a local placeholder identity, re-attributed to the contributor's public email), plus one follow-up fix of ours. ## Changes - `hermes_cli/kanban_db.py`: `delivery_mode` + `chat_type` + `user_id_alt` columns with idempotent migration; child tasks inherit the parent's notify return path (ACK-edge inheritance) - `gateway/kanban_watchers.py`: wake gated on `delivery_mode` instead of unconditional; passive send suppressed in pure-`wake` mode - `gateway/session_context.py` / `tools/kanban_tools.py`: persist `chat_type` and alt user id so the wake replay reconstructs the same `build_session_key`; gateway autosubscribes as `notify+wake`, TUI as `notify` - `hermes_cli/kanban.py`: `--delivery-mode` CLI flag; `gateway/slash_commands.py` autosubscribe; docs updated - **Follow-up (ours):** migration backfills pre-existing gateway subscriptions to `notify+wake` on first-add of the column — before this column existed, wake was unconditional, so defaulting legacy rows to `notify` would have silently disabled active wake on upgrade. Backfill runs only on first-add; explicit downgrades survive re-migration. Sabotage-verified regression tests. ## Validation | | Before | After | |---|---|---| | wake session key | could land context-less (user_id vs alt id divergence) | reconstructs creator's session | | legacy gateway subs after upgrade | silently lose wake | backfilled to notify+wake | | targeted tests | — | 60/60 pass (kanban_notify + kanban_tools + kanban_notifier) | Supersedes #37865; closes it with credit. Triage previously anointed #37865 canonical over dupes #49593/#49749/#49751. ## Infographic ![Kanban delivery modes](https://files.catbox.moe/se1zot.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85487",
          "createdAt": "2026-08-13T17:31:01Z",
          "updatedAt": "2026-08-13T17:46:50Z",
          "timestamp": "2026-08-13T17:46:50Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "comp/gateway",
            "comp/cron",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:05c59a300249f0f4f190",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85499",
        "event": "discovered",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85499",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cli): show context-length detection result when saving custom provider",
          "text": "Fixes #2513. When a custom provider is saved via `hermes model` / `hermes setup` with the context-length prompt left blank, the value stays `None` and the runtime resolution chain (`agent/model_metadata.get_model_context_length`) runs silently later. Users got no feedback about which context window was actually in effect — or whether it was a real detection vs. the hard fallback. This implements the approach the closed reference PR #2522 was verified for, adapted to current main (the flow moved from `hermes_cli/main.py::_model_flow_custom` to `hermes_cli/model_setup_flows.py`): - After parsing the blank input, call `get_model_context_length(model_name, base_url=effective_url, api_key=effective_key)` — the full 9-step resolution chain (endpoint probe, local server, registries, family defaults). - Distinguish a genuinely detected value from `DEFAULT_FALLBACK_CONTEXT` so the fallback is never mislabeled as \"auto-detected\". - Persist only real detections into the `custom_providers` entry; the bare fallback stays unset (so the runtime re-resolves each startup and logs its own fallback warning). - Print one clear line either way, using `_format_context_length` (`256K`-style). - Wrap the whole probe in try/except — a failing probe must never block saving the provider. Regression tests (`tests/hermes_cli/test_custom_provider_context_feedback.py`) cover: - known model resolves to a non-fallback value - unknown model falls through to `DEFAULT_FALLBACK_CONTEXT` - the fallback sentinel is distinguishable from a real detection - blank model name never triggers a probe Note: the fallback constant on current main is 256K (`CONTEXT_PROBE_TIERS[0]`), not the 128K mentioned in the issue — the messages reference `DEFAULT_FALLBACK_CONTEXT` directly so they stay correct if the tier list changes.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85499",
          "createdAt": "2026-08-13T17:46:24Z",
          "updatedAt": "2026-08-13T17:46:35Z",
          "timestamp": "2026-08-13T17:46:35Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [],
          "author": "Luna161",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:6949451c1b3964debf79",
        "signalId": "github:NousResearch/hermes-agent:issue:2513",
        "event": "discovered",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:2513",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: Custom providers lack context length auto-detection when saved via /model command",
          "text": "### Bug Description **Summary:** When users save a custom provider using the `/model` command without specifying a context length, the system doesn't automatically detect and display what context length was actually used. This leaves users uncertain about whether their custom provider has a short or long context window, which is critical for prompt caching behavior. **Impact:** - Users cannot easily verify if their custom provider has adequate context window - Prompt caching may be less effective than expected - No visual feedback distinguishes between auto-detected and default context lengths ### Steps to Reproduce run hermes model select a custom provider do not enter a context length ### Expected Behavior After saving a custom provider without a context length, the system should: 1. Attempt to auto-detect the context length based on the model name 2. Display the detected context length to the user 3. Fall back to a sensible default (128K tokens) if detection fails 4. Indicate visually when the default was used vs. when auto-detection succeeded ### Actual Behavior Users who save a custom provider without specifying context length have no feedback on what context length is being used. This can lead to unexpected prompt caching behavior and higher token costs. ### Affected Component CLI (interactive chat) ### Messaging Platform (if gateway-related) _No response_ ### Operating System Debian 13.4 ### Python Version 3.13.3 ### Hermes Version 0.4 ### Relevant Logs / Traceback ```shell ``` ### Root Cause Analysis (optional) _No response_ ### Proposed Fix (optional) _No response_ ### Are you willing to submit a PR for this? - [x] I'd like to fix this myself and submit a PR",
          "url": "https://github.com/NousResearch/hermes-agent/issues/2513",
          "createdAt": "2026-03-22T16:00:19Z",
          "updatedAt": "2026-08-13T17:46:25Z",
          "timestamp": "2026-08-13T17:46:25Z",
          "metrics": {
            "reactions": 1,
            "comments": 5
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "area/config",
            "P3"
          ],
          "author": "InB4DevOps",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a82c4aeb315fb12a18e7",
        "signalId": "github:NousResearch/hermes-agent:pull_request:13101",
        "event": "discovered",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:13101",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(memory): auto-install hindsight-all for local_embedded mode",
          "text": "## What does this PR do? Fixes a crash when users select `local_embedded` mode via `hermes memory setup`. The setup wizard tries to install `hindsight-all` but the install can silently fail because `capture_output=True` swallows errors. Since `plugin.yaml` only declares `hindsight-client>=0.4.22` as a dependency, hermes startup validation passes even when `hindsight-all` is missing. At runtime, `from hindsight import HindsightEmbedded` crashes with `ModuleNotFoundError: No module named 'hindsight'`. This patch adds a guard in `HindsightMemoryProvider.initialize()` that: 1. Detects when mode is `local_embedded` 2. Attempts to `import hindsight` — if it fails, auto-installs `hindsight-all` via `uv pip install` 3. Raises a clear `RuntimeError` with manual install instructions if installation fails 4. Does **not** surface errors as swallowed silent failures — errors are logged and propagated This mirrors the existing auto-upgrade logic for `hindsight-client` already present in `initialize()` for cloud mode. ## Related Issue Fixes crash described in hindsight local_embedded setup flow. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `plugins/memory/hindsight/__init__.py`: Added auto-install guard for `hindsight-all` in `initialize()` when mode is `local_embedded` - `plugins/memory/hindsight/plugin.yaml`: Added comment documenting the `hindsight-all` dependency for local_embedded mode ## How to Test 1. Configure hindsight with `local_embedded` mode but ensure `hindsight-all` is NOT installed 2. Start a new hermes session with hindsight memory enabled 3. Verify that the plugin auto-installs `hindsight-all` and logs the installation 4. Verify that if installation fails, a clear `RuntimeError` is raised with manual instructions 5. Test cloud mode still works without `hindsight-all` installed (no regression) ## Checklist - [x] My commit messages follow Conventional Commits - [x] My PR contains only changes related to this fix - [x] I have considered cross-platform impact (uses shutil.which(\"uv\") which works on all platforms) - [x] Updated relevant docstrings/comments — plugin.yaml comment added",
          "url": "https://github.com/NousResearch/hermes-agent/pull/13101",
          "createdAt": "2026-04-20T16:06:45Z",
          "updatedAt": "2026-08-13T17:46:21Z",
          "timestamp": "2026-08-13T17:46:21Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "tool/memory",
            "P2",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate",
            "area/memory",
            "area/install-update"
          ],
          "author": "LehaoLin",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:1f6ffbc690a8f263aa3e",
        "signalId": "github:NousResearch/hermes-agent:pull_request:5838",
        "event": "discovered",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:5838",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(memory): add sync_recall option for current-turn relevance",
          "text": "## Summary Closes #5820. Adds a `memory.sync_recall: true` config option that switches memory providers from background prefetch to synchronous recall at turn start. ## Problem The current model queues a background recall at turn end using that turn's query. The next turn consumes this result — which is based on the *previous* message. When topics change between turns the injected context is irrelevant or misleading. ## Solution When `sync_recall: true`, each turn performs a live recall with the actual current message before the first LLM call. Background prefetch is skipped entirely at turn end. Default is `false` — no behavior change for existing users. ## Implementation `MemoryProvider.recall_sync()` default: fires `queue_prefetch()` then immediately calls `prefetch()`, which joins the background thread. This works for all providers that join inside `prefetch()` (honcho, hindsight, mem0, openviking). RetainDB overrides `recall_sync()` directly because its `prefetch()` reads shared state without joining threads. 4 files changed, 54 lines added. ## Config ```yaml memory: provider: hindsight # or honcho, mem0, etc. sync_recall: true",
          "url": "https://github.com/NousResearch/hermes-agent/pull/5838",
          "createdAt": "2026-04-07T12:42:20Z",
          "updatedAt": "2026-08-13T17:46:18Z",
          "timestamp": "2026-08-13T17:46:18Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/plugins",
            "tool/memory",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate",
            "area/memory"
          ],
          "author": "heathley",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:08148b7f382a103acf34",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85340",
        "event": "changed",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [
          "updatedAt",
          "state"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85340",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cron): relay-fronted Slack delivery — synthetic creation-thread capture + preflight fronted-platform blindness",
          "text": "## Summary Two interlocking cron-delivery bugs on relay-fronted Slack deployments (the gateway runs only the relay platform; a connector owns the Slack app). Reported from a live deployment: `/sethome` was set correctly, yet cron jobs created from Slack delivered into the ephemeral thread that Slack spawns around the creation message — and the explicit-target escape hatch `deliver: \"slack:<chat_id>\"` was rejected at preflight with \"delivery platform 'slack' has no gateway credentials configured (not connected)\". ### Bug 1 — synthetic session-keying thread persisted as durable routing Relay-fronted Slack in thread-per-message mode stamps each top-level message's own id as `source.thread_id` (`gateway/relay/adapter.py::_stamp_slack_session_thread`, mirroring native `thread_ts = event.thread_ts or ts`). Its docstring is explicit that the stamp exists for **session keying** — but three consumers persisted it as a delivery address: 1. **Cron origin capture** (`tools/cronjob_tools.py::_origin_from_env`) stored it as `origin.thread_id`, and fire-time resolution replayed it unconditionally (`cron/scheduler.py::_resolve_single_delivery_target`), with a Slack origin-affinity block re-attaching it even onto explicit `slack:<chat_id>` targets. 2. **`/sethome` itself** (`gateway/slash_commands.py`) captured it into the persisted `HomeChannel.thread_id` and its legacy env mirror — pinning the *home target* to the thread around the `/sethome` message, so bare `deliver: \"slack\"` also routed into a stale thread. Found during live validation of fix 1; same root cause, third consumer. **Fix — recognize the stamp at every capture boundary, plus a fire-time repair for jobs persisted before the fix:** - Capture (cron): `_origin_from_env` drops a Slack thread id equal to the creation message's own id (`HERMES_SESSION_MESSAGE_ID`). A genuine in-thread creation (thread id = parent's id, not this message's own) keeps its thread. - Capture (`/sethome`): new `_home_thread_from_source` applies the same recognition before persisting the home target. Running `/sethome` inside a real thread still pins that thread deliberately. Users repair an already-poisoned home target by rerunning `/sethome`. - Fire time: `_origin_thread_is_stale` treats a Slack origin thread as stale when the origin chat is the configured Slack home chat — delivery goes top-level (or to the home target's own configured thread). Non-home chats keep their threads; the explicit-target re-attach is gated on the same predicate. - Slack-scoped throughout: Telegram forum topics legitimately reuse ids and are untouched (covered by negative-control tests). ### Bug 2 — preflight blind to relay-fronted platforms `_preflight_check_delivery` and `cron_delivery_targets` validated deliver prefixes against `get_connected_platforms()`, which only sees **natively configured** platforms — `{relay}` on a relay-only deployment, so `slack:<chat>` was refused before any spend. Fire-time routing already handles this topology correctly (`gateway/delivery.py::resolve_delivery_transport` → `RelayAdapter.fronts_platform`; the delivery loop even bypasses the native enabled-gate for relay transports). Validation and routing were asymmetric; the asymmetry is the bug. **Fix:** new `gateway.relay.relay_fronted_platforms()` — a thin wrapper over the existing `relay_platform_identities()` (env-derived from `GATEWAY_RELAY_PLATFORMS`, the same source that seeds the live adapter's identity set, so the two paths cannot disagree; usable from a standalone scheduler process with no live adapter handle). Unioned into the connected set at both validation sites, only when the relay itself is connected. Native topologies keep the strict credential check unchanged. ## Live validation (relay-fronted staging fleet, 2026-08-13) | Lane | Pre-fix behavior | Post-fix result | |---|---|---| | `deliver: \"origin\"` (job created from Slack DM) | delivered into creation-message thread | **verified: top-level delivery** | | `deliver: \"slack:<chat_id>\"` | killed at preflight (\"no gateway credentials\") | **verified: schedules and delivers top-level** | | `deliver: \"slack\"` (bare → home) | delivered into the old `/sethome` message's thread | **verified: top-level after rerunning `/sethome`** | ## Test evidence RED before fix (6 failed = the new contracts; 8 passed = negative controls): ``` FAILED tests/cron/test_cron_origin_synthetic_thread.py::TestSlackSyntheticThreadCapture::test_synthetic_slack_thread_not_captured FAILED tests/cron/test_cron_relay_delivery_guards.py::TestOriginThreadStaleGuard::test_origin_thread_dropped_when_chat_is_home FAILED tests/cron/test_cron_relay_delivery_guards.py::TestOriginThreadStaleGuard::test_home_thread_config_still_wins FAILED tests/cron/test_cron_relay_delivery_guards.py::TestOriginThreadStaleGuard::test_explicit_target_no_reattach_when_chat_is_home FAILED tests/cron/test_cron_relay_delivery_guards.py::TestPreflightRelayFronted::test_relay_fronted_slack_accepted FAILED tests/cron/test_cron_relay_delivery_guards.py::TestPreflightRelayFronted::test_delivery_targets_include_relay_fronted 6 failed, 8 passed ``` Mutation checks (fix committed first, one guard stubbed at a time, tests must go red, tree restored): - cron capture guard stubbed → `test_synthetic_slack_thread_not_captured` FAILED (1 failed, 3 passed) - `_origin_thread_is_stale` stubbed to `return False` → 3 staleness-guard tests FAILED (3 failed, 7 passed) - fronted-union stubbed to `return set()` → 2 preflight tests FAILED (2 failed, 2 passed) - `/sethome` guard stubbed → `test_synthetic_slack_thread_dropped` FAILED (1 failed, 4 passed) Canonical runner on the final tree: ``` === Summary: 74 files, 715 tests passed, 0 failed, 1 skipped (100% complete) in 13.9s (8 workers) === (scripts/run_tests.sh tests/cron tests/gateway/relay) ``` Full cron + gateway sweep after the `/sethome` commit: 543 passed, 1 skipped. ## Surfaces - No new config keys, no new model-tool surface, no wire/frame changes (`relay_fronted_platforms()` reads the existing `GATEWAY_RELAY_PLATFORMS` deploy stamp). - No connector-side changes required: both bugs are gateway-local (capture/validation); the connector's delivery path was already correct. - Behavior change scope: Slack origins/home targets only for bug 1; relay-connected deployments only for bug 2. ## What this does NOT do - No change to the per-message-session UX — the adapter's session-keying stamp is untouched; only its capture as delivery routing changes. - No `gateway/delivery.py` changes — fire-time routing was already correct. - No standalone-scheduler fire-path hardening (in-gateway fire works via the existing relay transport bypass). - Thread-continuation UX for cron deliveries in thread-per-message mode (a reply under a delivered brief opens an unseeded session) is a separate concern with a fix in progress on `fix-slack-delivery-root-seed`; it will arrive as its own PR once live-validated.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85340",
          "createdAt": "2026-08-13T14:02:55Z",
          "updatedAt": "2026-08-13T17:46:14Z",
          "timestamp": "2026-08-13T17:46:14Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "comp/cron",
            "platform/slack",
            "P2",
            "sweeper:risk-message-delivery"
          ],
          "author": "victor-kyriazakos",
          "state": "closed",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:0919c12052b7c9be86f9",
        "signalId": "github:NousResearch/hermes-agent:pull_request:46504",
        "event": "discovered",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:46504",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(hindsight): surface missing hindsight-all in local_embedded (#7718)",
          "text": "## Summary `local_embedded` imports the top-level `hindsight` module (from the `hindsight-all` package), but `plugin.yaml` only declares `hindsight-client`. Users who configure `local_embedded` by hand — or upgrade from the legacy `\"mode\": \"local\"` — never run the setup wizard that installs `hindsight-all`, so long-term memory is **silently disabled** behind a non-actionable `No module named 'hindsight'` warning. The agent-startup path (`agent/agent_init.py`) gates the provider on `is_available()` and drops it with a silent `logger.debug` **before** `initialize()` runs — so the actionable message has to live where the provider is actually dropped. ## Changes - **`is_available()`** — emits a one-time actionable warning naming `hindsight-all` + the install command when `local_embedded` is configured but the runtime package is missing. Stays silent for intentional graceful-degradation failures (e.g. NumPy CPU-baseline errors). - **`_get_client()`** — the `RuntimeError` raised when the runtime probe fails now carries the same actionable hint (the real raise point), with an `ImportError` backstop around the embedded import. - **`initialize()`** — actionable disable warning + a migration warning when the legacy `\"mode\": \"local\"` is remapped to `local_embedded`. - Shared `_embedded_runtime_hint()` / `_warn_missing_embedded_runtime_once()` helpers keep the messaging DRY and reason-gated. - **Docs** — README troubleshooting note + `plugin.yaml` comment explaining why `hindsight-all` is intentionally not a static dependency (avoids pulling the heavy embedded runtime for cloud-only users). ## Testing ``` scripts/run_tests.sh tests/plugins/memory/test_hindsight_provider.py # 109 passed, 0 failed ``` 7 new regression tests cover: `is_available()` warning + silence + once-per-process; `_get_client()` real-path `RuntimeError` and import backstop; `initialize()` disable + legacy-migration warnings. Fixes #7718",
          "url": "https://github.com/NousResearch/hermes-agent/pull/46504",
          "createdAt": "2026-06-15T06:15:14Z",
          "updatedAt": "2026-08-13T17:46:14Z",
          "timestamp": "2026-08-13T17:46:14Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "tool/memory",
            "P3",
            "sweeper:blast-contained",
            "area/memory"
          ],
          "author": "bionicbutterfly13",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:2fe5afdf7a50e2e40558",
        "signalId": "github:NousResearch/hermes-agent:pull_request:74379",
        "event": "discovered",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:74379",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(hindsight): memory provider improvements — recall_sync, retain_source, setup templates, memory indicators, error hints",
          "text": "Bundles previously-separate Hindsight/memory PRs into a single review surface. Each was independently mergeable; combining them avoids parallel reviews of the same file (`plugins/memory/hindsight/__init__.py`, which most of them touch). ## What's included | Supersedes | Change | Fixes | |---|---|---| | #70278 | **Opt-in synchronous recall** for the current turn (`recall_sync`) — recall the injected memory in-turn instead of next-turn prefetch | #5820 | | #70295 | **Actionable error when the `local_embedded` runtime is missing** — tells the user exactly which package to install instead of a bare import failure | #7718 | | #72926 | **Default `retain_source` to `\"hermes\"`** so every stored memory self-identifies its provenance (`metadata.source: \"hermes\"`) | — | | #73415 | **Offer a starter memory template during `hermes memory setup`**, plus **warn before overwriting** an already-configured bank | — | | #70257 | **Warn when a configured memory provider reports unavailable** (provider-agnostic) | #2765 | | _new_ | **Deterministic \"recalled N memories\" recall indicator** — Hermes itself emits `👁️ Hindsight — recalled N memories` via the status channel when auto-recall injects memory, so the user SEES memory working regardless of whether the model mentions it (`recall_indicator`, on by default) | — | | _new_ | **\"saving to memory\" retain indicator** — `👁️ Hindsight — saving to memory…` emitted the moment a turn is dispatched to the writer, only on real writes (`retain_indicator`, on by default) | — | ## Why the indicators Auto-recall injects memory into the prompt, but whether the user can *see* that Hindsight contributed was previously left to the model — and models routinely decline to mention it, so memory looks like it isn't working even when it is. These indicators are rendered by Hermes itself (`_emit_status`, the same model-independent channel as compression/idle notices), so they always show and can't be silently dropped. Both have off switches for customer-facing agents. ## Scope - Most changes are confined to `plugins/memory/hindsight/**` (+ their tests). - **#70257 and the indicators** are (partly) provider-agnostic and also touch `agent/agent_init.py`, `agent/memory_manager.py`, `agent/memory_provider.py`, `agent/turn_context.py`, and `hermes_cli/memory_setup.py` — the recall indicator adds an opt-in `recall_status()` hook to the base `MemoryProvider` and a `describe_recall()` aggregator on `MemoryManager`; the status channel is wired to the provider via `initialize(status_callback=…)`. ## Tests New/updated deterministic tests across all changes (provider behaviour incl. the recall_sync × indicator interaction, retain-indicator dispatch/off/buffered paths, template setup, local-runtime hint, provider-unavailable warning, env-hint, manager formatting, turn-loop emit wiring). Full affected suite green; `ruff` clean.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/74379",
          "createdAt": "2026-07-29T20:32:53Z",
          "updatedAt": "2026-08-13T17:46:09Z",
          "timestamp": "2026-08-13T17:46:09Z",
          "metrics": {
            "reactions": 0,
            "comments": 7
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/cli",
            "comp/plugins",
            "tool/memory",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:blast-moderate",
            "area/memory"
          ],
          "author": "benfrank241",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:1d9dd4bbb16a87613572",
        "signalId": "github:NousResearch/hermes-agent:issue:85497",
        "event": "discovered",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85497",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Feature]: kanban HERMES_TENANT has no memory-isolation effect — worker memory pollutes across tenants",
          "text": "### Problem or Use Case Kanban workers receive a `HERMES_TENANT` env var from the dispatcher (`kanban_db.py:_default_spawn`, ~line 9018) when a task is created with `--tenant <slug>`, but that variable has **no memory-isolation effect**. All workers — regardless of board, tenant, or assignee profile — write to the same flat global memory store (`~/.hermes/memories/MEMORY.md`), so concurrent tasks across tenants pollute each other's persistent memory. The kanban docs describe `--tenant` as providing \"data isolation by workspace path and memory key prefix\" ([docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban)). The workspace-path half works; the **memory key prefix half is not implemented**. In a multi-project setup where one role profile (e.g. `implementer`) serves several tenant boards concurrently, the same profile's worker processes write unrelated project context into the same global memory — the next task on any tenant reads the previous tenant's memories. ### Proposed Solution When a kanban worker is spawned with `HERMES_TENANT=<slug>`, scope its memory reads/writes to a per-tenant namespace, mirroring the `context_id` scoping proposed in #47552 and #34352: 1. In `_default_spawn`, map `task.tenant` → the same `context_id` mechanism from #47552 (`MemoryStore` context scoping — writes into `memories/contexts/{context_id}/`, reads merge global + scoped), or 2. Set a dedicated env (e.g. `HERMES_MEMORY_CONTEXT_ID=tenant`) consumed by the memory tool, keeping kanban independent of gateway message-source derivation. Either way, the existing `HERMES_TENANT` plumbing in `_default_spawn` is the natural hook point — the isolation is already half-wired. ### Alternatives Considered - **Manual review of MEMORY.md** — current workaround; fragile at scale with 10+ tenant boards. - **Per-profile memory dirs** (`memories` under each profile) — doesn't help when one role profile serves multiple tenants concurrently, which is the exact kanban pattern. - **Separate boards as hard isolation** — boards already isolate task queues and workspaces, but worker *memory* still lands in one global store regardless of board; board isolation alone doesn't solve this. ### Feature Type - Configuration option / Performance & reliability (memory isolation for multi-tenant kanban workers) ### Scope - Small to Medium (hook `HERMES_TENANT` into the existing `context_id` plumbing in `_default_spawn` + memory tool; tests for scoped reads/writes) ### Contribution - [ ] I'd like to implement this myself and submit a PR --- ### Evidence (source inspection + runtime test) - `HERMES_TENANT` is set at `kanban_db.py:9017-9018` in `_default_spawn`. - The only reader is `tools/kanban_tools.py:1217` (default tenant for `kanban_create`) — no memory path consults it. - Runtime test 2026-08-14: two tasks on two different boards (`cognitive-tree-csharp`, `envoy-task`), both assigned to `decomposer`, each created with `--tenant <board>`. Both workers' memory writes landed in the single global `~/.hermes/memories/MEMORY.md` (mtime during the run window); the profile's own `memories/` dir stayed empty. ### Related - #34352 (Solving the Multi-Tenant Hermes Problem) - #47552 (feat(memory): opt-in context_id scoping — open PR, not merged) ### Environment - Hermes agent, commit aec331899 (recent main) - macOS 26.5, kanban dispatcher in gateway (dispatch_in_gateway: true)",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85497",
          "createdAt": "2026-08-13T17:43:33Z",
          "updatedAt": "2026-08-13T17:46:00Z",
          "timestamp": "2026-08-13T17:46:00Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "hua-hua3321",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a3f3e3b6bea2899e7f9a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85388",
        "event": "changed",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85388",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(pricing): apply DeepSeek peak/off-peak rate card (#72662)",
          "text": "## Summary Implements #72662: DeepSeek's official rate card switches to peak/off-peak billing at **2026-08-16T16:00 UTC** (announced on https://api-docs.deepseek.com/quick_start/pricing): - Peak hours: **01:00–04:00 and 06:00–10:00 UTC** (all other hours off-peak) - Peak = 2× off-peak on every billing item (cache-hit input, cache-miss input, output) ## Changes ### `agent/usage_pricing.py` - **Snapshot updated to the 2026-08-16 OFF-PEAK rates**: deepseek-v4-flash $0.007 / $0.22 / $0.66 per 1M (cache-hit / cache-miss / output), deepseek-v4-pro $0.022 / $0.66 / $1.98. `pricing_version` bumped to `deepseek-pricing-2026-08-16`. The deepseek-chat / deepseek-reasoner aliases keep sharing flash's rates. - **`estimate_usage_cost` peak/off-peak selection**: before the switchover instant the legacy flat card (`deepseek-pricing-2026-07`) applies; after it, calls during peak hours bill at 2× the off-peak rates and the `CostResult` carries a \"peak-hour rate applied\" note. Peak windows are read as half-open intervals → hours 1, 2, 3 and 6, 7, 8, 9. - The legacy flat card lives in a small temporary module dict (`_DEEPSEEK_LEGACY_FLAT_RATES`) so estimates stay accurate during the transition window; it is marked for removal after the switchover. ### `tests/agent/test_usage_pricing.py` 7 new tests: - peak-hour boundary matrix (hours 0–11, 23) incl. exact window edges (00:59:59 → 01:00:00, 03:59:59 → 04:00:00, 09:59:59 → 10:00:00) - official-table amounts for both models (1M in + 1M out: flash $0.88 off-peak / $1.76 peak; pro $2.64 / $5.28) - cache-read tokens scale at peak ($0.007 → $0.014 flash) - pre-switchover legacy flat rates (incl. a would-be peak hour) - switchover instant boundary (15:59:59Z legacy, 16:00:00Z new card) - non-DeepSeek providers unaffected during peak hours - updated `test_deepseek_v4_pro_pricing_entry_exists` to the new card ## Testing - `tests/agent/test_usage_pricing.py`: 20 passed - All usage_pricing consumers (insights, model cost guard, usage command, MoA loop, context engine): 100 passed total - A large combined batch run shows ~120 failures in unrelated suites (title generator, secret-scope migration, codex transport, etc.) that pass in isolation on both main and this branch — known order-dependent batch interference in this repo, unrelated to this change. ## Notes - **Boundary semantics**: peak windows are treated as half-open [01:00, 04:00) and [06:00, 10:00) UTC, matching the 09:00–12:00 / 14:00–18:00 Beijing framing (clean 12:00–14:00 lunch gap). Hour 4 (04:00–05:00) is off-peak. - **Transition window**: rates are selected at call time (post-request), matching DeepSeek's per-request timestamp billing. Before 2026-08-16T16:00Z the legacy flat card is used, so estimates don't overstate during the transition. - Supersedes the earlier attempt #72756, whose 2×-on-current-rates approach no longer matches DeepSeek's published pricing (the official off-peak rates themselves differ from the current flat card). - **Credit**: feature request and the peak-window UTC mapping are from @dominicelayda's issue #72662; @webtecnica's earlier attempt (#72756) established the multiplier approach and surfaced the need for the official rate card. This PR is a fresh implementation against the official card. Closes #72662",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85388",
          "createdAt": "2026-08-13T15:24:17Z",
          "updatedAt": "2026-08-13T17:47:04Z",
          "timestamp": "2026-08-13T17:47:04Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "provider/deepseek",
            "P3",
            "area/usage-cost"
          ],
          "author": "DavidMetcalfe",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:b5832fc1ee1975a6c948",
        "signalId": "github:NousResearch/hermes-agent:issue:85491",
        "event": "changed",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85491",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "terminal_tool: cron lifecycle guard crashes with ValueError on venv binary paths (os.open raises ValueError, not OSError)",
          "text": "## Summary `terminal_tool` crashes with `ValueError: embedded null byte` before running the command, when the command string leads the guard to derive a script path containing a NUL byte. The failure happens in the cron lifecycle guard's pre-flight scan, so the command never executes and the tool call returns an error instead of output. ## Impact Hit repeatedly during an ordinary coding session on `main` (`470aa20d22`). Two commands that failed: ```bash cd /path/to/repo && git merge --ff-only origin/master && echo \"...\" && .venv/bin/python -m pytest -q cd /path/to/repo && .venv/bin/python -m pytest -q | tail -2 ``` Running a virtualenv interpreter by relative path is extremely common, so this is likely to affect many sessions. It fails loudly rather than silently, which limits the damage — but the error surfaces as a tool traceback with no indication that the *command* was fine and the *guard* was at fault, so it reads like a repo or environment problem. **Workaround:** assign the path to a shell variable first, which changes the token the guard inspects. ```bash PY=.venv/bin/python; \"$PY\" -m pytest -q # works ``` ## Root cause `cron/lifecycle_guard.py::_read_referenced_script` (line ~260): ```python flags = os.O_RDONLY | getattr(os, \"O_NONBLOCK\", 0) try: descriptor = os.open(path, flags) except OSError: return None, False ``` `os.open()` raises **`ValueError`** — not `OSError` — when the path contains an embedded NUL byte. The `except OSError` clause does not catch it, so it propagates up through `_contains_unsafe_gateway_action` → `contains_gateway_lifecycle_command_or_referenced_script` → `terminal_tool` and aborts the tool call. Verified in isolation: ```python >>> import os >>> os.open(\"/tmp/x\\x00y\", os.O_RDONLY) ValueError: embedded null byte ``` The surrounding code already treats unreadable and non-regular paths as \"nothing to scan\" and returns cleanly, so an unopenable path is an expected condition here rather than an exceptional one — this looks like a missed exception type rather than a design question. ## Full traceback ``` File \"tools/terminal_tool.py\", line 2560, in terminal_tool if contains_gateway_lifecycle_command_or_referenced_script( File \"cron/lifecycle_guard.py\", line 353, in contains_gateway_lifecycle_command_or_referenced_script return _contains_unsafe_gateway_action( File \"cron/lifecycle_guard.py\", line 335, in _contains_unsafe_gateway_action if script_text and _contains_unsafe_gateway_action( File \"cron/lifecycle_guard.py\", line 324, in _contains_unsafe_gateway_action script_text, unsafe = _read_referenced_script(script_path) File \"cron/lifecycle_guard.py\", line 260, in _read_referenced_script descriptor = os.open(path, flags) ValueError: embedded null byte ``` ## Suggested fix Catch `ValueError` alongside `OSError`: ```python try: descriptor = os.open(path, flags) except (OSError, ValueError): return None, False ``` A path that cannot be opened is already handled as \"nothing to scan, not unsafe\", and a NUL-containing path can never name a real script, so the same return is correct. Worth checking whether the path *derivation* upstream should also reject NUL-containing candidates before they reach the open — the guard scans several tokens per command, and this one produced a candidate that could not name any file on disk. ## Notes I have not opened a PR — the one-line change is easy, but the guard is security-adjacent code and I would rather the maintainers decide whether the fix belongs at the `open()` call, at the path derivation, or both. Happy to submit one if that is useful.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85491",
          "createdAt": "2026-08-13T17:32:33Z",
          "updatedAt": "2026-08-13T17:45:57Z",
          "timestamp": "2026-08-13T17:45:57Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/tools",
            "comp/cron",
            "tool/terminal",
            "P2"
          ],
          "author": "PolyphonyRequiem",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:e541c8ee976652709977",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85009",
        "event": "discovered",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85009",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(relay): session-span segmentation for continuous sessions",
          "text": "## Problem Continuous gateway sessions (the normal state for a Telegram/Slack agent) keep the Relay session scope open for days or weeks. Relay export is close-driven, so: - the session root span and its attributes stay unexported until `/new` or idle-end, - any marks attached to the session scope are held with it, - a crash or redeploy loses the entire open segment — nothing exports. In-turn marks already export per-turn since the turn-parenting fix (#83517); this addresses the session-scope remainder. ## Design Opt-in segmentation, config in `config.yaml` (no new env vars): ```yaml gateway: telemetry: session_segments: on_compaction: false # rotate the session scope when the session compacts max_turns: 0 # 0 = unlimited; N = rotate after N turns per segment ``` **Both defaults OFF → scope lifecycle byte-identical to today.** Nothing changes for any existing deployment. Rotation closes the current session scope and pushes the next segment with the same `session_id` attribute plus `hermes.session.segment=N` and `hermes.session.segment_reason=compaction|max_turns` — dashboards keep grouping on `session_id`; per-session queries are unchanged. Key invariants: - **Turn-boundary only.** The scope stack is LIFO; rotation happens exclusively in `begin_turn` before the turn scope pushes. Compaction never rotates directly — it flags `rotate_pending`, consumed at the next turn. A compaction completing mid-turn cannot violate stack order. - **Nothing on the compaction critical path.** The hook in `_notify_context_engine_compression_complete` is observer-semantics: flag set under the session lock, every failure swallowed at DEBUG. - **Legacy rotating compaction handled.** When compaction rotates to a child session id, the old session's scope would orphan unexported forever; the notification closes it (bounded) so the pre-compaction segment exports. - **Bounded like every native scope op.** Rotation rides the shared scope-op executor with `_SCOPE_OP_TIMEOUT` (#83514): a wedged rotation costs one segment span, never the agent. Bookkeeping advances before the native calls so a degraded rotation cannot retry on every turn. - **Subagents:** post-rotation children parent to the new segment handle; live children under the old handle remain valid (parent-ends-before-child is legal tracing). ## Rejected scope (deliberate) - Byte-size segment accounting — span size is not observable pre-close in the SDK - Wall-clock / age-based rotation — parked; turn count and compaction are the meaningful boundaries - OTel span links between segments — shared `session_id` attribute suffices for v1 - Rotation outside turn boundaries; retroactive stitching of pre-crash segments ## Verification - 11 new tests (`tests/agent/test_relay_session_segments.py`): defaults-never-rotate contract, rotation at next begin_turn (not immediately, never mid-turn), segment metadata, max_turns cap arithmetic, zero-cap unlimited, unknown-session/disabled no-ops, rotating-compaction close, wedged-rotation bounded (<5s return), subagent handle continuity. Wedge tests unwedge + drain at teardown (per the #83514 CI lesson). - Mutation check: disabling rotation consumption → exactly the 4 rotation tests fail (4 failed, 7 passed); restore → 11/11. - Gates over `tests/agent/ tests/gateway/relay/ tests/plugins/`: all green except `tests/plugins/memory/test_hindsight_provider.py` (6 failures) — **pre-existing on unmodified origin/main** (`fe8b44dac`), reproduced identically in a clean control worktree; unrelated to this change.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85009",
          "createdAt": "2026-08-13T03:22:35Z",
          "updatedAt": "2026-08-13T17:45:39Z",
          "timestamp": "2026-08-13T17:45:39Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "P3",
            "telemetry",
            "sweeper:risk-session-state",
            "area/compression"
          ],
          "author": "victor-kyriazakos",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:15f6268769e6ddab4e3c",
        "signalId": "github:NousResearch/hermes-agent:issue:5820",
        "event": "discovered",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:5820",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "feat(memory): Allow synchronous recall for current turn as option for honcho and hindsight",
          "text": "IIUC hermes currently queues a background recall for next turn instead of synchronously recalling based on the current user query (for honcho and hindsight at least). `prefetch` right now totally ignores the given query. This means that the recalled information may be completely irrelevant when it is actually injected. If one query is `fix linting`, and the next is `fix tests`, or even if the topic changes slightly, the recalled information may be irrelevant or even cause worse results. Hindsight recall is already supposed to be fast, so the cost of this for a slight latency improvement does not seem worth it to me. I want memory results relevant to the current message.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/5820",
          "createdAt": "2026-04-07T11:14:21Z",
          "updatedAt": "2026-08-13T17:45:28Z",
          "timestamp": "2026-08-13T17:45:28Z",
          "metrics": {
            "reactions": 0,
            "comments": 8
          },
          "labels": [
            "type/feature",
            "comp/plugins",
            "tool/memory",
            "P3",
            "sweeper:risk-session-state",
            "area/memory"
          ],
          "author": "noctuid",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e9268d1c7eff378f4cd8",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85494",
        "event": "changed",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [
          "updatedAt",
          "state"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85494",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(hindsight): memory provider improvements — recall_sync, retain_source, setup templates, memory indicators, error hints",
          "text": "## Summary Salvages PR #74379 (@benfrank241) with security and efficiency fixes applied on top. Hindsight memory provider improvements: opt-in synchronous recall (recall_sync), actionable error hints for missing local runtime, opt-in retain_source attribution, starter memory templates during setup, provider-unavailable warning, and deterministic \"recalled N memories\" / \"saving to memory\" status indicators. Fixes #5820, #7718, #2765. Supersedes #70278, #70295, #72926, #73415, #70257, #46504, #5838, #13101. ## Changes - `plugins/memory/hindsight/__init__.py`: recall_sync, retain_source default, indicators, local runtime hint, unavailable_reason(), recall_status() - `plugins/memory/hindsight/templates.py` (new): starter bank template catalog fetch + apply + overwrite warning - `agent/memory_provider.py`: RecallStatus dataclass, unavailable_reason() and recall_status() ABC hooks, generic INDICATOR_GLYPH - `agent/memory_manager.py`: describe_recall() aggregator - `agent/turn_context.py`: recall indicator emit after prefetch - `agent/agent_init.py`: provider-unavailable warning with dedup guard - `hermes_cli/memory_setup.py`: systemd/.env inheritance hint ## Fixes applied on top of @benfrank241's work 1. **Security:** `templates.py` apply_template() and probe_existing_customization() now use `open_credentialed_url()` instead of bare `urlopen()` — prevents credential leak via cross-origin redirect. 2. **Efficiency:** `agent_init.py` guards `unavailable_reason()` with the dedup set check before calling it — was running disk I/O + importlib probes on every gateway turn despite the warning being deduped. 3. **Abstraction:** `INDICATOR_GLYPH` in core is now a generic brain (🧠) instead of Hindsight's brand mark. Hindsight overrides with its own `_HINDSIGHT_GLYPH` (👁️). ## Validation - 129/129 targeted tests pass (7 test files) - Ruff clean - E2E smoke tests pass with real imports Contributor: @benfrank241 (ben.bartholomew@vectorize.io) — authorship preserved via commit 1.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85494",
          "createdAt": "2026-08-13T17:34:49Z",
          "updatedAt": "2026-08-13T17:45:27Z",
          "timestamp": "2026-08-13T17:45:27Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/cli",
            "comp/plugins",
            "tool/memory",
            "P3"
          ],
          "author": "kshitijk4poor",
          "state": "closed",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:66b2b787a300bd163136",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85349",
        "event": "changed",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [
          "updatedAt"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85349",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(compress): do not publish micro-compaction when DB sync fails",
          "text": "## What does this PR do? Micro-compaction spliced the rolling summary into the in-memory transcript, then called `_sync_micro_compact_to_db` and swallowed any exception. Resume then loaded the still-active originals on top of the already-compacted list. Rehydrated marker text and generated micro summaries also skipped the batch path's strict redaction, so a legacy secret in an old marker could re-enter the aux prompt and the session DB. `_sync_micro_compact_to_db` now returns success/failure. A failed persist restores the previous cursor and summary and returns the pre-splice messages. Rehydrated marker text and generated micro summaries go through `_redact_compaction_text` before they are stored or sent. ## Related Issue Fixes #84723 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `_sync_micro_compact_to_db` returns `True` on success / no DB, `False` on write exception. - Absorb path rolls back cursor/summary and keeps the original list when persist fails. - Rehydrate and generated micro summaries are redacted before prompt/DB. - Tests: persist-fail keeps the pre-splice transcript; leaked summarizer output and a legacy marker secret do not survive publish or the aux prompt. ## How to Test 1. On `origin/main`, `test_failed_db_sync_keeps_pre_splice_transcript`, `test_generated_micro_summary_is_redacted_before_publish`, and `test_rehydrated_secret_is_redacted_before_summarizer_prompt` fail. 2. `scripts/run_tests.sh tests/agent/test_micro_compaction.py tests/agent/test_compaction_redaction_boundaries.py` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin arm64) ### Documentation & Housekeeping - [x] I've updated relevant documentation — or N/A - [x] I've updated `cli-config.yaml.example` — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` — or N/A - [x] I've considered cross-platform impact — or N/A - [x] I've updated tool descriptions/schemas — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85349",
          "createdAt": "2026-08-13T14:20:09Z",
          "updatedAt": "2026-08-13T17:45:24Z",
          "timestamp": "2026-08-13T17:45:24Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "P2",
            "sweeper:risk-session-state",
            "area/sessions",
            "area/compression"
          ],
          "author": "Christopher-Schulze",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:d04a0984b6744ed74c01",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85467",
        "event": "changed",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [
          "updatedAt",
          "state"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85467",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(relay): session-span segmentation for continuous sessions (salvage #85009)",
          "text": "## Summary Salvage of #85009 by @victor-kyriazakos: opt-in Relay session-scope segmentation so continuous gateway sessions export session-level telemetry at turn boundaries instead of holding one unexported span open for weeks — plus a LIFO-safety fix and docs on top. Relay export is close-driven; a Telegram/Slack session that never ends means the session root span (and its marks) never exports, and a crash loses the whole open segment. Turn spans already export per-turn (#83517); this handles the session-scope remainder. ## Changes - `agent/relay_runtime.py`: `gateway.telemetry.session_segments.{on_compaction,max_turns}` (both default OFF → byte-identical lifecycle); rotation consumed only in `begin_turn`, rides the bounded scope-op executor (#83514); segments share `session_id` + `hermes.session.segment` / `segment_reason` metadata (contributor's work, authorship preserved) - `agent/conversation_compression.py`: observer-only compaction notification (contributor's work) - **Fix on top:** the legacy rotating-compaction path closed the old session scope immediately — a compaction completing while a turn was still live on the old session would pop the session scope under the live turn scope (LIFO violation). Now defers via `close_pending`, consumed by that turn's `end_turn` after its own scope pops. Sabotage-verified: the new regression test fails without the fix. - **Docs on top:** `observability/nemo_relay` section on the built-in plugins page (the plugin had no section) with the `session_segments` config table; summary mirrored in the plugin README. ## Validation | | Result | |---|---| | PR test suite | 12/12 (contributor's 11 + 1 new mid-turn deferred-close regression test) | | All relay tests | 50/50 | | Sabotage run | new test fails at the exact assertion with the fix reverted | | Defaults-off contract | pinned by `TestDefaultsNeverRotate` | | Attribution audit | clean | ## Infographic ![Relay session-span segmentation](https://files.catbox.moe/mpxfpr.png) Closes #85009.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85467",
          "createdAt": "2026-08-13T17:24:11Z",
          "updatedAt": "2026-08-13T17:45:19Z",
          "timestamp": "2026-08-13T17:45:19Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "P3",
            "telemetry",
            "sweeper:risk-session-state",
            "area/compression"
          ],
          "author": "teknium1",
          "state": "closed",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:fd670112c33b6e9b9d69",
        "signalId": "github:NousResearch/hermes-agent:pull_request:65515",
        "event": "discovered",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:65515",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): default context lookup for empty model IDs",
          "text": "## Summary This is the focused current-main replacement for the unique safety fix in #26873. `get_model_context_length()` can be called while a runtime/model selection is still empty. Before this guard, an empty or `None` model flowed into provider-prefix normalization and then into cache/probe logic. Return the documented 256K fallback immediately instead. The replacement intentionally excludes the unrelated runtime-switch, Feishu, cron, auxiliary-client, metadata, and compressor changes from #26873. Those concerns overlap with later upstream work and should not be resurrected as one conflicted patch. ## Verification - `scripts/run_tests.sh tests/agent/test_model_metadata.py -q` — 122 passed - `ruff check agent/model_metadata.py tests/agent/test_model_metadata.py` - `python -m py_compile agent/model_metadata.py tests/agent/test_model_metadata.py` - `git diff --check` Closes the unique empty-model failure mode extracted from #26873; the historical PR remains separate until maintainers decide how to handle its now-overlapping runtime-switch changes.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/65515",
          "createdAt": "2026-07-16T08:47:19Z",
          "updatedAt": "2026-08-13T17:44:20Z",
          "timestamp": "2026-08-13T17:44:20Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "P2",
            "sweeper:risk-compatibility",
            "sweeper:blast-contained"
          ],
          "author": "whirmill",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:3b41637e45d50aeefc14",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85498",
        "event": "discovered",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85498",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): default context lookup for empty model IDs (salvage #65515) — un-reds main CI",
          "text": "## Summary Every open PR's CI is currently red on one shard with: ``` FAILED tests/run_agent/test_primary_runtime_restore.py::TestTryRecoverPrimaryTransport::test_allowed_for_nous_anthropic_messages ValueError: Model has a context window of 32,000 tokens, which is below the minimum 64,000 required by Hermes Agent. ``` (Seen on #85444 slice 7/12, #85452 slice 2/12; reproduces locally on plain `upstream/main` files.) Root cause: the test constructs an agent with `model=\"\"` against the live Nous portal URL. `get_model_context_length(\"\")` reaches `_resolve_endpoint_context_length`, whose fuzzy matcher ```python if model in key or key in model: ``` is **vacuously true for an empty model** — `\"\" in key` matches every entry — so it picks an arbitrary model from the live `/v1/models` response and returns that entry's context length. The portal catalog changed recently; the arbitrary match now lands on a 32K entry, `init_agent` raises the 64K-floor `ValueError`, and the junk value is persisted under a `@https://inference-api.nousresearch.com/v1` cache key (visible in the failing log: `Cached context length @https://... -> 32,000 tokens`). This is a salvage of #65515 by @whirmill (open since July 16), rebased onto current main — the same guard, now positioned after the malformed-base_url normalization that landed since, plus an explanatory comment documenting the fuzzy-match footgun. Contributor authorship preserved on the commit. ## Changes - `agent/model_metadata.py`: a blank/empty model id falls back to `DEFAULT_FALLBACK_CONTEXT` immediately — before any cache write or network probe. - `tests/agent/test_model_metadata.py`: `test_empty_model_uses_fallback_context` (covers `\"\"` and `None`). ## Validation - The two previously failing/new tests pass: `test_allowed_for_nous_anthropic_messages` + `test_empty_model_uses_fallback_context`. - Full `tests/agent/test_model_metadata.py` + `tests/run_agent/test_primary_runtime_restore.py`: 92 passed. - Mutation check: removing the guard makes `test_empty_model_uses_fallback_context` fail (arbitrary live-catalog match returns non-default), restoring goes green. - `ruff check` clean. Closes #65515.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85498",
          "createdAt": "2026-08-13T17:43:57Z",
          "updatedAt": "2026-08-13T17:43:57Z",
          "timestamp": "2026-08-13T17:43:57Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "kshitijk4poor",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:cacd826a53d39bee31ff",
        "signalId": "github:NousResearch/hermes-agent:issue:58596",
        "event": "discovered",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:58596",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "DaemonThreadPoolExecutor crashes on Python 3.14: `_initializer` attribute removed",
          "text": "## Bug Description `DaemonThreadPoolExecutor._adjust_thread_count()` references `self._initializer` and `self._initargs`, which no longer exist in Python 3.14. This breaks **all** concurrent features: `delegate_task`, async delegation, skills hub fan-out, and memory sync. ``` Error during OpenAI-compatible API call: 'DaemonThreadPoolExecutor' object has no attribute '_initializer' ``` ## Root Cause Python 3.14 changed `ThreadPoolExecutor` internals: | | Python ≤ 3.13 | Python ≥ 3.14 | |---|---|---| | Initializer storage | `self._initializer`, `self._initargs` (instance attrs) | `self._create_worker_context()` returns a `WorkerContext` | | `_worker()` signature | `_worker(executor_ref, work_queue, initializer, initargs)` (4 params) | `_worker(executor_ref, ctx, work_queue)` (3 params) | | Context management | None | `WorkerContext.initialize()` / `ctx.finalize()` lifecycle | The code at `tools/daemon_pool.py:58-59` directly accesses the removed attributes. ## Affected Code File: `tools/daemon_pool.py`, method `_adjust_thread_count`, lines 55-60: ```python args=( weakref.ref(self, weakref_cb), self._work_queue, self._initializer, # AttributeError in 3.14 self._initargs, # AttributeError in 3.14 ), ``` ## Reproduction ``` Python 3.14.6 from tools.daemon_pool import DaemonThreadPoolExecutor e = DaemonThreadPoolExecutor(max_workers=1) e.submit(lambda: 42) # -> AttributeError: 'DaemonThreadPoolExecutor' object has no attribute '_initializer' ``` ## Proposed Fix Detect `_worker` signature at import time and branch accordingly: ```python import inspect from concurrent.futures.thread import _worker _worker_uses_ctx = len(inspect.signature(_worker).parameters) == 3 # In _adjust_thread_count: if _worker_uses_ctx: # Python >= 3.14: _worker(executor_ref, ctx, work_queue) args = ( weakref.ref(self, weakref_cb), self._create_worker_context(), self._work_queue, ) else: # Python <= 3.13: _worker(executor_ref, work_queue, initializer, initargs) args = ( weakref.ref(self, weakref_cb), self._work_queue, self._initializer, self._initargs, ) ``` This fix: - Uses `inspect.signature(_worker)` at import time (zero overhead per call) - Passes `self._create_worker_context()` for 3.14 (returns a `WorkerContext` with `initialize()`/`finalize()` lifecycle) - Falls back to old `_initializer`/`_initargs` for <= 3.13 - Preserves the original semantics (daemon=True, no `_threads_queues` registration) ## Environment - Hermes Agent: v0.18.0 (2026.7.1) - Python: 3.14.6 - Platform: aarch64-linux-android (Termux) - Verified: patch applied locally, `DaemonThreadPoolExecutor` creates, submits tasks, and shuts down correctly ## Impact **Critical** — on Python 3.14, every feature using `DaemonThreadPoolExecutor` crashes: `delegate_task`, `async_delegation`, `skills_hub`, `memory_manager`, `tool_executor`.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/58596",
          "createdAt": "2026-07-05T01:29:33Z",
          "updatedAt": "2026-08-13T17:43:57Z",
          "timestamp": "2026-08-13T17:43:57Z",
          "metrics": {
            "reactions": 3,
            "comments": 7
          },
          "labels": [
            "type/bug",
            "comp/tools",
            "tool/delegate",
            "P2",
            "sweeper:risk-compatibility"
          ],
          "author": "iamzulx",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e1a0737cd50e89eec4d5",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85274",
        "event": "changed",
        "observedAt": "2026-08-13T17:47:07.884300Z",
        "changedFields": [
          "text",
          "updatedAt"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85274",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): bound large composer image previews",
          "text": "## What does this PR do? Fixes #41169. Supersedes #68744 while preserving David Metcalfe's original authorship on its two commits, replayed onto current `main`. Follow-up commits harden the fix for the reproduced 72-image failure and the async races exposed during independent review. The Desktop composer previously retained full-resolution image data URLs and rendered them as attachment thumbnails. Large images forced Chromium to decode and rasterize full-size sources on renderer display surfaces. With 72 images, the renderer emitted: ```text Failed to serialize op in 16777152 bytes webContents became unresponsive render-process-gone reason=crashed exitCode=133 ``` The `Failed to serialize op` message comes from Chromium's raster/PaintOp path, not Electron IPC. The parent Electron process, backend, and gateway remained alive; Electron replaced the failed renderer. The original PR's 2048×2048 thumbnail ceiling can itself decode to 16,777,216 RGBA bytes — 64 bytes above the logged 16,777,152-byte PaintOp serialization failure — so the hardened ceiling is intentionally 512px rather than 2048px. This PR bounds the complete composer-preview pipeline rather than only the final `<img>` dimensions: - serializes local/gateway reads and thumbnail creation so only one full image is read/decoded at a time; - creates display thumbnails with `createImageBitmap` + `OffscreenCanvas`, with a 512px longest edge; - closes decoded `ImageBitmap` resources and fails closed to a 1×1 placeholder instead of rendering the original when conversion is unavailable or fails; - retains only the bounded `thumbnailUrl` in new composer attachment state, rather than dozens of multi-megabyte full-resolution data URLs; - reads the original path only when the lightbox is opened, then releases that data URL when the lightbox closes; - keeps model input full resolution: submit/upload reads the authoritative on-disk original independently of the display thumbnail; - binds async preview completion to a renderer-lifetime per-occurrence UUID that survives session-draft cloning, so both A → B → A switching and remove + same-path reattach remain race-safe; - retains local-first reads with gateway fallback for local and remote project paths; - after cross-filesystem staging rewrites `path`, falls back to the preserved original host path in `detail` for lightbox/download; - uses the bounded thumbnail for the optimistic in-flight message bubble too, and never falls back to rendering `@image:<path>` while a queued thumbnail is still pending. No new custom protocol, URL allowlist, or navigation permission is introduced. The existing `MEDIA:` / transcript image path tracked by #42109 remains out of scope. ## Related Issue Fixes #41169 Supersedes #68744. A maintainer can close the conflicted original after accepting this attributed replacement. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [x] ♻️ Refactor (no intended external behavior change beyond fixing the failure) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `apps/desktop/src/lib/image-resize.ts` - serial one-at-a-time resize queue; - 512px default longest edge; - explicit bitmap cleanup and fail-closed fallback. - `apps/desktop/src/app/chat/hooks/use-composer-actions.ts` - serializes the full read + resize pipeline; - stores only the bounded thumbnail for new image chips; - updates only the exact optimistic attachment occurrence captured before async preview work, preserving identity across draft clones while preventing same-path replacement races. - `apps/desktop/src/lib/desktop-fs.ts` - centralizes local-first image reads with active-gateway fallback. - `apps/desktop/src/app/chat/composer/attachments.tsx` - renders bounded thumbnails; - keys each pill by occurrence identity so a removed occurrence's deferred lightbox read cannot populate a same-path replacement; - loads the full image on demand for lightbox/download, falls back from a staged backend path to the preserved host path, and releases it on close. - `apps/desktop/src/lib/chat-runtime.ts` - uses the bounded thumbnail in the optimistic in-flight bubble. - `apps/desktop/src/store/composer.ts` - documents thumbnail versus legacy/on-demand full-source semantics; - provides UUID-backed occurrence-identity conditional patching shared by main and tile composers; - patches the main live atom and inactive per-session draft stashes while merging into the latest occurrence state, so thumbnail and staging completions cannot overwrite each other; - removes only the attachment occurrences captured by a successful submit: UUID-backed chips match by occurrence token, while legacy occurrence-less chips match by exact object identity; this preserves newer same-ID file/URL replacements while still clearing successfully staged submitted files. - `apps/desktop/src/app/chat/session-tile-actions.ts` - applies the same occurrence-aware staging merge in independently mounted session-tile composers, rejecting stale upload completion after remove + same-path reattach. - `apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts` - merges cross-filesystem staging metadata into the current occurrence instead of replacing concurrent preview state. Regression coverage verifies: - 72 image reads run one at a time; - 72 bitmap decodes run one at a time and every generated canvas is 512×512 or smaller; - composer state contains 72 bounded thumbnails and no full-resolution preview data; - removing an attachment during queued resize does not resurrect it; - removing and reattaching the same path cannot receive the removed occurrence's late thumbnail; - a delayed thumbnail resolving while another session is active updates only the original session's stashed draft and appears after returning; - thumbnail generation and submit-time staging may finish in either order in both the main and session-tile composers without dropping thumbnail, staged path, session ownership, or upload state; - remove + same-path reattach rejects stale staging completion in both composer scopes; - successful main and tile submits remove only their captured occurrences, preserve newer same-ID image and occurrence-less URL replacements added while submission is in flight, still clear successfully staged legacy files, and emit cleanup state exactly once; - an old occurrence's deferred full-image read cannot open or populate a same-path replacement lightbox; - lightbox full-source data is read only on open and released on close; - a split-filesystem image remains previewable after its `path` is rewritten to a gateway-staged path; - local-first and gateway-fallback paths remain supported; - optimistic messages prefer the bounded thumbnail and render no path-backed image while resize is pending; - resize failures and unsupported APIs fail closed. ## How to Test 1. Attach or paste one large screenshot and verify the composer remains responsive. 2. Attach many large images (the reproduced report used 72) and verify the renderer remains responsive. 3. Verify attachment pills and the optimistic user bubble render bounded thumbnails. 4. Open/download an attachment and verify the original full-resolution image is used. 5. Send the prompt and verify the model receives the original image, not the thumbnail. 6. Remove a chip while thumbnails are still being created and verify it does not reappear. Automated verification on CachyOS Linux with Node 22.23.2: ```bash LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 TZ=UTC NODE_ENV=test \\ NODE_OPTIONS='--max-old-space-size=8192 --localstorage-file=/tmp/hermes-pr68744-full-ui-en-localstorage.json' \\ npm --workspace apps/desktop run test:ui # 423 files passed; 3,819 tests passed NODE_ENV=test NODE_OPTIONS='--max-old-space-size=8192 --localstorage-file=/tmp/hermes-pr68744-exact-localstorage.json' \\ npm --workspace apps/desktop exec -- vitest run --project ui \\ src/lib/image-resize.test.ts \\ src/app/chat/hooks/use-composer-actions.test.ts \\ src/lib/chat-runtime.test.ts \\ src/app/chat/composer/attachments.test.tsx \\ src/lib/desktop-fs.test.ts \\ src/store/composer.test.ts \\ src/app/chat/composer/hooks/use-composer-draft.test.tsx \\ src/app/session/hooks/use-prompt-actions/index.test.tsx \\ src/app/chat/session-tile-attachments.test.tsx # 9 files passed; 216 tests passed NODE_ENV=test npm --workspace apps/desktop run typecheck # passed NODE_ENV=test npm --workspace apps/desktop run lint # 0 errors; repository baseline warnings only, none in changed files NODE_ENV=production npm --workspace apps/desktop run build # production renderer and Electron bundles built; assert-dist-built passed git diff --check # passed ``` Running the full UI suite without forcing an English locale initially produced four locale-sensitive assertion failures (`1 234 567`/`25 USD` formatting versus en-US expectations). Re-running under the CI locale passed all 3,813 tests. Mutation proof: in an isolated worktree of the exact candidate, bypassing the end-to-end preview queue made the 72-image regression fail with `maxActiveReads = 72` versus the required `1`. Restoring the candidate returned the focused suite to 74/74. ## Live CI Exact head `cd265e005e6a49319d97ca2fcf1b1d59edf4bf76`: [CI run 31726943548](https://github.com/NousResearch/hermes-agent/actions/runs/31726943548) completed successfully. `All required checks pass` is green, including Desktop UI, Desktop platform tests, lint, OSV, and both amd64/arm64 Docker builds. Final failed/pending counts: **0 / 0**. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] I searched for existing PRs; this is an attributed current-main replacement for conflicted #68744 - [x] My PR contains only changes related to this fix - [x] I've run the relevant Desktop automated tests - [x] I've added regression tests for the reported 72-image case - [x] I've tested on CachyOS Linux with Node 22.23.2 and a production Electron build ### Documentation & Housekeeping - [x] User documentation — N/A; no user-facing command or configuration changed - [x] `cli-config.yaml.example` — N/A; no config keys changed - [x] `CONTRIBUTING.md` / `AGENTS.md` — N/A; no workflow contract changed - [x] Cross-platform impact considered — uses Chromium/Electron-standard APIs with a safe fallback - [x] Tool descriptions/schemas — N/A; no agent tool changed ## For New Skills N/A — this PR does not add a skill.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85274",
          "createdAt": "2026-08-13T12:11:02Z",
          "updatedAt": "2026-08-13T17:47:18Z",
          "timestamp": "2026-08-13T17:47:18Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "P3",
            "comp/desktop"
          ],
          "author": "frizikk",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:ea13f4f19d4a2ec21fc7",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85508",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85508",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cron): honest fallback/timeout/drift alerts + drift-guard alert-once",
          "text": "Cron failure alerts stop lying about fallback chains, timeouts, and drift — and drift alerts fire once per job instead of once per tick. Salvage of #85011 (@victor-kyriazakos), which itself carries #81579 (@georgell-ceo); reconciled with #72056 (@sashmatash), the earliest submission of the drift-alert-delivery half. All contributor authorship preserved via cherry-pick. ## Changes - `cron/scheduler.py`: `_fallback_chain_phrase()` distinguishes \"no chain configured\" (with the fix commands: `hermes fallback add`, or `cron.model` + `cron.model_provider`) from genuine exhaustion (#81579 + #85011). - `cron/scheduler.py`: the scheduler's own inactivity watchdog (`idle for Ns (limit Ns)`) is classified before the generic timeout match instead of being reported as a provider timeout (#81579). - `cron/scheduler.py`: sibling site widened — the TERMINAL_CWD lock-wait `TimeoutError` (#79768) also no longer falls through as \"provider timeout\" (our follow-up; gap flagged in #81579 review). - `cron/scheduler.py` + `cron/jobs.py`: #44585 drift-guard skips alert once per job via a persisted `drift_alerted` bit with a `:silent` marker (same shape as the blocked-config preflight, #73506); cleared on the next successful run so future drift re-alerts (#85011). - Drift alert delivered untruncated (bypasses the summarizer's 180-char cap) and with lifecycle-aware remediation from #72056: recurring jobs get the pin command, consumed finite one-shots are told to recreate the job. - `website/docs/user-guide/features/cron.md`: drift-guard tip documents alert-once and the one-shot recreation path. ## Validation | | Before | After | |---|---|---| | No chain configured, provider fails | \"Fallback chain was exhausted or unavailable.\" | \"No fallback chain configured — add one with `hermes fallback add`, or…\" | | Inactivity watchdog trips | \"provider timeout. Fallback chain was exhausted…\" | \"the job itself stalled — no tool/API activity…\" | | TERMINAL_CWD lock timeout | \"provider timeout. Fallback chain was exhausted…\" | \"could not acquire the scheduler's working-directory lock…\" | | 40 unpinned jobs after config change | identical drift alert per job per tick | one alert per job; re-arms when drift heals | | Consumed finite one-shot drifts | told to `cronjob action=update` a dead job | told to create a new one-shot with explicit provider/model | Tests: 13 new across 4 files, asserting delivered content through `run_one_job` + `_deliver_result`. Full cron suite: 578 passed, 1 skipped. Closes #85011, closes #81579, closes #72056. ## Infographic ![Honest cron alerts](https://files.catbox.moe/icm527.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85508",
          "createdAt": "2026-08-13T17:58:34Z",
          "updatedAt": "2026-08-13T18:01:56Z",
          "timestamp": "2026-08-13T18:01:56Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:1608573c24eb2f579e50",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85511",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85511",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(tools): symlink-safe exclusive creation for all spill/cache writers",
          "text": "## Summary Spill-file writes can no longer be redirected through a planted symlink, and raw pre-redaction spills are now owner-only instead of world-readable. Root cause: all four spill/cache writers (terminal overflow tee, hook context spill, web_extract full-text store, subagent summary spill) used plain `open()`/`Path.write_text` into predictable directories under `~/.hermes` — which follows a pre-existing symlink, so any local process able to write to those directories could divert our write onto an arbitrary user-owned file (`~/.bashrc`, `authorized_keys`, ...). Terminal and hook spills — which hold raw output BEFORE secret redaction — also landed world-readable under the default umask. ## Changes - `tools/spill_safety.py` (new): `ensure_spill_dir` / `open_exclusive` / `write_text_exclusive` — create with `O_CREAT|O_EXCL|O_NOFOLLOW` (a link-shaped path fails the write instead of following it); overwrite goes through lstat-checked unlink + exclusive re-create, so it removes only the link, never touches its target, and cannot be raced. Two tiers: `private=True` forces `0o700` dir / `0o600` file. - `tools/environments/base.py`: terminal overflow tee → private + exclusive. - `tools/terminal_tool.py`: post-redaction spill rewrite → symlink-safe overwrite (covers a link planted between collector write and redaction). - `tools/hook_output_spill.py`: hook context spill → private + exclusive. - `tools/web_tools.py`, `tools/delegate_tool.py`: exclusive create, umask perms kept — `cache/web` and `cache/delegation` are bind-mounted into remote backends (Docker/Modal/SSH) whose container UID must still read them. - `tests/tools/test_spill_safety.py`: 12 tests including the planted-symlink, dangling-symlink, symlinked-dir, and overwrite-keeps-target cases. ## Validation | | Before | After | |---|---|---| | Symlink at spill path | write follows link into target | `OSError`, target untouched (E2E: all 4 sites) | | Raw terminal/hook spill perms | umask (0644 typical) | 0600 in 0700 dir | | Normal spill content | — | byte-identical (21 existing spill tests pass) | E2E-tested against real functions with isolated `HERMES_HOME`: hook spill attack refused with \"spill write failed\" preview, collector attack leaves victim intact and command execution unbroken, web_extract re-extraction overwrite still works, remote-mount cache files remain readable. Pattern adapted from DeepSeek Harness `dsh-spill-local` (MIT): private spill root + exclusive owner-only opens. ## Infographic ![Spill files: symlink-safe](https://files.catbox.moe/abz8f6.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85511",
          "createdAt": "2026-08-13T17:59:22Z",
          "updatedAt": "2026-08-13T18:01:54Z",
          "timestamp": "2026-08-13T18:01:54Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:214310c3c69b3dd6678f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85510",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85510",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(xai): preserve xhigh effort and priority service_tier for Grok 4.6",
          "text": "## Summary Grok 4.6 is the first xAI Responses model to accept `reasoning.effort=xhigh` and xAI Priority Processing (`service_tier`), but Hermes' provider-wide xAI guards silently clamped xhigh→high and stripped `service_tier` for every Grok model — so 4.6 users were silently downgraded. This model-gates both guards on the 4.6 family and exposes the `/fast` toggle for it. Fixes #84799. Salvages #84820 by @fangliquanflq (cherry-picked, authorship preserved), the tighter of two competing fixes (exact-slug family matcher + priority-value check on the service_tier preserve). #84848 by @Chukwuebuka-2003 was submitted 1h21m later with the same coverage — both credited; we carried over its docstring line for `resolve_fast_mode_overrides` as a follow-up commit. ## Changes - `agent/model_metadata.py` (@fangliquanflq): `is_grok_46_family()` matcher. - `agent/transports/codex.py` (@fangliquanflq): xhigh clamp and `service_tier` strip both model-gated on the 4.6 family. - `hermes_cli/models.py` (@fangliquanflq): `/fast` toggle exposed for Grok 4.6 (`model_supports_fast_mode`), falls through to `{\"service_tier\": \"priority\"}`. - Tests (@fangliquanflq): transport preserve/strip/clamp matrix + fast-command coverage. - Docstring follow-up (ours), mirroring #84848. ## Validation | | Result | |---|---| | `test_codex_transport.py` + `test_fast_command.py` + `test_model_metadata.py` | 169/169 pass | | Stale-base gate | 0 behind, 5-file diff | ## Infographic ![Grok 4.6 wire capabilities — xhigh + priority preserved](https://files.catbox.moe/3xg4ww.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85510",
          "createdAt": "2026-08-13T17:58:39Z",
          "updatedAt": "2026-08-13T18:02:12Z",
          "timestamp": "2026-08-13T18:02:12Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:b1e88dc71b8ab8a4087f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:41351",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:41351",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(acp): resolve symlink targets when guarding sensitive edit auto-approval",
          "text": "## What does this PR do? The ACP edit-approval guard promises that sensitive files (`.env`, `.ssh`, `.git`, `id_rsa`, `id_ed25519`) always prompt the user, even when a session runs under an autonomous policy (`session` / `workspace_session`). It did not hold for symlinks. `should_auto_approve_edit` resolves the path with `Path(...).resolve()` for the workspace-containment check, but `_is_sensitive_auto_approve_path` inspected only the *unresolved* literal path. So a symlink with a harmless name — e.g. `notes.txt` -> `~/.ssh/authorized_keys`, or `link.txt` -> `.env` inside the workspace — passed the sensitive check and was silently auto-approved, writing straight through to the protected file. A repo can ship such a symlink (git tracks them), so an autonomous editor session could have a secret overwritten without a prompt. Before: `_is_sensitive_auto_approve_path(\"link.txt\")` looks at the name `link.txt`, sees nothing sensitive, returns `False` -> auto-approved. After: the guard also checks `link.txt`'s `resolve()`d target (`.env`), matches the sensitive set, returns `True` -> the edit prompts instead of auto-approving. ## Related Issue N/A ## Type of Change - [x] 🔒 Security fix ## Changes Made - `acp_adapter/edit_approval.py`: split the sensitivity test into a reusable `_path_is_sensitive(Path)` helper and make `_is_sensitive_auto_approve_path` check both the expanded literal path and its `resolve(strict=False)` target, so an innocently named symlink can no longer launder a write to a protected file. `resolve()` failures (e.g. symlink loops) fall back to treating only the literal path, matching the prior conservative behavior. - `tests/acp/test_edit_approval.py`: add `test_symlink_to_sensitive_file_is_not_auto_approved`, covering a workspace symlink to `.env` (under both `session` and `workspace_session`) and to `.ssh/authorized_keys`, asserting none are auto-approved. ## How to Test 1. `scripts/run_tests.sh tests/acp/test_edit_approval.py` — the new `test_symlink_to_sensitive_file_is_not_auto_approved` passes; `test_workspace_auto_approval_allows_workspace_and_tmp_but_not_sensitive` still passes (real workspace files keep auto-approving). 2. Manually: in a workspace, `ln -s .env link.txt`, drive an ACP session under `workspace_session`/`session` policy, and confirm an edit to `link.txt` now raises an approval prompt instead of being auto-applied. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits (`fix(scope):`, etc.) - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains only changes related to this fix - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes - [x] I've tested on my platform: macOS 15 (Darwin 25.5.0) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/41351",
          "createdAt": "2026-06-07T15:03:25Z",
          "updatedAt": "2026-08-13T18:01:18Z",
          "timestamp": "2026-08-13T18:01:18Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/security",
            "comp/acp",
            "area/auth",
            "P2",
            "sweeper:risk-security-boundary",
            "sweeper:blast-moderate"
          ],
          "author": "Vesna-9",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:344a8c235dd9d2aa6b1e",
        "signalId": "github:NousResearch/hermes-agent:pull_request:37977",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:37977",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(plugins): require renewed review before plugin updates",
          "text": "## Summary Plugin updates now use a staged review transaction instead of modifying an enabled plugin in place. - Copy the installed Git plugin into a private quarantine directory and fast-forward only the staged copy. - Validate the manifest and exact old/new commits, compute a deterministic staged-content hash, inventory changed files, and run the existing skills AST audit as a diagnostic scan. - Return the same review payload to the CLI and dashboard, including the candidate revision, content hash, changed files, scan findings, and `after-install.md` content. - Require explicit acceptance using a review token bound to the plugin key, old commit, new commit, and staged content hash. - Revalidate both the live and staged trees at acceptance time, then promote atomically with rollback. The live enabled plugin remains unchanged until this succeeds. - Append `STAGED` and `ACCEPTED` events to a JSONL audit log. - Bind tool-override consent to the exact installed plugin revision. Updates revoke an earlier grant unless the operator explicitly renews it for the reviewed revision. Closes #37976 ## Review feedback addressed - The live plugin directory is no longer pulled before review; update preparation occurs in a private quarantine directory. - CLI and dashboard use one staged transaction and one exact-token acceptance path. - The dashboard API and UI now surface the review fields and provide explicit accept/cancel controls. - Tool-override permission is revision-bound and fails closed when plugin content changes. - End-to-end local-Git coverage proves that staging leaves the live commit/content untouched, exact acceptance promotes the reviewed commit while preserving enabled state, stale or mutated review state is rejected, and override renewal is explicit. ## Tests and validation - `scripts/run_tests.sh tests/hermes_cli/test_plugins_cmd.py tests/hermes_cli/test_plugins.py` — 217 passed, 0 failed. - `npm run typecheck --workspace=web` — passed. - `cd web && npx eslint src/pages/PluginsPage.tsx src/lib/api.ts` — passed. - `ruff check hermes_cli/plugins.py hermes_cli/plugins_cmd.py hermes_cli/web_server.py tests/hermes_cli/test_plugins.py tests/hermes_cli/test_plugins_cmd.py` — passed. - `git diff --check` — passed. Rebased onto `477c08b44766ace8b890faa72bf82ecbcf2b3ba8`; current head is `9b631420294693b0ce82471edad65ec69418b675`.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/37977",
          "createdAt": "2026-06-03T06:44:38Z",
          "updatedAt": "2026-08-13T18:01:14Z",
          "timestamp": "2026-08-13T18:01:14Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/security",
            "comp/cli",
            "comp/plugins",
            "P3",
            "sweeper:blast-moderate",
            "comp/dashboard"
          ],
          "author": "coygeek",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:bbbed80b863cb85c82aa",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85500",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85500",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(file): fail closed on silent read transport",
          "text": "## Summary - reject empty or malformed successful size/line-count probes instead of coercing them to zero - reject empty in-range content for a known nonempty regular file - add transport-shaped regressions for linked-worktree and delegation-summary false empties ## Verification - `scripts/run_tests.sh tests/tools/test_file_operations_edge_cases.py tests/tools/test_file_operations.py tests/tools/test_read_shell_line_clamp.py` (97 passed, 2 platform skips) - `.venv/bin/ruff check tools/file_operations.py tests/tools/test_file_operations_edge_cases.py` - real `ShellFileOperations.read_file` smoke on this linked worktree and a delegation-cache summary",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85500",
          "createdAt": "2026-08-13T17:53:35Z",
          "updatedAt": "2026-08-13T18:01:07Z",
          "timestamp": "2026-08-13T18:01:07Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "tool/file",
            "P2"
          ],
          "author": "ajoslin",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:bf20852bf527e7c752a0",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85502",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85502",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(kanban): terminalize iteration exhaustion",
          "text": "## Summary - make Kanban iteration-budget exhaustion terminal for the exact run/revision instead of routing it through retryable wall-clock timeout handling - preserve workspace/evidence, record `retryable=false` and `resume_policy=never`, and prevent automatic claim/unblock of the exhausted card - notify and wake the owner for replan while retaining the existing delivery-mode boundary - keep normal wall-clock timeout retry behavior unchanged ## Why The iteration ceiling is an emergency stop. Retrying the same task revision after it has exhausted its model/tool budget can repeat the same broad run and risks obscuring the preserved partial artifact. Continuing work should use an owner-planned replacement revision that references the preserved workspace. ## Tests ```text 17 passed, 5 third-party deprecation warnings ruff: pass py_compile: pass git diff --check: pass ``` Focused coverage includes: - exact-run terminal task/run state - stale-finalizer guard against a successor run - idempotent repeated finalization - preserved workspace/evidence and terminal metadata - no automatic claim or unblock - notifier text and owner wake context - unchanged retryable wall-clock timeout semantics",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85502",
          "createdAt": "2026-08-13T17:55:04Z",
          "updatedAt": "2026-08-13T18:01:03Z",
          "timestamp": "2026-08-13T18:01:03Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "comp/gateway",
            "comp/cron",
            "P3",
            "sweeper:risk-message-delivery"
          ],
          "author": "stigrunar",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e98a426fdcb90aee3787",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85503",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85503",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(whatsapp): strip the multi-device suffix when normalizing bot ids",
          "text": "## Bug Baileys hands the bridge its own identity **with a device index** — `sock.user.id` is `\"15551234567:17@s.whatsapp.net\"` — while inbound `mentionedJid` entries and `contextInfo.participant` arrive **without** one. Both normalizers (`normalizeWhatsAppId` in `bridge_helpers.js`, `_normalize_whatsapp_id` in `whatsapp_common.py`) folded the first `:` into an `@` instead of stripping the suffix, producing a malformed two-`@` id: ``` \"15551234567:17@s.whatsapp.net\" → \"15551234567@17@s.whatsapp.net\" ← never equals the clean form ``` So every set-membership comparison of `botIds` against clean inbound ids silently failed. ## User-visible impact | Path | Effect | |---|---| | `_message_mentions_bot` — `mentionedIds ∩ botIds` | Never matched. @-mentions only kept working through the bare-number **substring fallback** on the body text. | | `_message_is_reply_to_bot` — `quotedParticipant ∈ botIds` | Never matched. Under `require_mention`, **quote-replying the bot's own answer does not re-trigger it** — the natural \"reply to the bot in a group\" flow is dead, with no fallback. | | `reply_to_is_own_message` | Always `False` — the gateway renders `[Replying to: ...]` instead of `[Replying to your previous message: ...]`, and downstream logic keyed on it never fires. | ## Why this is clearly a bug, from the tree itself - The **owner message gate** in `bridge.js` strips the suffix inline (`.replace(/:.*@/, '@')`) when resolving \"my\" number, with a comment documenting the `sock.user.id` shape. - `gateway/whatsapp_identity.normalize_whatsapp_identifier` both splits on `:` and lists `\"60123456789:47@s.whatsapp.net\"` in its docstring as a shape \"the WhatsApp bridge may emit\". Only the `botIds` path missed it — and the existing tests never caught the mismatch because they hand-write clean botIds (`tests/gateway/test_whatsapp_group_gating.py`, `bridge.native.test.mjs`). ## Fix Strip `\":<device>\"` before the `\"@\"` in **both** normalizers, so any producer/consumer combination converges on the clean JID. No behavior change for already-clean ids, group JIDs, or bare numbers. ## Tests - `tests/gateway/test_whatsapp_botids_device_suffix.py` — drives the mention/quote gates with the **realistic suffixed identities**. **5 of the 6 tests fail against the previous normalization** (the passing one is the substring fallback, matching the analysis above). Includes the mirror case (suffixed `quotedParticipant` vs clean botIds) as defense in depth. - `bridge.native.test.mjs` — new `normalizeWhatsAppId` block with the suffixed → clean cases. ``` tests/gateway -k whatsapp: 186 passed, 4 skipped bridge JS test files: all pass ruff check (changed files): clean ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85503",
          "createdAt": "2026-08-13T17:55:19Z",
          "updatedAt": "2026-08-13T18:01:00Z",
          "timestamp": "2026-08-13T18:01:00Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "duplicate",
            "comp/gateway",
            "platform/whatsapp",
            "P2",
            "sweeper:risk-message-delivery"
          ],
          "author": "NehoraiHadad",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:28ad92d800e2b8a58e76",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85505",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85505",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(sessions): keep gateway reset continuations listable (salvage #84009)",
          "text": "## Summary Sessions created after a gateway reset (`/new`, idle/daily timeout, suspension, expired resume recovery) are listable again in every session surface — `/sessions`, `hermes sessions list`, desktop sidebar, dashboard — and are no longer classified as ephemeral (cascade-delete-class) children. Root cause: since d2a4d373eb, gateway resets write `parent_session_id` on the continuation row for durable lineage, but `_LISTABLE_CHILD_SQL` only admitted `branched` parents — every reset continuation was treated as a hidden subagent/compression row, and `_ephemeral_child_sql` classified it as a cascade-delete target. Salvage of #84009 by @embwl0x (cherry-picked, authorship preserved) plus follow-up fixes from review. ## Changes **Salvaged from #84009 (@embwl0x, 2 commits):** - Durable `_reset_from` marker in `model_config` for gateway reset continuations (survives parent reopen/re-end churn — the mutable `end_reason` heuristic alone regresses to hidden after a parent resume, the same fragility class fixed for `/branch` in #39214) - Legacy same-routing-key fallback in `_RESET_CHILD_SQL` for pre-marker rows; reset children excluded from `_ephemeral_child_sql` - Marker preservation across the `create_session` ON CONFLICT upsert (gateway identity row first, agent enriches later) - `reopen_session()` stamps markerless legacy children durable before clearing the parent's `end_reason`; two raw CLI reopen SQL sites routed through the shared helper - Real-path tests: reset→`/sessions full`, legacy rows per end reason, count parity, migration classification, negative cases **Follow-up commit (review findings):** - `session_switch` added to `_RESET_END_REASONS`: a reset continuation's parent can later be promoted to `session_switch` (resume, then switch away), permanently hiding pre-marker legacy children — reopen-time stamping can't rescue them because the parent is being ended, not reopened. Probe-verified. - `resolve_resume_session_id` forward walker now excludes reset children (marker + legacy shape): resuming a reset parent could redirect into the post-reset conversation — the exact context the user reset away. Compression-tip following unchanged (regression-tested). - Boundary-set dedup: the legacy heuristic is shared via `_legacy_reset_child_sql()` between the listing predicate and the stamping UPDATE, and `find_latest_gateway_session_for_peer`'s two hand-written recovery-fence literals now derive from `_RESET_END_REASONS_SQL` (was a third copy of the same set; its own docstring warned about exactly this drift). ## Validation | Probe (reset child, parent reopened then re-ended) | main | #83987 | #84198 | this PR | |---|---|---|---|---| | fresh reset listed | ✗ | ✓ | ✓ (but still ephemeral) | ✓ | | after parent reopen + re-end | ✗ | ✗ | ✗ | ✓ | | legacy child, parent promoted to `session_switch` | ✗ | ✓ | ✗ | ✓ | - `tests/test_hermes_state.py` 229 passed (3 new); `tests/gateway/test_resume_command.py` + `tests/cli/test_resume_display.py` 42 passed; `tests/hermes_state/` 88 passed; session-reset-notify + continuity + session-API suites green - Walker regression tests mutation-checked (guard fails with the fix reverted, passes restored) - EXPLAIN QUERY PLAN: new predicate arm is an indexed PK probe; reopen stamping uses `idx_sessions_parent` (sub-ms) - ruff clean ## Credit Based on #84009 by @embwl0x — commits cherry-picked with authorship preserved. Same bug also targeted by #83987 (@albert748) and #84198 (@JonthanaHanh), both using the mutable `end_reason` heuristic that regresses after a parent resume; #83987's boundary-reason analysis informed the `session_switch` follow-up. Fixes #83861. Fixes #84109. Closes #84009. Closes #83987. Closes #84198.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85505",
          "createdAt": "2026-08-13T17:57:04Z",
          "updatedAt": "2026-08-13T18:00:52Z",
          "timestamp": "2026-08-13T18:00:52Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/gateway",
            "P1",
            "sweeper:risk-session-state",
            "area/sessions"
          ],
          "author": "kshitijk4poor",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c3b7e99a0d24d8b6a4e1",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85504",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85504",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(providers): discover pip-installed model providers via entry points (salvage #81419)",
          "text": "## Summary `pip install`ed model-provider plugins now register their providers and show their models in the registry, matching filesystem-plugin behavior — gated by the same `plugins.enabled` opt-in the general PluginManager enforces. Salvages #81419 by @albertodepaola (cherry-picked, authorship preserved) with hardening on top. ## Changes - `providers/__init__.py`: new step-0 entry-point scan of the `hermes_agent.plugins` group in `_discover_providers()`; supports `module:func` callables and self-registering bare modules (contributor's work). Follow-up hardening: - honors `plugins.enabled` allow-list / `plugins.disabled` deny-list — installed ≠ loaded - skips callables that require arguments, so `register(ctx)`-style general plugins sharing the group are never invoked (no TypeError warning spam) - discovered FIRST → lowest precedence: last-writer-wins means bundled/`$HERMES_HOME` profiles always beat a pip impostor on name collision - `tests/providers/test_entry_point_discovery.py`: contributor's 3 tests + 3 new (opt-in gate, deny-list wins, `register(ctx)` never invoked); fixed inverted precedence docstring - `website/docs/developer-guide/model-provider-plugin.md`: corrected mechanism wording, documented the config gate with a `plugins.enabled` example ## Validation | Check | Result | |---|---| | `tests/providers/` (56 tests) | pass | | E2E: real pip-built package (`pip install --target`), temp HERMES_HOME | not enabled → skipped; enabled → registered; deny-list → skipped | ## Infographic ![PIP providers via entry points](https://files.catbox.moe/shbze8.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85504",
          "createdAt": "2026-08-13T17:55:28Z",
          "updatedAt": "2026-08-13T18:00:48Z",
          "timestamp": "2026-08-13T18:00:48Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/plugins",
            "P3"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:e583322af35929cda22a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85501",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85501",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(kanban): inherit ALL routing columns in notify-sub inheritance",
          "text": "## Summary Child kanban tasks now inherit EVERY routing/delivery column of their parent's notify subscription — `chat_type`, `user_id_alt`, `delivery_mode`, and `delivery_metadata` included. Fixes #73030. Root cause: `_inherit_notify_subs()` (the link_tasks / triage-decompose / create-with-parents path) copied only platform/chat/thread/user/profile. A DM-originated child completion fell back to `chat_type='group'` and woke a fresh group-scoped session instead of the originating DM; Telegram DM-topic subscriptions lost their persisted reply-fallback metadata. ## Changes - `hermes_cli/kanban_db.py`: `_inherit_notify_subs` copies all routing/delivery columns; the duplicated inline inheritance block in `create_task` (added by #85487) consolidated onto the single-owner helper — one inheritance path, every column, one owner - `tests/hermes_cli/test_kanban_notify.py`: sabotage-verified regression tests for both the `link_tasks` and create-with-parents paths, asserting all four previously-dropped columns ## Validation | | Before | After | |---|---|---| | inherited chat_type / metadata | NULL (dropped) | preserved | | DM child wake | group-scoped fallback session | originating DM | | inheritance code paths | 2 (helper + inline dup) | 1 owner | | targeted tests | — | 22/22 (63/63 across kanban suites) | ## Infographic ![Kanban inheritance fix](https://files.catbox.moe/8uk9xi.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85501",
          "createdAt": "2026-08-13T17:54:01Z",
          "updatedAt": "2026-08-13T18:00:44Z",
          "timestamp": "2026-08-13T18:00:44Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/cron",
            "platform/telegram",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery"
          ],
          "author": "teknium1",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:05716a63f9a92f1b1564",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85506",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85506",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): reject stale 32K metadata underreports for MiniMax (extends Kimi guard)",
          "text": "## Summary MiniMax M2-family models wrongly capped at 32K: stale third-party metadata underreports them as 32,768 (real window 204,800), and both the persisted cache and the OpenRouter/Nous fallback paths trusted it — tripping the 64K minimum and breaking previously-working setups after upgrade. This extends the existing Kimi 32K-underreport guard to a shared `_model_name_suggests_stale_32k_underreport()` covering both families. Salvages #24436 by @luoxiao6645 (cherry-picked, authorship preserved; resolves the remaining half of #24140's class that our generic pre-catalog guard in #85434 did not cover — that guard handles persisted pre-catalog values, this one rejects live 32K underreports). ## Changes - `agent/model_metadata.py`: `_model_name_suggests_minimax()` + `_model_name_suggests_stale_32k_underreport()`; the step-1 cache invalidation, the Nous-path `_safe_ctx`, and the step-6 OpenRouter guard now cover MiniMax alongside Kimi. - `tests/agent/test_model_metadata.py`: cache-invalidation, OR-fallback-rejection, and non-MiniMax-safety tests (contributor's), conflict-resolved onto the current generic-guard code. ## Validation | | Result | |---|---| | `test_model_metadata.py` + `test_minimax_provider.py` | 99/99 pass | | Stale-base gate | 0 behind, 3-file diff | ## Infographic ![MiniMax 32K underreport guard](https://files.catbox.moe/vgt8sk.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85506",
          "createdAt": "2026-08-13T17:58:31Z",
          "updatedAt": "2026-08-13T18:00:40Z",
          "timestamp": "2026-08-13T18:00:40Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/minimax",
            "P2"
          ],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:574dcf440a34a2b972e5",
        "signalId": "github:NousResearch/hermes-agent:pull_request:72367",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:72367",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(web): validate provider-reported final URLs",
          "text": "## Summary - Validate provider-reported final or canonical URLs before returning web content across Firecrawl, Tavily, Exa, and Parallel. - Fail closed for malformed, private, internal, and unresolved final destinations while suppressing unsafe provider metadata and content. - Preserve safe public results and leave Oxylabs behavior unchanged. ## Verification - Focused final-URL safety suite: 26 passed. - Relevant web-tool regression suite: 333 passed. - Ruff, syntax, diff-scope, and static-security checks passed. - Reconciled cleanly as one commit directly on current upstream main. ## Scope Exactly eight files: four provider integrations, the shared URL-safety helper, and three test files. No dependency, lockfile, deployment, service, production, or Oxylabs changes.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/72367",
          "createdAt": "2026-07-27T01:26:33Z",
          "updatedAt": "2026-08-13T17:59:53Z",
          "timestamp": "2026-08-13T17:59:53Z",
          "metrics": {
            "reactions": 0,
            "comments": 8
          },
          "labels": [
            "type/security",
            "comp/plugins",
            "tool/web",
            "area/auth",
            "P2",
            "needs-decision",
            "sweeper:risk-security-boundary",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "shaneblick",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:5b2ade6398020d0bbcaf",
        "signalId": "github:NousResearch/hermes-agent:pull_request:84142",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:84142",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway): preserve message continuity across restarts",
          "text": "## What does this PR do? Makes messaging continuity durable across gateway restarts so a reply to an already-visible message resumes the exact session and transcript instead of opening an empty context, replaying a blank synthetic turn, losing an accepted inbound ask, or producing a duplicate response. The implementation adds a durable continuity journal/binding layer and wires it into inbound dispatch, outbound delivery, delivery-ledger recovery, and continuable cron delivery. It fails closed when a durable claim or binding cannot be established and scopes resolution by platform, chat, thread, profile, and owner. This is broader than the existing restart-recovery PRs #68902, #72305, and #30030: those address final-delivery timing or synthetic resume behavior, while this change also provides durable inbound idempotency, delivered-message reply binding, new-topic/background transcript seeding, recovered-ACK binding, and profile/owner isolation. ## Related Issue Related to #46088 and the restart-continuity work in #68902, #72305, and #30030. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] 🔒 Security fix - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 📝 Documentation update - [x] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - Add `gateway/message_continuity.py` with additive SQLite state for inbound event claims, outbound reply bindings, continuable seeds, diagnostics, retention, and owner-generation guards. - Journal accepted inbound events before agent dispatch; suppress durable CAS losers and replay only recoverable real events. - Bind provider message IDs to the correct session/transcript before retiring delivery obligations, including restart redelivery. - Treat successful recovery ACKs without a stable provider message ID as failed/recoverable instead of falsely delivered. - Seed continuable cron/background transcripts atomically before visible delivery and preserve multiplex profile scope. - Remove blank synthetic interactive restart turns while preserving non-interactive recovery behavior. - Add hermetic unit, integration, concurrency, profile-isolation, and process-boundary restart coverage. ## How to Test 1. Run the focused continuity and scheduler suite: `scripts/run_tests.sh tests/gateway/test_delivery_ledger.py tests/gateway/test_delivery_ledger_producer.py tests/gateway/test_message_continuity_inbound.py tests/gateway/test_message_continuity_outbound.py tests/gateway/test_message_continuity_e2e.py tests/gateway/test_message_continuity_restart.py tests/gateway/test_message_continuity_cron.py tests/gateway/test_message_continuity_diagnostics.py tests/gateway/test_message_continuity_schema.py tests/cron/test_scheduler_continuity_e2e.py tests/gateway/test_restart_resume_pending.py tests/tools/test_cronjob_tools.py tests/gateway/test_multiplex_busy_input_mode.py` 2. Run `python3 -m compileall -q gateway/message_continuity.py gateway/delivery_ledger.py gateway/platforms/base.py gateway/run.py cron/scheduler.py tools/cronjob_tools.py`. 3. Run `git diff --check origin/main..HEAD`. Latest exact-head result: 240 passed, 0 failed in each independent code and security gate. A broader integration run on the same candidate completed 338 passed, 0 failed. The earlier full gateway suite completed 5,321 passed / 3 failed / 37 skipped; all three failures reproduced identically on the pinned base and are environment-specific, not candidate regressions. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits - [x] I searched for existing PRs and documented the overlapping but narrower work above - [x] My PR contains only changes related to this fix - [ ] I've run `pytest tests/ -q` and all tests pass (focused and full-gateway evidence is reported above; three full-gateway failures reproduce on the pinned base) - [x] I've added tests for my changes - [x] I've tested on macOS 26.6 ### Documentation & Housekeeping - [x] Documentation update — N/A; behavior is internal and covered by diagnostics/tests - [x] `cli-config.yaml.example` update — N/A; no config keys added - [x] `CONTRIBUTING.md` / `AGENTS.md` update — N/A - [x] Cross-platform impact considered; implementation uses Python/SQLite/temp-state abstractions and tests are hermetic - [x] Tool descriptions/schemas update — N/A ## Safety / Rollback - No live gateway, provider, credential, configuration, or production state was touched. - Rollback is the isolated PR revert; schema changes are additive and nullable.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/84142",
          "createdAt": "2026-08-11T22:47:39Z",
          "updatedAt": "2026-08-13T17:59:26Z",
          "timestamp": "2026-08-13T17:59:26Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "comp/cron",
            "P2",
            "needs-decision",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility",
            "area/sessions"
          ],
          "author": "Isaac-b-ux",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:d0b303f10e4b8538ea55",
        "signalId": "github:NousResearch/hermes-agent:pull_request:73052",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:73052",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(kanban): preserve chat_type and delivery_metadata in subscription inheritance (#73030)",
          "text": "## Problem `_inherit_notify_subs()` in `hermes_cli/kanban_db.py` copies a parent task's notification subscription to a child via `INSERT … SELECT`, but omits the `chat_type` and `delivery_metadata` columns. Inherited child rows silently lose available routing metadata. This affects all three call paths that route through the helper: `create_task(parents=…)`, `link_tasks()`, and `decompose_triage_task()`. **Impact:** - Creator-wake reconstruction falls back to `chat_type=\"group\"`, so a DM-originated child completion wakes a fresh group-scoped session instead of the originating DM session. - Telegram DM-topic subscriptions lose their persisted reply-fallback and topic metadata; `message_thread_id` alone can render outside the intended visible lane. Closes #73030. ## Fix Add `chat_type` and `delivery_metadata` to both the INSERT column list and the SELECT projection in `_inherit_notify_subs`. One logical change — the stored TEXT value is copied as-is and decoded on read by `list_notify_subs` as before. No re-encoding needed. ## Verification - **RED:** 3 regression tests fail on `upstream/main` before the fix (`assert None == 'dm'` — `chat_type` not inherited). - **GREEN:** All 4 targeted tests pass with the fix. - **Nearby suite:** 246 passed (`test_kanban_notify.py` + `test_kanban_db.py`), 0 failed. ## Tests - Extended `_assert_inherited_notify_sub()` to assert `chat_type` and `delivery_metadata` are preserved. - Updated all 3 existing inheritance tests (create_task, link_tasks, decompose_triage_task) to supply `chat_type=\"dm\"` and realistic `delivery_metadata`. - Added `test_inherited_notify_sub_preserves_null_routing_fields` — a parent subscription created without routing metadata must inherit cleanly (NULL → None). ## Competitor analysis No competing open PRs found (issue-number search, topic-overlap keyword search, same-author check — all clean). --- Auto-published by Moonsong via Path B automated pipeline.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/73052",
          "createdAt": "2026-07-28T02:27:07Z",
          "updatedAt": "2026-08-13T17:59:23Z",
          "timestamp": "2026-08-13T17:59:23Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "comp/cron",
            "platform/telegram",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery",
            "sweeper:blast-moderate"
          ],
          "author": "Tranquil-Flow",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:0317709938836e8f327c",
        "signalId": "github:NousResearch/hermes-agent:issue:85496",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt",
          "metrics",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85496",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "auth_middleware rejects desktop /api/ws?token= upgrade with HTTP 401 (boot loop)",
          "text": "# Bug: `auth_middleware` rejects the desktop `/api/ws?token=...` upgrade with HTTP 401 ## Affected version hermes-agent `0.20.0` (install stamp `fa83af3f9a42`, main, local install). Reproduced on macOS arm64. ## Symptom The Electron desktop app fails to boot in a loop: ``` Local Hermes backend is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: WebSocket connection failed. → Desktop boot failed → Restarting ``` ## Root cause `apps/desktop/dist/electron-main.mjs` spawns the backend with: ``` serve --host 127.0.0.1 --port 0 ``` and opens the WebSocket upgrade at `/api/ws?token=<session_token>` (the desktop app's own freshly minted token, set as `HERMES_DASHBOARD_SESSION_TOKEN` in the backend child env). The backend's `auth_middleware` (`hermes_cli/web_server.py`, `auth_middleware`) gates **every** `/api/*` request: ```python if path.startswith(\"/api/\") and path not in _PUBLIC_API_PATHS and not is_mcp_oauth_callback: if not _has_valid_session_token(request) and not _has_valid_query_token(request, path): return JSONResponse(status_code=401, ...) ``` `_has_valid_query_token` only whitelists `/api/files/download` for the `?token=` query param — it does **NOT** include `/api/ws` or `/api/pty`. So `auth_middleware` returns 401 on the WS upgrade **before** the actual `@app.websocket(\"/api/ws\")` handler (`_ws_auth_reason`) ever runs and validates the token against `_SESSION_TOKEN`. The WS handler itself would accept the token; the middleware blocks it first. ## Note on token sync (separate, related footgun) If a stale `HERMES_DASHBOARD_SESSION_TOKEN=...` line is left in `~/.hermes/.env` (e.g. pinned during a remote-tunnel setup), the backend is locked to that fixed token while the desktop app generates a *different* fresh token for the WS → also 401. Removing the `.env` line fixes the sync; the middleware bug above is independent of it. ## Proposed fix (minimal, what we currently run locally) Exempt the WS/PTY upgrade paths from the middleware's 401 so the handler's own check runs: ```python is_ws_or_pty = path in (\"/api/ws\", \"/api/pty\") if path.startswith(\"/api/\") and path not in _PUBLIC_API_PATHS and not is_mcp_oauth_callback and not is_ws_or_pty: if not _has_valid_session_token(request) and not _has_valid_query_token(request, path): return JSONResponse(status_code=401, ...) ``` This does **not** weaken auth: `_ws_auth_reason` still validates the token against `_SESSION_TOKEN` (hmac.compare_digest), so a wrong token is still rejected. It only moves the check from the middleware to the handler, where the desktop app expects it. ## Steps to reproduce 1. Fresh `HERMES_HOME`, no stale `.env` token. 2. `hermes desktop` (or spawn `serve --host 127.0.0.1 --port 0`). 3. Observe the boot loop / `curl -i \"http://127.0.0.1:<port>/api/ws?token=<env token>\"` → 401. 4. A real WS upgrade (`websockets.connect(\"ws://127.0.0.1:<port>/api/ws?token=<env token>\")`) is also rejected by the middleware.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85496",
          "createdAt": "2026-08-13T17:40:49Z",
          "updatedAt": "2026-08-13T17:59:20Z",
          "timestamp": "2026-08-13T17:59:20Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "area/auth",
            "P2",
            "needs-repro",
            "sweeper:risk-security-boundary",
            "comp/desktop",
            "comp/dashboard"
          ],
          "author": "strohjarvis",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:b7b56a8d7bd5e3c68898",
        "signalId": "github:NousResearch/hermes-agent:issue:73030",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:73030",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug] Child Kanban subscriptions drop chat_type and delivery_metadata during inheritance",
          "text": "### Summary At commit `373632e33813c86b1b9b024168a5892af1926b83`, `hermes_cli/kanban_db.py::_inherit_notify_subs()` copies a parent task’s notification subscription to a child but omits the `chat_type` and `delivery_metadata` columns. The same omission remained on `main` when rechecked on 2026-07-27. Subscriptions created by gateway `/kanban create` and tool-driven `kanban_create` can carry both fields. The explicit CLI subscription path can carry `chat_type`. `create_task(..., parents=...)`, `link_tasks()`, and `decompose_triage_task()` all call the inheritance helper, so inherited child rows silently lose available routing metadata. ### Impact The recipient identifiers (`chat_id`, `user_id`, and `thread_id`) are preserved, so this is not a cross-recipient delivery issue. However: - creator-wake reconstruction falls back to `chat_type=\"group\"`, causing a DM-originated child completion to wake a fresh group-scoped session instead of the originating DM session; - Telegram DM-topic subscriptions lose their persisted reply-fallback and topic metadata, while `message_thread_id` alone can render outside the intended visible lane. ### Reproduction 1. Add a parent notification subscription with `chat_type=\"dm\"` and non-empty `delivery_metadata`. 2. Create a child with that parent, link an existing child with `link_tasks()`, or decompose a triage task. 3. Read the inherited child subscription. 4. `chat_type` and `delivery_metadata` are `NULL`, while the other routing fields are copied. ### Expected behavior Inherited subscriptions preserve `chat_type` and `delivery_metadata`. ### Suggested fix Include both columns in the helper’s `INSERT … SELECT`. Extend `_assert_inherited_notify_sub()` in `tests/hermes_cli/test_kanban_notify.py` to assert them; the existing tests already exercise parent-linked creation, `link_tasks()`, and triage decomposition. Related work: #56580, #60600, #72191, #72223, and #72232.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/73030",
          "createdAt": "2026-07-28T01:33:54Z",
          "updatedAt": "2026-08-13T17:58:46Z",
          "timestamp": "2026-08-13T17:58:46Z",
          "metrics": {
            "reactions": 0,
            "comments": 3
          },
          "labels": [
            "type/bug",
            "comp/cron",
            "platform/telegram",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery"
          ],
          "author": "bmetcalf21",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a01311519b6adb7c0c04",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85509",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85509",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): write context-length cache atomically",
          "text": "## Summary The context-length cache was written with a plain truncating `open(path, \"w\")` — a crash/kill mid-dump leaves empty or partial YAML, and the next load swallows the parse error and returns `{}`, silently wiping every persisted context length. Both writers now use the existing `atomic_yaml_write()` (temp file + fsync + `os.replace`). Salvages #40919 by @sasquatch9818 (cherry-picked, authorship preserved), the cleaner of the two duplicate implementations. #35140 by @annguyenNous proposed the same fix 8 days EARLIER — first-submitter credit to @annguyenNous; #40919 was picked for its regression test and comments. ## Changes - `agent/model_metadata.py`: `save_context_length()` + `_invalidate_cached_context_length()` → `atomic_yaml_write()`. - `tests/agent/test_model_metadata.py`: interrupted-write-leaves-cache-intact regression test. ## Validation | | Result | |---|---| | `test_model_metadata.py` | 75/75 pass | | Stale-base gate | 0 behind, 2-file diff | ## Infographic ![Atomic cache write — no torn YAML](https://files.catbox.moe/uachrd.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85509",
          "createdAt": "2026-08-13T17:58:36Z",
          "updatedAt": "2026-08-13T17:58:36Z",
          "timestamp": "2026-08-13T17:58:36Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:f406b9e80953123c1f4f",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85507",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85507",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): guard against non-positive context_length poisoning the cache",
          "text": "## Summary A cached `context_length` of 0 (corrupted cache, failed probe, manual edit) poisoned the whole resolution chain: `0 is not None` short-circuits step 1, so the compressor and every downstream display got context_length=0 forever. This refuses to persist non-positive values and drops any pre-existing non-positive entry so it re-resolves. Salvages #25812 by @OmarB97 (cherry-picked, authorship preserved). The original shipped without tests; we added sabotage-verified regression coverage on top. ## Changes - `agent/model_metadata.py` (@OmarB97): `save_context_length()` refuses `length <= 0`; step-1 drops `cached <= 0` entries and re-resolves. - `tests/agent/test_model_metadata.py` (ours): never-persisted + dropped-and-re-resolved regression tests. ## Validation | | Result | |---|---| | `test_model_metadata.py` | 76/76 pass | | Sabotage run (guard disabled) | both new tests fail as expected, restore → green | ## Infographic ![Zero poisons the cache — non-positive context lengths rejected](https://files.catbox.moe/8jaai3.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85507",
          "createdAt": "2026-08-13T17:58:34Z",
          "updatedAt": "2026-08-13T17:58:34Z",
          "timestamp": "2026-08-13T17:58:34Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [],
          "author": "teknium1",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:c931cf06936cb04df375",
        "signalId": "github:NousResearch/hermes-agent:pull_request:44772",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:44772",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cli): drop agent-browser/@streamdown-math from root npm deps",
          "text": "## What does this PR do? `hermes update` was pruning root-level Node dependencies (e.g. `agent-browser`) on every run. This PR originally tried to fix that by juggling install order between the root and ui-tui/web workspace installs; that approach didn't hold up under review. The old two-pass shape (`npm ci --workspaces=false` then `npm ci --workspace ui-tui --workspace web`) reliably prunes a root-only dependency on its second pass, confirmed empirically and via npm/cli source. A single-pass `npm ci --workspace ui-tui --workspace web --include-workspace-root` does keep a root-only dependency (`--include-workspace-root` is consumed via `flatOptions` into Arborist's `includeWorkspaceRoot`, independent of the two-pass shape's problem), so a flag-only fix was possible. This PR takes a different approach anyway, removing the root-only dependencies entirely, because it also eliminates the manifests-digest skip-marker problem a flag-only fix would still have to handle explicitly, and it doesn't depend on one flag's semantics staying stable across future npm versions: 1. **agent-browser is no longer a root `package.json` dependency.** It resolves lazily via `npx agent-browser` (`tools/browser_tool.py` already had npx as a fallback in its resolution cascade: PATH, then Homebrew/Hermes-managed node, then local `.bin`, then npx); it's now the primary path, identified via a shared `NPX_AGENT_BROWSER_SENTINEL` constant + `_is_npx_agent_browser_sentinel()` predicate used at every comparison site instead of ad-hoc string literals. The npx version spec (`AGENT_BROWSER_NPX_SPEC = \"agent-browser@^0.26.0\"`) matches the same `^0.26.0` range `install.sh`/`install.ps1` use; it is a version range, not an exact pin, and npx resolution has no lockfile-equivalent integrity check, so every real npx invocation of agent-browser passes `--ignore-scripts` so a future compromised patch release can't run install-time lifecycle scripts. A `warm_agent_browser_npx_cache()` helper runs early in both `hermes update` (before the lockfile-unchanged early return, so it actually fires on a plain run) and `hermes doctor --fix`, keeping npx's own cache warm ahead of time with a credential-scrubbed, PATH-propagated environment (matching every other agent-browser subprocess spawn) and killing its whole process tree, not just the top-level npx PID, if it hangs past its timeout. This preserves the \"available before any session starts\" property agent-browser had as an eager dependency (per #27055's original reasoning) without re-entangling it with the npm workspace graph or exposing Hermes's own credentials to a registry-fetched package. 2. **`@streamdown/math` moves to `apps/desktop/package.json`.** It's only ever imported by desktop's own TS code (`markdown-text.tsx`, `katex-memo.ts`); it was misplaced at root and subject to the exact same pruning risk agent-browser had. 3. **`_update_node_dependencies()` runs a single `npm ci --workspace ui-tui --workspace web --include-workspace-root` call.** `apps/desktop` is never named, so its Electron devDependency (and ~200MB postinstall) is still never resolved by default. `--include-workspace-root` protects root's own remaining `devDependencies` (the shared ESLint flat config every workspace's `eslint.config.mjs` imports) from the same scoped-install pruning; it has nothing to do with agent-browser/`@streamdown/math`, which aren't root dependencies anymore. 4. **Every surface that probes for agent-browser resolves through the same cascade**, so none of them can diverge from what browser tools actually invoke at runtime: `hermes_cli/tools_config.py`'s post-setup Chromium-install path, `hermes_cli/doctor.py`'s agent-browser check, `hermes_cli/doctor_live.py`'s `--live` browser probe, `hermes_cli/dep_ensure.py`'s `ensure_dependency(\"browser\")`, and `hermes_cli/nous_subscription.py`'s desktop-Capabilities-panel check all resolve via `_find_agent_browser`/`_resolve_npx_bin` (honoring the same Termux bare-npx carve-out) instead of hand-rolling their own PATH/`node_modules/.bin` lookups. `_resolve_npx_bin()` checks the Hermes-managed/extended search before a bare ambient PATH lookup, validating each candidate actually runs before trusting it, so a broken system npx can't shadow a healthy managed one. 5. **`install.sh`/`install.ps1` no longer npm-install agent-browser at all**, eager or on-demand. Both scripts' browser-setup functions were only ever reached through their explicit `--ensure browser`/`-Ensure browser` fallback mode, itself only triggered by an actual browser-tool call's lazy-install path, which already resolves agent-browser via npx before ever reaching these scripts. Eagerly npm-installing a second, separately version-pinned copy there was redundant and an extra supply-chain surface for a path npx already covers. Chromium acquisition for that on-demand path now defers entirely to `_maybe_autoinstall_chromium`'s existing lazy fallback. `@askjo/camofox-browser`'s install and system-browser detection/configuration are unaffected. ## Related Issue Fixes #43564 ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) ## Changes Made - `package.json`: drop `agent-browser` and `@streamdown/math` from root `dependencies` (root now has no runtime dependencies of its own; a shared ESLint `devDependencies` block remains, protected separately, see below); also drop the now-stale `\"agent-browser@0.26.0\": true` `allowScripts` entry, since nothing in the lockfile resolves that pin anymore - `apps/desktop/package.json`: add `@streamdown/math` alongside its sibling `@streamdown/code` - `package-lock.json`: regenerated to match - `hermes_cli/update_cmd.py`: `_update_node_dependencies()` runs `npm ci --workspace ui-tui --workspace web --include-workspace-root` (the last flag protects root's own devDependencies only). `warm_agent_browser_npx_cache()` runs before the `_npm_lockfile_changed` early return (with a status line) so it fires on the common no-op `hermes update` path too, not just when npm actually reinstalls - `tools/browser_tool.py`: - `_resolve_npx_bin()`: checks the Hermes-managed/Homebrew extended PATH search before a bare ambient lookup, validating each candidate with `node_tool_runnable` before trusting it - `warm_agent_browser_npx_cache()`: rewritten around `subprocess.Popen` instead of `subprocess.run`; runs a credential-scrubbed (`_build_browser_env()`), PATH-propagated environment instead of inheriting the full parent environment, runs in its own process group, and kills the whole tree (new `_kill_process_tree` helper: POSIX `killpg`, Windows recursive `taskkill /T /F`) if it hangs past its timeout, since a surviving descendant can otherwise hold a capture pipe open indefinitely - `NPX_AGENT_BROWSER_SENTINEL`/`_is_npx_agent_browser_sentinel()` and `AGENT_BROWSER_NPX_SPEC` (`agent-browser@^0.26.0`, matching `install.sh`/`install.ps1`'s range, not an exact pin) - every real npx-agent-browser invocation (the two real launch sites, the Chromium auto-install site, and the warm-up) resolves npx via `_resolve_npx_bin()` instead of a bare `shutil.which(\"npx\")`, and passes `--ignore-scripts --prefer-offline -y` - `hermes_cli/doctor.py`: agent-browser check mirrors `_find_agent_browser`'s resolution cascade instead of checking `node_modules/agent-browser` directly; `hermes doctor --fix` warms the npx cache, reported info-only (doesn't count toward \"Fixed N issue(s)\" since agent-browser was already healthy) - `hermes_cli/doctor_live.py`: `--live`'s `_browser_available()` probe falls through to the same npx cascade (with the Termux carve-out) when PATH/node_modules checks miss - `hermes_cli/dep_ensure.py`: the `\"browser\"` dependency check gains the same npx rung, so `ensure_dependency(\"browser\")` (used by `hermes acp --setup-browser` and browser_tool's lazy-install path) can't shell out to `install.sh` on an install `hermes doctor` already reports healthy - `hermes_cli/nous_subscription.py`: `_has_agent_browser`'s `tools.browser_tool` import-failure fallback restores the Hermes-managed-Node-path probe (Windows installer) and the PATHEXT-aware `shutil.which` lookup for the local `node_modules/.bin` copy (Windows `.cmd`-shim resolution) - `hermes_cli/tools_config.py`: `_run_post_setup`'s Chromium-install path resolves via the same cascade and passes `--ignore-scripts`, dropping the now-dead `npm install --workspaces=false` step; dropped a dead `import shutil` left over once it stopped calling `shutil.which` directly - `scripts/install.sh` / `scripts/install.ps1`: `ensure_browser()` / `Install-AgentBrowser` no longer npm-install `agent-browser` (camofox's install and system-browser detection are unaffected); `install.ps1` also drops the now-dead `-SkipChromium` switch - Tests: `tests/hermes_cli/test_cmd_update.py`, `test_doctor.py`, `test_doctor_live.py`, `test_dep_ensure.py`, `test_nous_subscription.py`, `test_tools_config.py`, `test_windows_subprocess_no_window_flags.py`, `tests/tools/test_browser_npx_warmup.py` (full rewrite for the `Popen`-based rework), `tests/tools/test_browser_homebrew_paths.py`, `tests/tools/test_browser_chromium_autoinstall.py`, `tests/test_install_sh_browser_install.py`, and new `tests/test_install_ps1_browser_install.py`: updated/extended throughout for the above - `tests-js/package-json-lazy-deps.test.ts`: contract updated for the new behavior (agent-browser and `@streamdown/math` must NOT be root dependencies; `@streamdown/math` must be in `apps/desktop`). This test lived at `tests/test_package_json_lazy_deps.py` until upstream ported it to vitest so it runs in the correct CI lane. Also adds a lockfile-level check (mirroring the existing camofox one) so a future regression that reintroduces `agent-browser` into `package-lock.json` fails this test directly. - Docs updated for the npx-based resolution (root `npm install` no longer installs agent-browser): - `website/docs/user-guide/features/browser.md`: agent-browser install instructions now npx-first - `website/docs/developer-guide/browser-provider-plugin.md`: fixed a stale comment claiming `post_setup: agent_browser` installs the npm dep - `CONTRIBUTING.md`: relabeled the two optional `npm install` steps (no longer \"browser tools\") - `website/docs/getting-started/termux.md`: split the manual Node-deps guidance by backend: cloud browser providers only need Node.js (npx resolves agent-browser lazily), but local browser automation on Termux needs a real `agent-browser` install since local mode rejects the bare npx fallback as too fragile ## How to Test 1. Run the targeted test suite: ``` scripts/run_tests.sh tests/hermes_cli/test_cmd_update.py tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_live.py tests/hermes_cli/test_dep_ensure.py tests/hermes_cli/test_nous_subscription.py tests/hermes_cli/test_tools_config.py tests/tools/test_browser_npx_warmup.py tests/tools/test_browser_homebrew_paths.py tests/tools/test_browser_chromium_autoinstall.py tests/test_windows_subprocess_no_window_flags.py tests/test_install_sh_browser_install.py tests/test_install_ps1_browser_install.py ``` All tests should pass. The package.json invariant tests are now vitest (they were ported upstream to run in the JS CI lane): `npm run --prefix tests-js check`. 2. To verify the regression fix manually: run `hermes update`, then confirm `hermes doctor` no longer reports `agent-browser` missing (it now reports \"resolves via npx on first use\"). 3. To verify the npx warm-up: run `hermes doctor --fix` and confirm it reports warming the npx cache for agent-browser. 4. To verify root devDependencies survive: run `hermes update`, then confirm `node_modules/@eslint`/`node_modules/typescript-eslint` etc. are still present at repo root. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `scripts/run_tests.sh` and all targeted tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) (or N/A) - [x] I've updated `cli-config.yaml.example` if I added/changed config keys (or N/A) - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows (or N/A) - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) (or N/A) - [x] I've updated tool descriptions/schemas if I changed tool behavior (or N/A)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/44772",
          "createdAt": "2026-06-12T08:22:34Z",
          "updatedAt": "2026-08-13T17:58:27Z",
          "timestamp": "2026-08-13T17:58:27Z",
          "metrics": {
            "reactions": 0,
            "comments": 27
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "tool/browser",
            "P2",
            "sweeper:risk-compatibility",
            "sweeper:risk-platform-windows",
            "sweeper:blast-moderate",
            "comp/desktop",
            "platform/windows",
            "area/install-update"
          ],
          "author": "zakame",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:014be0eaa1adaba8e554",
        "signalId": "github:NousResearch/hermes-agent:pull_request:77507",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "text",
          "updatedAt"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:77507",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(desktop): eliminate sidebar scroll jitter during dynamic sizing",
          "text": "## What does this PR do? Stops the Desktop sessions sidebar from jumping/flickering while you scroll a long virtualized list. Native scroll anchoring was fighting the virtualizer’s padding updates, and date-divider rows were estimated at the session-row height. Rebased onto current `main` so inbox-style card rows keep the 66px estimate. A naive replay of the original `estimateSize` line would have sized every non-divider row at 28px, including cards. Not in this PR: mouse-wheel dead-zones once the list is long (~25+ sessions), where the wheel stops mid-list but dragging the scrollbar still works. Tracked as #84964. ## Related Issue Fixes #77505 ## Type of Change - [x] 🐛 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 - `apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx`: disable native scroll anchoring on the virtual list container. - Same file: `estimateSize` is divider 26px / compact session 28px / card session 66px. - Same file: set the virtualizer `gap` to 1px to match the existing `gap-px` row layout. - `apps/desktop/src/app/chat/sidebar/virtual-session-list.test.tsx`: contract test so a future merge cannot drop the card or divider branch. ## How to Test 1. Open Hermes Desktop with enough sessions for the sidebar to virtualize (25+), including several date dividers. 2. Scroll up and down past the dividers with the wheel and with arrow keys. 3. The list should no longer jump or flicker as rows are measured. 4. With inbox-style session cards enabled, card rows should still estimate at card height (no compact-row snap). 5. Unit: from `apps/desktop`, `npx vitest run --project ui src/app/chat/sidebar/virtual-session-list.test.tsx` — 2 passed. This PR does **not** claim that mid-list wheel stalls are gone. If the wheel stops while scrollbar drag still works, that is #84964. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass — N/A (Desktop UI only; Python suite not in this diff) - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Linux, vitest UI project ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A",
          "url": "https://github.com/NousResearch/hermes-agent/pull/77507",
          "createdAt": "2026-08-03T08:42:55Z",
          "updatedAt": "2026-08-13T17:58:03Z",
          "timestamp": "2026-08-13T17:58:03Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "P2",
            "comp/desktop"
          ],
          "author": "StanleyStetson",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:48a79a5053baecb86d0a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85263",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85263",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(webhook): stop route scripts from losing authenticated delivery identity",
          "text": "## What does this PR do? Webhook route scripts currently receive only payload JSON, so they cannot bind work to the event type and delivery identity that Hermes authenticated. This change passes bounded request metadata through a sanitized environment, rejects script deliveries without an external identity, and applies idempotency before script execution so a retry cannot run a stateful script twice. ### Symptom A signed GitHub webhook reaches a configured route script without `X-GitHub-Event` or `X-GitHub-Delivery`. A repeated delivery can also execute the script again before Hermes recognizes the duplicate. ### Impact Stateful script consumers cannot reliably associate processing with the authenticated delivery. They must otherwise infer identity from attacker-controlled payload data, and duplicate webhook retries can repeat script side effects. ### Bug Cause **Trigger:** `gateway/platforms/webhook.py:738` in `WebhookAdapter._handle_webhook` **Causal chain:** 1. Hermes validates the request signature and resolves the event type from request headers. 2. The adapter calls `run_route_script` with only parsed payload data, and it checks delivery idempotency only after the script returns. 3. The child process cannot observe authenticated metadata, while duplicate deliveries can invoke it more than once. **Why it is wrong:** The trusted request metadata is dropped at the subprocess boundary, and the side-effecting script runs outside the delivery idempotency boundary. **Working sibling / contrast:** Non-script routes do not need subprocess metadata and retain their timestamp fallback when no external delivery ID is present. **Ruled out:** Payload lookalike fields are not a safe substitute because the request body is attacker-controlled even when the signature identifies its sender. Live verification confirmed those fields did not populate the script environment on the base revision. ### Fix Resolve one bounded external delivery ID before script execution, require valid event and delivery metadata for script routes, and pass both values as `HERMES_WEBHOOK_EVENT_TYPE` and `HERMES_WEBHOOK_DELIVERY_ID` in the sanitized subprocess environment. The same delivery ID now gates script execution and downstream processing. Documentation and integration tests cover exact propagation, payload non-override, fail-closed missing identity, and duplicate suppression. ## Related Issue Closes #85244 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `gateway/platforms/webhook.py` - resolve and validate authenticated metadata before idempotency and route-script execution. - `gateway/platforms/webhook_filters.py` - expose bounded event and delivery values to scripts through the sanitized child environment. - `tests/gateway/test_webhook_adapter.py` - cover signed propagation, payload non-override, missing identity, and duplicate execution. - `website/docs/user-guide/messaging/webhooks.md` - document the route-script metadata contract. ## How to Test 1. Send a correctly signed webhook with `X-GitHub-Event` and `X-GitHub-Delivery` to a route with a real script and verify the script receives the exact values. 2. Repeat the same delivery and verify the script invocation count remains one; omit the delivery header and verify Hermes returns HTTP 400 without invoking the script. 3. Run the targeted gateway suite (41 passed locally): ```bash scripts/run_tests.sh tests/gateway/test_webhook_adapter.py tests/gateway/test_webhook_deliver_only.py ``` ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run the repository test entry on the relevant tests and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: Windows 11 ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) - [x] `cli-config.yaml.example` is N/A because this change adds no config keys - [x] `CONTRIBUTING.md` and `AGENTS.md` are N/A because this does not change contributor workflows - [x] I've considered cross-platform impact; the existing script launcher remains platform-specific and the metadata contract is platform-neutral - [x] Tool descriptions and schemas are N/A because this does not change a model tool ## Screenshots / Logs Live verification with an aiohttp listener and a real script subprocess reproduced the missing metadata, duplicate invocation, and synthesized missing identity on the base revision. At commit `a8e3dd055`, the script received the exact authenticated values, duplicate invocation remained at one, and missing identity returned HTTP 400 before script execution.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85263",
          "createdAt": "2026-08-13T11:38:59Z",
          "updatedAt": "2026-08-13T17:57:51Z",
          "timestamp": "2026-08-13T17:57:51Z",
          "metrics": {
            "reactions": 0,
            "comments": 8
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "platform/webhook",
            "P2",
            "sweeper:risk-message-delivery"
          ],
          "author": "fangliquanflq",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:8bb6af1bd0f83aefb150",
        "signalId": "github:NousResearch/hermes-agent:issue:4438",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:4438",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Feature]: Rich Spreadsheet Skill (xlsx / csv)",
          "text": "## Current behavior hermes-agent can read and write files with its terminal tool, but Excel/spreadsheet handling relies on raw Python (`openpyxl`, `pandas`) with no structured abstraction. The agent has to figure out the library, the sheet structure, and error handling from scratch every time. This leads to inconsistent results — formatting is lost on round-trips, the wrong library is chosen for the task, and CSV vs. XLSX differences are handled ad hoc. ## Desired behavior A dedicated `spreadsheet` skill in `~/.hermes/skills/` that gives the agent a consistent, high-level vocabulary for spreadsheet operations: ``` read_sheet(path, sheet=None) → structured JSON rows write_sheet(path, rows, sheet=None) → creates/overwrites sheet apply_formula(path, cell, formula) → sets a formula and recalculates pivot(path, rows, cols, values, agg) → builds a pivot table chart(path, type, data_range, title) → embeds a chart into the workbook diff(path_a, path_b) → highlights cell-level changes ``` A CSV fallback path should handle plain CSVs without requiring Excel dependencies. This mirrors the pattern used by the existing `pdf` and `browser` skills. ## Implementation sketch - **Core wrapper** around `openpyxl` (read/write `.xlsx` with formatting preservation) + `xlsxwriter` (chart embedding, new workbook creation) + `pandas` (pivot tables, aggregation, CSV fallback). - **Skill manifest** registers the above functions as tool-callable operations, matching the existing skill infrastructure. - **Formatting preservation**: `read_sheet` → `write_sheet` round-trips should not destroy cell styles, merged cells, or conditional formatting. This is the main pain point with naive `pandas` usage. - **Error handling**: clear messages for corrupt files, missing sheets, formula errors, and unsupported chart types. - **Dependencies**: `openpyxl`, `xlsxwriter`, `pandas` added as optional extras (`pip install hermes-agent[spreadsheet]`). ## Why this matters Spreadsheets are the universal data format — finance, HR, operations, research. No other mainstream agent framework ships a first-class spreadsheet skill. This would be a strong differentiator and covers one of the most common real-world automation use cases. ## Related issues - #4381 — `skills create` / `skills edit` configurable target directory (the spreadsheet skill would benefit from the `--dir` flag and `default_write_dir` config proposed there)",
          "url": "https://github.com/NousResearch/hermes-agent/issues/4438",
          "createdAt": "2026-04-01T11:09:18Z",
          "updatedAt": "2026-08-13T17:57:40Z",
          "timestamp": "2026-08-13T17:57:40Z",
          "metrics": {
            "reactions": 0,
            "comments": 7
          },
          "labels": [
            "type/feature",
            "tool/skills",
            "P3"
          ],
          "author": "raphael-solace",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:faf679f26d855d2ea984",
        "signalId": "github:NousResearch/hermes-agent:pull_request:80135",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:80135",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(update): preserve non-prefixed systemd gateway process",
          "text": "## Summary Fixes an updater safety gap for **non-prefixed systemd-managed Hermes gateways**. `hermes update` already excludes active `hermes-gateway*` service PIDs from its manual-process sweep. A valid gateway run owned by a custom system unit (for example `gaiasignal-agent-hermes.service`) was not in that set, then was rediscovered as a manual process and sent `SIGTERM`. This focused patch classifies only existing positive `gateway run` candidates at the update manual-sweep boundary using `/proc/<pid>/cgroup`: - cgroup v2 `0::/...unit.service` and v1 `name=systemd:/...unit.service` => managed service, never raw-SIGTERM; - confirmed non-service membership => preserves the existing manual-cleanup behavior; - unreadable/ambiguous cgroup on Linux => fail safe and do not terminate; - no broad `systemctl` enumeration and no unit-name-prefix trust. ## Why A real custom systemd service was interrupted by the generic updater and systemd recovered it roughly five seconds later. The update code must not treat system-manager ownership as manual merely because the unit name is outside the standard `hermes-gateway*` convention. ## Verification Fork branch commit `2ece25503`: ```text uv sync --extra dev .venv/bin/python -B -m pytest -p no:cacheprovider -q \\ tests/hermes_cli/test_update_gateway_pid_classification.py \\ tests/hermes_cli/test_update*.py tests/hermes_cli/test_gateway.py # 162 passed ``` Reviewed/rebased validation commit `02f1a138` on current upstream `8f271272`: ```text # 164 update + gateway tests passed .venv/bin/python -B -m ruff check hermes_cli/update_cmd.py \\ tests/hermes_cli/test_update_gateway_pid_classification.py .venv/bin/python -B -m py_compile hermes_cli/update_cmd.py git diff --check ``` The new focused coverage verifies custom v2 and legacy v1 service cgroups, normal manual session scopes, unreadable cgroups, a false capability-probe path, non-systemd manual behavior, and preservation of targeted existing service discovery. A read-only check classified the affected live service PID as managed (`True`) without updating or restarting it. ## Scope / safety - Exactly two files; no dependencies, workflow files, or production changes. - Independent evaluator and full Consensus Board approved the final two-file change. - This draft intentionally does **not** authorize local deployment or a service restart. ## Maintainer note The personal fork is 534 commits behind current `main` because its PAT cannot push inherited workflow-file updates. GitHub compare shows this branch is **ahead by one commit with exactly these two file changes**. Please rebase/cherry-pick the focused patch onto current `main` before merging. Related context: closed unmerged #5409 added the existing standard-service exclusion; open #20488 and #16349 concern status/profile reporting rather than update-time termination; open #77854 addresses updater-owning standard gateway lifecycle and is currently dirty. No exact duplicate was found.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/80135",
          "createdAt": "2026-08-06T07:16:55Z",
          "updatedAt": "2026-08-13T17:56:49Z",
          "timestamp": "2026-08-13T17:56:49Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "P2",
            "sweeper:risk-compatibility",
            "area/install-update"
          ],
          "author": "pankajarm",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ad42e79a0a60fb0e5c47",
        "signalId": "github:NousResearch/hermes-agent:pull_request:79203",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:79203",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway): bound systemd planned-restart helper",
          "text": "## Summary - bound the transient systemd planned-restart helper to `restart_drain_timeout + 5s` - after that grace period, re-read the unit's `MainPID` and only force-kill when it still matches the original gateway PID - reset and restart the unit as before, using the resolved `systemctl` path - add focused coverage for user-unit scope, timeout derivation, PID identity guard, and action ordering ## Why this is valuable A gateway can finish logical shutdown (drain timeout, adapter disconnect, state cleanup, exit code selected) yet retain a live Python PID because a thread or interpreter teardown path is wedged. In that state systemd still reports the service as active, while Slack/other platforms are offline. `_launch_systemd_restart_shortcut()` currently waits forever in `while kill -0 <pid>`. Because the PID never disappears, the helper never reaches `systemctl restart`, and systemd has no failed/exited main process to recover. This turns a recoverable stuck shutdown into an indefinite messaging outage. The change keeps the graceful path intact and uses the existing configured drain budget. Force is a last resort only after that budget, and the fresh `MainPID` equality check prevents killing a recycled PID or a replacement gateway. The result is bounded recovery without weakening normal drain semantics or crash-loop backoff. This is the remaining systemd-helper variant of the restart fragility discussed in #12438; current `main` already bounds the general detached restart watcher, but not this transient systemd helper. ## Validation ```text scripts/run_tests.sh tests/gateway/test_gateway_shutdown.py tests/gateway/test_gateway_process_exit.py -q 11 passed ```",
          "url": "https://github.com/NousResearch/hermes-agent/pull/79203",
          "createdAt": "2026-08-05T08:29:38Z",
          "updatedAt": "2026-08-13T17:56:45Z",
          "timestamp": "2026-08-13T17:56:45Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "P2",
            "sweeper:risk-message-delivery"
          ],
          "author": "carljborg",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:fcb7aa6e340d3052694d",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85365",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85365",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway): propagate HERMES_BUNDLED_* into launchd/systemd units",
          "text": "## Problem On Homebrew/Nix installs the launchd-supervised gateway logs `No adapter available for <platform>` for every bundled messaging platform (telegram, discord, slack, …), even when the plugin is enabled, the SDK is installed, and the token is valid. The identical setup works when started from a shell. ## Root cause The `hermes` wrapper exports resource pointers — `HERMES_BUNDLED_PLUGINS`, `HERMES_BUNDLED_SKILLS`, `HERMES_BUNDLED_LOCALES`, `HERMES_OPTIONAL_SKILLS`, `HERMES_TUI_DIR` — so `get_bundled_plugins_dir()` resolves to the real manifest tree, which on packaged installs lives outside site-packages (e.g. `<prefix>/share/hermes-agent/plugins`). But `generate_launchd_plist()` writes only `PATH`, `VIRTUAL_ENV`, and `HERMES_HOME` into the plist's `EnvironmentVariables`. launchd starts the venv python directly, bypassing the wrapper, so inside the supervised gateway `get_bundled_plugins_dir()` falls back to the in-repo `plugins/` path, discovers **zero** bundled platform manifests, and `_create_adapter()` returns `None`. `generate_systemd_unit()` has the identical gap on the Linux side (Nix uses systemd user units with the same wrapper vars). ## Fix Bake the wrapper's `HERMES_BUNDLED_*` / `HERMES_OPTIONAL_SKILLS` / `HERMES_TUI_DIR` pointers into the generated launchd plist and systemd unit(s) when they are present in the environment, via a small shared `_bundled_resource_env_pairs()` helper. - **Empty on a standard pip/uv install** (the wrapper isn't involved), so those units are **byte-for-byte unchanged** — no churn to `launchd_plist_is_current()` / `systemd_unit_is_current()` for existing deployments. - Deterministic ordering; only non-blank values are emitted. ## Tests New `TestBundledResourceEnvPropagation` covers, for both generators: - vars set → emitted in the correct plist/`Environment=` form; - vars unset → not emitted (unit unchanged); - unset vars aren't leaked when only some are set; - blank/whitespace values are skipped. Full `tests/hermes_cli/test_gateway_service.py` (83 passed, 1 skipped) plus the sibling systemd/launchd suites (`test_gateway_linger`, `test_gateway`, `test_systemd_watchdog_unit`, `test_systemd_optional_directives`, `gateway/test_status`) pass. ruff + windows-footgun gates clean. (The test file also picks up `encoding=\"utf-8\"` on a handful of pre-existing bare `read_text`/`write_text` calls that the footgun gate flags once the file is touched — part of the #71014 encoding campaign.) Fixes #85357",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85365",
          "createdAt": "2026-08-13T14:46:08Z",
          "updatedAt": "2026-08-13T17:56:40Z",
          "timestamp": "2026-08-13T17:56:40Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/gateway",
            "P2",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility"
          ],
          "author": "PRATHAMESH75",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:1bb6032b0efc8acad792",
        "signalId": "github:NousResearch/hermes-agent:pull_request:78590",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:78590",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(install): surface aborted gateway restart during hermes update (#78574)",
          "text": "## What does this PR do? `hermes update` can finish with a clean `✓ Update complete!` and exit 0 while leaving a running gateway on pre-update modules — the next turn then dies with `ImportError: cannot import name 'is_trivial_prompt' from 'agent.memory_provider'`. The cause is the blanket handler around the entire gateway auto-restart phase in `hermes_cli/update_cmd.py`: ```python except Exception as e: logger.debug(\"Gateway restart during update failed: %s\", e) ``` If anything in that phase raises — most plausibly the `from hermes_cli.gateway import (...)` at the top of the block, which pulls the *freshly pulled* module into a process that already loaded the *pre-update* ones — the whole phase is skipped. Every `→ draining …` / `✓ Restarted hermes-gateway` line vanishes from the update log, and the failure is invisible at default log level. That matches the reported log exactly: earlier updates show drain/restart lines, the failing run shows none, and exit status is 0. Upstream already has the right machinery for the *partial* failure case (`failed_or_stale_units` → `_warn_incomplete_gateway_fleet_restart` → `gateway_fleet_restart_incomplete` → nonzero exit + `.update_exit_code` marker). This PR routes the *total* failure case into the same contract instead of swallowing it. On exception the handler now: 1. probes for surviving gateway PIDs (best-effort, never raises — a broken `hermes_cli.gateway` is itself one of the ways we get here); 2. unless it can *positively* prove nothing is running (empty list), prints the underlying exception, the surviving PIDs, and `hermes gateway restart`; 3. sets `gateway_fleet_restart_incomplete`, so the update exits nonzero and — under `--gateway` — writes `1` to `.update_exit_code`, exactly like the existing failed-unit path. `None` (undeterminable) and a non-empty PID list are both treated as \"assume stale\"; only a positive empty result stays quiet, so a host with no gateway running does not start failing its updates. This is deliberately a *reporting* fix, not an attempt to guess why the phase died: the issue asks for \"exit nonzero or print a clear warning and manual recovery command\", and any root cause behind that blanket `except` produces the same silent-stale outcome today. ## Related Issue Fixes #78574 ## Type of Change - [x] 🐛 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 - `hermes_cli/update_cmd.py` - new `_surviving_gateway_pids_after_failed_restart()` — best-effort probe for gateways still running after the restart phase aborted; returns `None` when undeterminable so callers can distinguish \"nothing running\" from \"cannot tell\". - new `_warn_gateway_restart_phase_aborted()` — prints the cause, the surviving PIDs, and the `hermes gateway restart` / `hermes gateway status` recovery commands. Mirrors the wording/shape of the neighbouring `_warn_incomplete_gateway_fleet_restart()`. - the phase-level `except Exception` now calls both, sets `gateway_fleet_restart_incomplete = True`, and writes the gateway-mode `.update_exit_code` marker. - `hermes_cli/main.py` — re-export the two new helpers alongside the existing update-path names. - `tests/hermes_cli/test_update_gateway_restart_aborted.py` — new regression tests. ## How to Test ``` scripts/run_tests.sh tests/hermes_cli/test_update_gateway_restart_aborted.py tests/hermes_cli/test_update_fleet_restart_timeout.py ``` Result: `2 files, 9 tests passed, 0 failed`. Full update-path suite (22 files, includes every `tests/hermes_cli/test_update*.py` and `test_cmd_update*.py`): ``` scripts/run_tests.sh tests/hermes_cli/test_update*.py tests/hermes_cli/test_cmd_update*.py ``` Result: `22 files, 167 tests passed, 0 failed`. The tests cover the invariants from the issue: the probe never raises when `hermes_cli.gateway` is broken, an empty probe result stays distinguishable from an undeterminable one, and the warning names both the underlying cause and the manual recovery command. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass <!-- ran the 22-file update-path suite above, not the whole tree --> - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS (Darwin 25.5.0) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A <!-- N/A: helper docstrings only --> - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A <!-- N/A: no new config keys --> - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A <!-- N/A --> - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A <!-- the handler is platform-agnostic; `write_text` uses `encoding=\"utf-8\"`; no new POSIX-only calls --> - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A <!-- N/A --> ## Screenshots / Logs Before (reported in #78574 — no drain/restart step, exit 0): ```text ✓ Update complete! Tip: You can now select a provider and model: hermes model ``` After, when the restart phase aborts: ```text ⚠ Update incomplete — gateway auto-restart failed: cannot import name 'is_trivial_prompt' from 'agent.memory_provider' Gateway process(es) still running pre-update code: 4321 Restart it manually, then verify: hermes gateway restart hermes gateway status ``` …and the update process exits nonzero.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/78590",
          "createdAt": "2026-08-04T15:46:26Z",
          "updatedAt": "2026-08-13T17:54:45Z",
          "timestamp": "2026-08-13T17:54:45Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "comp/gateway",
            "P1",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility",
            "area/install-update"
          ],
          "author": "PRATHAMESH75",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:c81cd51992f1a970f2e2",
        "signalId": "github:NousResearch/hermes-agent:pull_request:82581",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "text",
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:82581",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(tools): isolate subprocess Python environments (#74817)",
          "text": "## Summary Remove Hermes-owned runtime contamination while preserving user-owned `PYTHONPATH` semantics. This consolidates and builds on #78917, incorporates the follow-up correctness work from Yiipu/hermes-agent#1, and rebases the complete attributed history onto main at `f4c2c263f0672a4b1485f3071cd5f79cd32d38ab`. ## Rebase onto main at f4c2c263f (#84500 reconciliation) Rebased onto main at `f4c2c263f`; conflicts were confined to the `PYTHONPATH` composition region in `tools/code_execution_tool.py`: upstream #84500 (`fix(tools): isolate external project environments`) had reworked the exact block this PR touches. The reconciliation preserves **both** fixes — inherited-PYTHONPATH sanitization is applied to the scrubbed child environment **before** the conditional composition, which keeps the staging `tmpdir` first, re-adds the Hermes repo root only for same-Hermes-environment children, and omits it for external project interpreters. #84500's interpreter-probe machinery is untouched, and its regression suites (`test_code_execution_modes.py`, `test_code_execution.py`, `test_code_execution_windows_env.py`) pass unmodified. The Windows-native test now uses the registered `@pytest.mark.windows_only` marker (current upstream OS-native test policy) so the Windows CI lane actually selects it. ## Reproduction Hermes launchers can prepend the Hermes repository root and the Hermes Python 3.11 venv's `site-packages` to `PYTHONPATH`. An unrelated Python 3.13 child then resolves NumPy from the 3.11 tree and fails to load its C extension: ```text ModuleNotFoundError: No module named 'numpy._core._multiarray_umath' ... _multiarray_umath.cpython-311-darwin.so ``` The same contamination class affects Pillow and other ABI-sensitive packages. It also reaches non-agent cron/script subprocesses through the shared environment builders. ## Root cause Hermes-owned paths needed to bootstrap the backend are inherited by unrelated child processes. Blanket removal of `PYTHONPATH` would fix the immediate ABI failure but would also destroy legitimate user configuration. Conversely, comparing path-shaped Python versions against the backend interpreter is not sound because the environment builder does not know which interpreter a future child will run. The ownership boundary is therefore the reliable contract: remove entries proven to be injected by the Hermes runtime and preserve every other component. ## Design - Strip only the exact Hermes repository root and exact Hermes runtime `site-packages` directories. - Preserve direct/deeper repo children and `site-packages` descendants because launchers do not inject them as standalone entries. - Preserve raw non-owned components byte-for-byte, including empty components, duplicates, whitespace, spelling, and ordering. - Do not use cross-version, path-shape, or `/nix/store` heuristics. - Sanitize before removing runtime marker variables so the validated Windows runtime relationship remains available to the ownership check. ## Changes - Apply the selective filter across terminal/background, shared subprocess, cron/script, and execute-code environment construction. - Remove inherited `PYTHONHOME` together with `VIRTUAL_ENV` and `CONDA_PREFIX` on normal sanitized paths. - Keep the explicit `build_subprocess_env(scrub_secrets=False)` no-scrub escape hatch unchanged. - Add behavioral regression coverage for exact ownership, raw `PYTHONPATH` semantics, HERMES_HOME aliases, Windows base-Python runtime ownership, and `PYTHONHOME`. ## Windows runtime coverage Some Windows gateway launchers run under a base/uv `python.exe` or `pythonw.exe` while `VIRTUAL_ENV` points to the separate Hermes runtime venv. In that mode, `sys.prefix` is not the Hermes runtime owner. The sanitizer accepts `VIRTUAL_ENV` only when it is the exact `<Hermes repo>/venv` location under a known physical or configured repo alias and contains a real `pyvenv.cfg`. That validated runtime contributes the exact `Lib/site-packages` entry. An unrelated inherited `VIRTUAL_ENV` is not trusted. This contract is covered with platform-independent helper tests plus a Windows-only native-semantics test. **Live Windows 11 validation was provided by @vollegrewar**: on a real Windows 11 desktop build (junction install, Hermes backend Python 3.11, external project venv Python 3.14), the original #74817 failure reproduces on main -- the Python 3.14 child loads the Hermes venv's cp311 NumPy and crashes with No module named 'numpy._core._multiarray_umath' -- and is fixed by this PR: OK numpy 2.5.2, with NumPy resolved from the project venv's own site-packages. His run also surfaced five test-portability failures in the sanitization suite (72 passed / 5 failed), addressed by the follow-up commit e00e9dc17 (see Tests). ## PYTHONHOME The default sanitized paths remove inherited `PYTHONHOME`, preventing it from redirecting an unrelated child interpreter (#75018). No active gateway producer was found that intentionally sets it. The explicit no-scrub escape hatch still preserves it byte-for-byte. ## Known residual limitations - Nix `extraPythonPackages` may append runtime plugin paths outside the repo/venv ownership boundary. A `/nix/store` path is not sufficient provenance because users can legitimately put Nix paths in `PYTHONPATH`; these entries are deliberately preserved. A future producer-side provenance contract should be discussed separately if needed. - `HostSupervisor` has an independent internal-child environment overwrite pattern. It does not block the user-facing #74817 subprocess path and is intentionally left for a separate issue/PR. ## Tests - `scripts/run_tests.sh tests/tools/test_local_env_blocklist.py -q` — 75 passed, 2 skipped (Windows-only) ### Windows follow-up (commit e00e9dc17) The five failures from the reporter's native run are test-code portability, not production bugs; the follow-up commit makes the tests express the true per-platform contract: - os.pathsep-based inputs/parsing in the four :-hardcoded tests. - The sane-PATH merge tests now assert the documented Windows passthrough (Git Bash prepend neutralised) and the POSIX Homebrew append respectively. - The macOS launchd regression (#35613) is marked macos_only per the repo OS-marker policy. - The HERMES_HOME alias test creates its directory link via a helper that falls back to an unprivileged junction (cmd /c mklink /J, locale-safe binary capture) when symlink creation raises WinError 1314, and skips with a clear reason if no mechanism exists. - Also corrected a stale execute_code comment claiming the sandbox runs the same Python as Hermes (project mode can select an external venv). Validation of the exact follow-up commit: - Native Windows 11 (isolated temp checkout at the PR head, Python 3.12): baseline tests/tools/test_local_env_blocklist.py reproduced 4 separator failures (73 passed / 4 failed; the symlink test passed on this host because Developer Mode/symlink privilege is available -- it is the reporter's WinError 1314 host that needs the junction fallback); after the follow-up: **76 passed, 1 skipped (macos_only), 0 failed**. - Real directory-junction experiments on the same host (same-drive C: and cross-drive D: junctions, junction-backed HERMES_HOME): _build_hermes_repo_root_aliases and _strip_hermes_owned_pythonpath verified end to end (see the junction section below). ### Junction lexical root across profile re-home (commits 7d72996e4, d66ead3aa) @vollegrewar's residual (the lexical repo-root spelling survives stripping) was traced to a **supported production path**, confirmed on native Windows 11 with a real junction and the real startup chain: - Launchers spawn the backend with HERMES_HOME in the configured (lexical) spelling (e.g. D:\\hermes) and PYTHONPATH entries in that same spelling (D:\\hermes\\hermes-agent). - With `--profile <name>` / `--profile default` / a sticky `active_profile`, `_apply_profile_override()` (hermes_cli/main.py, module level, before any Hermes imports) re-homes HERMES_HOME through `resolve_profile_env()`. That resolver resolved the junction under the platform default and returned the **physical** spelling, so by the time `tools.environments.local` is imported and `_hermes_repo_root_aliases` is built, the launcher's lexical spelling is unrecoverable in-process and the lexical repo-root entry survives stripping. - Reproduced matrix on native Windows 11 (real junction, real `hermes_cli.main` module-level override): no profile strips the entry; `--profile default`, `--profile <name>`, sticky `active_profile`, and a cross-drive D: junction all leave it in place (exactly the reported residual). Narrow fix, no heuristics, no new env vars, no profile-system refactor: - `hermes_cli/profiles.py::resolve_profile_env`: when HERMES_HOME is set, the configured spelling IS the launch root (junction-transparent -- physically identical directories); keep it instead of re-deriving the native default. Existing resolution invariants are covered by new unit tests (root env -> `<root>/profiles/<name>`; profile-shaped env -> `<root>/profiles/<name>` with no nesting; profile-shaped env + default -> `<root>`; custom roots never fall back to the platform default; missing named profiles still raise). - `tools/environments/local.py::_build_hermes_repo_root_aliases`: when the configured home is a profile home (`<root>/profiles/<name>`), also derive the root spelling lexically (parent of the \"profiles\" component, the same rule `get_default_hermes_root()` uses) and run the existing exact-ownership mapping against it -- the launcher's lexical root is recovered after re-home without ever matching arbitrary descendants of HERMES_HOME. Validation of the exact fix commit on native Windows 11: the 5-case matrix above now strips the lexical repo-root entry in **every** case (no profile / default / named / sticky / cross-drive D:), and `tests/tools/test_local_env_blocklist.py` passes 77 passed / 1 skipped (macos_only) including the new regression test `test_profile_rehome_keeps_junction_lexical_alias`. macOS targeted suites: 134 passed / 4 skipped (blocklist + profiles + apply-profile-override), related subprocess suites 121 passed / 3 skipped. - macOS: tests/tools/test_local_env_blocklist.py -- 75 passed, 2 skipped (Windows-only); test_code_execution_modes.py test_code_execution.py test_code_execution_windows_env.py test_build_subprocess_env.py test_hermes_subprocess_env.py -- 121 passed, 3 skipped. - .venv/bin/ruff check / py_compile / git diff --check on the changed files -- passed. - `scripts/run_tests.sh tests/tools/test_code_execution_modes.py tests/tools/test_code_execution.py tests/tools/test_code_execution_windows_env.py -q` — 99 passed, 3 skipped (Windows-only; includes the #84500 suite, unmodified) - `scripts/run_tests.sh tests/tools/test_build_subprocess_env.py tests/tools/test_hermes_subprocess_env.py tests/tools/test_code_execution_modes.py tests/tools/test_code_execution_windows_env.py tests/cron/test_cron_script.py tests/tools/test_tts_pythonpath_fallback.py -q` — 109 passed, 4 skipped - `scripts/run_tests.sh tests/hermes_cli/test_gateway_windows.py tests/hermes_cli/test_update_gateway_launcher_refresh.py tests/gateway/test_restart_drain.py -q` — 15 passed, 5 skipped - `.venv/bin/ruff check tools/environments/local.py tools/code_execution_tool.py tests/tools/test_local_env_blocklist.py` — passed - `.venv/bin/python -m py_compile tools/environments/local.py tools/code_execution_tool.py tests/tools/test_local_env_blocklist.py` — passed - `git diff --check` — passed - `scripts/ci/list_os_marked_tests.py windows_only` — lists `tests/tools/test_local_env_blocklist.py` (Windows lane imports it; the native test is selected by the marker) - New integration test `test_execute_code_composition_strips_inherited_hermes_entries` (2 parametrized cases) drives `execute_code` to Popen with a contaminated inherited `PYTHONPATH`, proving the #84500 + #82581 composition contract: staging first, inherited Hermes site-packages stripped, repo root re-added exactly once for a same-env child / absent for an external child, user entries preserved in order. - Full `scripts/run_tests.sh` was paired against pristine `main` at `2446c8bb6` with the same wrapper/Python. A follow-up rerun of the exact 32 failing files produced the identical 96 failing node IDs on both revisions (`1017 passed, 96 failed`); there were no branch-only failures. For this rebase onto `f4c2c263f`, the #84500 intersection suites were added to the regression set and pass unmodified. - macOS arm64 real E2E: the contaminated environment makes independent Python 3.13 load the Hermes Python 3.11 NumPy tree and fail; the sanitized environment preserves `/custom/lib/python3.13/site-packages`, and Python 3.13 then imports NumPy and Pillow from its own conda environment successfully. ## Lineage / attribution This branch preserves the original Git commit authors instead of squashing or recreating their work: - `mcjoys`: the initial selective stripping commit, carried through #78917 from the earlier #61028 lineage. - `Yiipu`: repo-root correction and boundary tests from #78917. - `Xinyu Du` / `Starfie1d1272`: independent macOS reproduction, adversarial review, removal of the cross-version heuristic, user `PYTHONPATH` preservation, `PYTHONHOME`, Windows/runtime ownership hardening, current-main integration, and regression/E2E validation. No synthetic `Co-authored-by` trailers are added because the Git history already preserves authorship. ## Relationship to existing work and issues This PR consolidates and builds on #78917 by @Yiipu and its #61028 lineage while addressing the correctness gaps found during real reproduction and adversarial review. It targets #74817 and the inherited-`PYTHONHOME` portion of #75018, with related context in #65909 and #57467.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/82581",
          "createdAt": "2026-08-09T16:03:32Z",
          "updatedAt": "2026-08-13T18:02:10Z",
          "timestamp": "2026-08-13T18:02:10Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "comp/tools",
            "tool/code-exec",
            "backend/local",
            "P2",
            "sweeper:risk-compatibility"
          ],
          "author": "Starfie1d1272",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:04df6409d35efe28ee47",
        "signalId": "github:NousResearch/hermes-agent:pull_request:78162",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:78162",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(media): media-delivery denylist is inert on Windows",
          "text": "## The bug `_MEDIA_DELIVERY_DENIED_PREFIXES` is POSIX-only, and the entries **don't degrade gracefully**. `Path(\"/etc\").resolve()` becomes `C:\\etc` on the current drive — which doesn't exist. So on Windows every entry in that tuple is inert and the default-mode denylist is effectively empty. Measured against unmodified `main` on Windows: ``` /etc -> C:\\etc exists=False /sys -> C:\\sys exists=False /root -> C:\\root exists=False ...all nine resolve to nonexistent paths C:\\Windows covered=False C:\\ProgramData covered=False ``` Verified end-to-end through the guard itself, not just the constant: ```python validate_media_delivery_path(r\"C:\\Windows\\win.ini\") before -> <path> # deliverable as a gateway attachment after -> None # refused ``` **Strict mode was unaffected** — the `~/.ssh` denial works, because those entries are built from the live `$HOME` rather than hard-coded POSIX roots. This is the *default* (non-strict) path, which is what most operators run. ## The fix Two Windows-specific lists: **System roots**, resolved from `%SystemRoot%` and `%ProgramData%` — read from the environment rather than hard-coded, because Windows needn't live on `C:` and a roaming profile needn't sit under `C:\\Users`. **Credential stores under the user profile** that have no POSIX counterpart: `Microsoft\\Credentials` (roaming and local), `Microsoft\\Protect` (DPAPI master keys), `Microsoft\\Crypto`, `Local\\Microsoft\\Vault`. The POSIX dotfile equivalents (`.aws`, `.ssh`, `.azure`, `.gcloud`) use the same names on Windows and are already covered by `_MEDIA_DELIVERY_DENIED_HOME_SUBPATHS`. **AppData is deliberately not denied wholesale.** `%LOCALAPPDATA%\\Temp` is a normal home for generated artifacts, so a blanket rule would break legitimate media delivery. A test asserts `Temp`, `Pictures` and `Downloads` stay deliverable. ## Verification (Windows) - **9 new tests. 6 fail when the production change is reverted** — so the coverage pins real behaviour rather than passing vacuously. The 3 that pass either way are the must-stay-deliverable assertions. - `tests/gateway/test_platform_base.py`: 2 failed before, 2 failed after. **Set-diff of failure names is empty** — nothing newly broken. Those 2 are unrelated pre-existing failures. - `ruff check` clean. ## Relationship to #52045 Complementary, not overlapping. #52045 rewrites the *comparison* helpers to be case-insensitive (`_path_compare_key`, `_path_under_or_equal`); this fixes *what is in the list* to compare against. Different hunks in the same file — #52045 touches the helpers around :52 and `_path_under_denied_prefix` at :1134, this touches the constants at :1196 and `_media_delivery_denied_paths` at :1333. Both are needed: case-folding a denylist that contains only nonexistent paths still denies nothing. 🤖 Generated with [Claude Code](https://claude.com/claude-code)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/78162",
          "createdAt": "2026-08-04T03:53:48Z",
          "updatedAt": "2026-08-13T17:54:52Z",
          "timestamp": "2026-08-13T17:54:52Z",
          "metrics": {
            "reactions": 0,
            "comments": 2
          },
          "labels": [
            "type/security",
            "comp/gateway",
            "area/auth",
            "P2",
            "sweeper:risk-message-delivery",
            "sweeper:risk-security-boundary",
            "sweeper:risk-platform-windows",
            "platform/windows"
          ],
          "author": "bbasketballer75",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:36890141ac7f01b72c07",
        "signalId": "github:NousResearch/hermes-agent:issue:85433",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85433",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Gateway: same-epoch orphaned .drain_request.json has no TTL — agent bounces every message indefinitely (\"draining for a maintenance action\")",
          "text": "### Summary A `.drain_request.json` marker that is orphaned **without a machine restart** wedges the gateway in `draining` indefinitely — every inbound message on every platform is bounced with *\"⏳ This agent is draining for a maintenance action and isn't accepting new turns right now\"* until someone manually removes the marker or the container happens to be recreated. ### Real-world impact A Hermes Cloud instance had a begin-drain marker written to the durable volume (`/opt/data/.drain_request.json`) for a maintenance action. The action finished, but the marker was never cleared, and the container was **not** restarted afterwards. The gateway bounced every inbound Telegram message for ~3 days until the operator diagnosed it and removed the marker via `clear_drain_request()`. From the user's side the agent looked alive (the bot answers instantly) but refused every turn. ### Why the existing NS-570 fix doesn't cover this `8ab7246c45` (PR #53050) stamps the marker with an instantiation epoch so a marker that survives a **machine restart** is recognised as stale. That fix is built on the assumption stated in `gateway/drain_control.py`'s module docstring: > the disruptive lifecycle actions a drain protects (auto-update / image migrate / env edit / profile change) all **restart the machine**, which is exactly the signal that the drain is over. This incident violated that assumption: the maintenance action completed without recreating the container, so the epoch still matched, `_marker_epoch_is_stale()` correctly returned `False`, and the 1s `_drain_control_watcher` kept honouring the orphan — forever. ### The gap The marker already carries `requested_at` (ISO-8601 UTC, written by `write_drain_request()`), but **no reader ever looks at it**. There is no TTL/max-age fallback, so a same-epoch orphan has unbounded lifetime. Compare the neighbouring `.restart_notify.json` marker, which `gateway/run.py` guards with a 5-minute `requested_at` staleness check precisely so \"a legitimately old marker … should not swallow a fresh /restart\". ### Proposed fix Add a lenient max-age fallback to the reader in `gateway/drain_control.py`, alongside the epoch check: * a marker whose `requested_at` is present, parseable, and older than a generous bound (drain-gated lifecycle actions complete in minutes; 60 min is conservative) reads as stale in `drain_requested()` / `drain_notification_suppressed()`, with a warning log; * a marker with **no** `requested_at`, or an unparseable one, stays honoured — same fail-safe-toward-quiescing leniency as the epoch check; * a deliberately long drain keeps a sanctioned keep-alive: `write_drain_request()` is documented as idempotent and re-writing refreshes `requested_at`. (The writer side — the cloud control plane clearing the marker in a `finally` after non-restart maintenance actions — is out of scope for this repo, but the gateway should defend itself regardless: any writer crash between \"action done\" and \"cancel drain\" reproduces this.) ### Environment - Hermes Cloud (durable Fly volume `HERMES_HOME=/opt/data`) - Verified against current `main` (`fa83af3f9a`): `drain_requested()` has only the epoch staleness check; `requested_at` is write-only.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85433",
          "createdAt": "2026-08-13T16:43:18Z",
          "updatedAt": "2026-08-13T17:53:50Z",
          "timestamp": "2026-08-13T17:53:50Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "P1",
            "sweeper:risk-message-delivery"
          ],
          "author": "kshitijk4poor",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:ecd75d97c7b01d5018ee",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85444",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85444",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(gateway): expire orphaned drain markers past a max-age so a leaked marker can't wedge the gateway",
          "text": "## Summary A `.drain_request.json` marker orphaned **without a machine restart** wedges the gateway in `draining` indefinitely — every inbound message on every platform is bounced with *\"⏳ This agent is draining for a maintenance action…\"* until someone manually deletes the marker. Observed in the field: a Hermes Cloud instance refused all Telegram turns for ~3 days (marker written for a maintenance action that completed without recreating the container; the writer never cancelled the drain). The NS-570 epoch stamp (#53050) only clears markers that survive a **restart** — it bakes in the assumption that every drain-gated action ends in one. A same-epoch orphan passes the epoch check forever, and the marker's `requested_at` was write-only: no reader ever looked at it. Compare the neighbouring `.restart_notify.json` marker, which `gateway/run.py` guards with a 5-minute `requested_at` staleness check for exactly this reason. Fixes #85433. ## Changes - `gateway/drain_control.py`: - New `_marker_is_expired()`: a marker whose `requested_at` parses and is older than `DRAIN_REQUEST_MAX_AGE_SECONDS` (1h) reads as stale. Same leniency contract as the epoch check — a missing/unparseable timestamp still reads as drain-active (fail-safe toward quiescing), a future-dated timestamp (clock skew) is honoured, a tz-naive timestamp is treated as UTC. - New `_marker_is_stale()` composes epoch-mismatch OR expiry; `drain_requested()` and `drain_notification_suppressed()` both route through it (all external callers — the 1s gateway watcher, the shutdown broadcast gate, and the dashboard status endpoint — go through these two readers, so every path is covered). - Expiry logs a warning **once per marker** (keyed by `requested_at`), not once per 1s poll tick (~86k/day otherwise). A refreshed marker that expires again warns again. - Long drains keep a sanctioned keep-alive: `write_drain_request()` is idempotent and re-writing refreshes `requested_at` (docstring updated to say so). ## Before / after Before: marker written at 20:04 for a no-restart maintenance action, never cleared → gateway bounces every message with the draining text **indefinitely** (same epoch → honoured on every 1s tick). After: the same orphan is honoured for at most 1h, then reads as absent; the watcher flips the gateway back to `running` on the next tick and logs one warning naming the marker's timestamp, age, and principal. ## Validation - `tests/gateway/test_external_drain_control.py`: 7 new tests — expired marker reads as absent (drain state AND suppression flag), missing timestamp honoured, unparseable timestamp honoured, tz-naive timestamp expires, keep-alive re-write revives an expired drain, expiry warning fires once across repeated polls and re-fires for a refreshed marker. - Full file: 18 passed. Wider `-k \"drain or restart\"` slice over `tests/gateway/`: 168 passed, 3 skipped. - Mutation check: reverting `gateway/drain_control.py` to the pre-fix version makes the 4 new-behavior guard tests fail (the leniency tests correctly still pass — they assert behavior that predates the fix); restoring goes green. - `ruff check` clean on both files.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85444",
          "createdAt": "2026-08-13T16:56:21Z",
          "updatedAt": "2026-08-13T17:53:49Z",
          "timestamp": "2026-08-13T17:53:49Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/gateway",
            "P1",
            "sweeper:risk-message-delivery"
          ],
          "author": "kshitijk4poor",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:817f8915278c004294c2",
        "signalId": "github:NousResearch/hermes-agent:pull_request:77263",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:77263",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(usage,security): desktop usage & cost surface + Safety & Security panel",
          "text": "<!-- native-links:v1 --> Related #77221 #77222 #77223 #77262 ## What changed and why This PR turns Hermes' existing local usage accounting and security controls into first-class desktop surfaces. ### 1. Usage observability (`/usage`) The desktop now provides three distinct Usage decks: - **Overview** — profile/session telemetry from `usage.overview`: sessions, canonical tokens, provider actual cost, local estimated cost, cache activity, activity history, and top sessions. - **Routes** — installation-wide traffic captured by the optional `usage-meter` plugin, grouped by provider/model/endpoint with explicit priced, included, and unpriced coverage. - **Call Ledger** — installation-wide events from `usage.meter.recent`, including route, task, token, pricing-status, pricing-source, and cost evidence. Profile/session analytics and installation-wide capture remain visibly separate because they are not atomically equivalent populations. Accounting behavior is evidence-preserving: - Overview, model, platform, daily-series, top-session, and cost-bucket reducers share one reconciled main-plus-auxiliary row population. - Provider actual cost remains distinct from local estimation, while actual rows participate in known market-comparison coverage rather than falling into the unknown bucket. - Main-loop legacy rows may inherit missing session route metadata; auxiliary tasks remain route-isolated. - A literal provider actual `$0.00` is shown only when producer evidence proves it. Missing or malformed evidence renders `—`. - Malformed token buckets reject the captured meter event instead of becoming an invented zero-token priced call. - Included subscription usage remains billed at `$0.00`; public market-equivalent pricing is a separate contract. - Mixed estimated/included sessions preserve every route contribution. Missing required comparison evidence makes the result unavailable rather than partial. - Reasoning tokens remain an output subset and are never added to canonical totals twice. - Overview and daily series use the same local-calendar boundary. The desktop boundary preserves absent and malformed RPC fields as unavailable. It includes localized navigation, labels, units, and dates; RTL-logical layout; Hermes `Tip` tooltips; accessible compact controls; a single pane-owned vertical scroll surface; and responsive midnight-black wide, medium, and mobile layouts. ### 2. Safety & Security panel (Settings → Safety) The former flat toggle list becomes a live posture surface: - `security.status` reports redaction, approvals, Tirith pre-exec scanning, checkpoints, and a redaction self-test. - `security.scan` exercises `check_command_security` and reports allow/warn/block findings. - The desktop explains each control, persists settings through existing config paths, and provides interactive verification. ## Placement rationale This extends existing Hermes seams and introduces no third-party observability backend, parallel pricing store, or new model-tool schema: - `InsightsEngine` remains the profile/session analytics engine. - `usage_pricing.py` remains the billing and public-comparison pricing engine. - `usage.overview` and `usage.meter.*` use the existing gateway RPC registry. - Installation-wide capture remains optional through `hermes plugins enable usage-meter`. ## Verification Final owner-directed shipment candidate: `7c8ec9a9a673ca026ce88ab6b161bcf4d3add2f7` (supersedes `6cc368eaae4e0f2377e894414d41e8e949752652`). Closed after the initial publication: - stored session cost is reconciled with per-model derived rows instead of counting the same estimate twice; - a route containing any unpriced calls reports cost as unavailable rather than displaying a partial subtotal; - Messaging, Skills, and Gateway Settings imports no longer consume the ordinary behavioral test budget; the Gateway test imports its component statically with an isolated profile-store mock. Exact-final receipts: - Messaging, Skills, and Gateway Settings regression boundary: **3 passed files; 13 passed tests**; - Gateway Settings exact test: **2 passed in 824 ms**; - desktop TypeScript checks: **PASS**; - affected ESLint with zero warnings: **PASS**; - diff check and clean committed worktree: **PASS**; - guarded remote push and PR-head readback at `7c8ec9a9a673ca026ce88ab6b161bcf4d3add2f7`: **PASS**. The complete suite on immediate parent `4c5e074ccab9a21ed890766718b0d466b0d15dc1` executed **382 passed / 383 files; 3,320 passed tests; 2 skipped**. Its only failure was the Gateway Settings suite's 60-second `beforeAll` dynamic-import hook. The final commit removes that hook; all 13 assertions across the three formerly failing files pass on the final candidate. GitHub CI was retriggered through exact head `7c8ec9a9a673ca026ce88ab6b161bcf4d3add2f7`. The code-bearing CI run passed; the remaining non-green runner job failed before tests while downloading Python from GitHub (HTTP 503 Service Unavailable). ## Platforms tested - Windows 10 native desktop build and test toolchain. - Python changes are platform-neutral stdlib/SQLite code; report buckets intentionally use the host's local calendar boundary. ## Related issues - Part of #77221 — desktop Usage surface and installation-wide captured meter - Part of #77222 — time-series aggregation - Part of #77223 — cost buckets and pricing coverage - Part of #77262 — Safety section overhaul ## Contributor provenance The installation-wide per-call meter is credited to @muhammadshess-10xe from #77221 and remains carried in commit `2fb128e2c86`. The dashboard builds on that seam without collapsing it into profile/session telemetry.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/77263",
          "createdAt": "2026-08-03T02:56:00Z",
          "updatedAt": "2026-08-13T17:52:40Z",
          "timestamp": "2026-08-13T17:52:40Z",
          "metrics": {
            "reactions": 0,
            "comments": 5
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "comp/tui",
            "P3",
            "comp/desktop",
            "area/billing"
          ],
          "author": "andrexibiza",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:a7ffe38ef2d45f3feaa5",
        "signalId": "github:NousResearch/hermes-agent:issue:2513",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:2513",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Bug]: Custom providers lack context length auto-detection when saved via /model command",
          "text": "### Bug Description **Summary:** When users save a custom provider using the `/model` command without specifying a context length, the system doesn't automatically detect and display what context length was actually used. This leaves users uncertain about whether their custom provider has a short or long context window, which is critical for prompt caching behavior. **Impact:** - Users cannot easily verify if their custom provider has adequate context window - Prompt caching may be less effective than expected - No visual feedback distinguishes between auto-detected and default context lengths ### Steps to Reproduce run hermes model select a custom provider do not enter a context length ### Expected Behavior After saving a custom provider without a context length, the system should: 1. Attempt to auto-detect the context length based on the model name 2. Display the detected context length to the user 3. Fall back to a sensible default (128K tokens) if detection fails 4. Indicate visually when the default was used vs. when auto-detection succeeded ### Actual Behavior Users who save a custom provider without specifying context length have no feedback on what context length is being used. This can lead to unexpected prompt caching behavior and higher token costs. ### Affected Component CLI (interactive chat) ### Messaging Platform (if gateway-related) _No response_ ### Operating System Debian 13.4 ### Python Version 3.13.3 ### Hermes Version 0.4 ### Relevant Logs / Traceback ```shell ``` ### Root Cause Analysis (optional) _No response_ ### Proposed Fix (optional) _No response_ ### Are you willing to submit a PR for this? - [x] I'd like to fix this myself and submit a PR",
          "url": "https://github.com/NousResearch/hermes-agent/issues/2513",
          "createdAt": "2026-03-22T16:00:19Z",
          "updatedAt": "2026-08-13T17:52:22Z",
          "timestamp": "2026-08-13T17:52:22Z",
          "metrics": {
            "reactions": 1,
            "comments": 5
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "area/config",
            "P3",
            "sweeper:risk-compatibility"
          ],
          "author": "InB4DevOps",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:68340855aa05dc475a54",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85388",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85388",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(pricing): apply DeepSeek peak/off-peak rate card (#72662)",
          "text": "## Summary Implements #72662: DeepSeek's official rate card switches to peak/off-peak billing at **2026-08-16T16:00 UTC** (announced on https://api-docs.deepseek.com/quick_start/pricing): - Peak hours: **01:00–04:00 and 06:00–10:00 UTC** (all other hours off-peak) - Peak = 2× off-peak on every billing item (cache-hit input, cache-miss input, output) ## Changes ### `agent/usage_pricing.py` - **Snapshot updated to the 2026-08-16 OFF-PEAK rates**: deepseek-v4-flash $0.007 / $0.22 / $0.66 per 1M (cache-hit / cache-miss / output), deepseek-v4-pro $0.022 / $0.66 / $1.98. `pricing_version` bumped to `deepseek-pricing-2026-08-16`. The deepseek-chat / deepseek-reasoner aliases keep sharing flash's rates. - **`estimate_usage_cost` peak/off-peak selection**: before the switchover instant the legacy flat card (`deepseek-pricing-2026-07`) applies; after it, calls during peak hours bill at 2× the off-peak rates and the `CostResult` carries a \"peak-hour rate applied\" note. Peak windows are read as half-open intervals → hours 1, 2, 3 and 6, 7, 8, 9. - The legacy flat card lives in a small temporary module dict (`_DEEPSEEK_LEGACY_FLAT_RATES`) so estimates stay accurate during the transition window; it is marked for removal after the switchover. ### `tests/agent/test_usage_pricing.py` 7 new tests: - peak-hour boundary matrix (hours 0–11, 23) incl. exact window edges (00:59:59 → 01:00:00, 03:59:59 → 04:00:00, 09:59:59 → 10:00:00) - official-table amounts for both models (1M in + 1M out: flash $0.88 off-peak / $1.76 peak; pro $2.64 / $5.28) - cache-read tokens scale at peak ($0.007 → $0.014 flash) - pre-switchover legacy flat rates (incl. a would-be peak hour) - switchover instant boundary (15:59:59Z legacy, 16:00:00Z new card) - non-DeepSeek providers unaffected during peak hours - updated `test_deepseek_v4_pro_pricing_entry_exists` to the new card ## Testing - `tests/agent/test_usage_pricing.py`: 20 passed - All usage_pricing consumers (insights, model cost guard, usage command, MoA loop, context engine): 100 passed total - A large combined batch run shows ~120 failures in unrelated suites (title generator, secret-scope migration, codex transport, etc.) that pass in isolation on both main and this branch — known order-dependent batch interference in this repo, unrelated to this change. ## Notes - **Boundary semantics**: peak windows are treated as half-open [01:00, 04:00) and [06:00, 10:00) UTC, matching the 09:00–12:00 / 14:00–18:00 Beijing framing (clean 12:00–14:00 lunch gap). Hour 4 (04:00–05:00) is off-peak. - **Transition window**: rates are selected at call time (post-request), matching DeepSeek's per-request timestamp billing. Before 2026-08-16T16:00Z the legacy flat card is used, so estimates don't overstate during the transition. - Supersedes the earlier attempt #72756, whose 2×-on-current-rates approach no longer matches DeepSeek's published pricing (the official off-peak rates themselves differ from the current flat card). - **Credit**: feature request and the peak-window UTC mapping are from @dominicelayda's issue #72662; @webtecnica's earlier attempt (#72756) established the multiplier approach and surfaced the need for the official rate card. This PR is a fresh implementation against the official card. Closes #72662",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85388",
          "createdAt": "2026-08-13T15:24:17Z",
          "updatedAt": "2026-08-13T17:52:19Z",
          "timestamp": "2026-08-13T17:52:19Z",
          "metrics": {
            "reactions": 0,
            "comments": 6
          },
          "labels": [
            "type/feature",
            "comp/agent",
            "provider/deepseek",
            "P3",
            "area/usage-cost"
          ],
          "author": "DavidMetcalfe",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:e830349f508842358cb1",
        "signalId": "github:NousResearch/hermes-agent:issue:58596",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:58596",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "DaemonThreadPoolExecutor crashes on Python 3.14: `_initializer` attribute removed",
          "text": "## Bug Description `DaemonThreadPoolExecutor._adjust_thread_count()` references `self._initializer` and `self._initargs`, which no longer exist in Python 3.14. This breaks **all** concurrent features: `delegate_task`, async delegation, skills hub fan-out, and memory sync. ``` Error during OpenAI-compatible API call: 'DaemonThreadPoolExecutor' object has no attribute '_initializer' ``` ## Root Cause Python 3.14 changed `ThreadPoolExecutor` internals: | | Python ≤ 3.13 | Python ≥ 3.14 | |---|---|---| | Initializer storage | `self._initializer`, `self._initargs` (instance attrs) | `self._create_worker_context()` returns a `WorkerContext` | | `_worker()` signature | `_worker(executor_ref, work_queue, initializer, initargs)` (4 params) | `_worker(executor_ref, ctx, work_queue)` (3 params) | | Context management | None | `WorkerContext.initialize()` / `ctx.finalize()` lifecycle | The code at `tools/daemon_pool.py:58-59` directly accesses the removed attributes. ## Affected Code File: `tools/daemon_pool.py`, method `_adjust_thread_count`, lines 55-60: ```python args=( weakref.ref(self, weakref_cb), self._work_queue, self._initializer, # AttributeError in 3.14 self._initargs, # AttributeError in 3.14 ), ``` ## Reproduction ``` Python 3.14.6 from tools.daemon_pool import DaemonThreadPoolExecutor e = DaemonThreadPoolExecutor(max_workers=1) e.submit(lambda: 42) # -> AttributeError: 'DaemonThreadPoolExecutor' object has no attribute '_initializer' ``` ## Proposed Fix Detect `_worker` signature at import time and branch accordingly: ```python import inspect from concurrent.futures.thread import _worker _worker_uses_ctx = len(inspect.signature(_worker).parameters) == 3 # In _adjust_thread_count: if _worker_uses_ctx: # Python >= 3.14: _worker(executor_ref, ctx, work_queue) args = ( weakref.ref(self, weakref_cb), self._create_worker_context(), self._work_queue, ) else: # Python <= 3.13: _worker(executor_ref, work_queue, initializer, initargs) args = ( weakref.ref(self, weakref_cb), self._work_queue, self._initializer, self._initargs, ) ``` This fix: - Uses `inspect.signature(_worker)` at import time (zero overhead per call) - Passes `self._create_worker_context()` for 3.14 (returns a `WorkerContext` with `initialize()`/`finalize()` lifecycle) - Falls back to old `_initializer`/`_initargs` for <= 3.13 - Preserves the original semantics (daemon=True, no `_threads_queues` registration) ## Environment - Hermes Agent: v0.18.0 (2026.7.1) - Python: 3.14.6 - Platform: aarch64-linux-android (Termux) - Verified: patch applied locally, `DaemonThreadPoolExecutor` creates, submits tasks, and shuts down correctly ## Impact **Critical** — on Python 3.14, every feature using `DaemonThreadPoolExecutor` crashes: `delegate_task`, `async_delegation`, `skills_hub`, `memory_manager`, `tool_executor`.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/58596",
          "createdAt": "2026-07-05T01:29:33Z",
          "updatedAt": "2026-08-13T17:52:19Z",
          "timestamp": "2026-08-13T17:52:19Z",
          "metrics": {
            "reactions": 3,
            "comments": 7
          },
          "labels": [
            "type/bug",
            "comp/tools",
            "tool/delegate",
            "P3",
            "sweeper:risk-compatibility"
          ],
          "author": "iamzulx",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:e5dff79adaef3268a23a",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85499",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85499",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(cli): show context-length detection result when saving custom provider",
          "text": "Fixes #2513. When a custom provider is saved via `hermes model` / `hermes setup` with the context-length prompt left blank, the value stays `None` and the runtime resolution chain (`agent/model_metadata.get_model_context_length`) runs silently later. Users got no feedback about which context window was actually in effect — or whether it was a real detection vs. the hard fallback. This implements the approach the closed reference PR #2522 was verified for, adapted to current main (the flow moved from `hermes_cli/main.py::_model_flow_custom` to `hermes_cli/model_setup_flows.py`): - After parsing the blank input, call `get_model_context_length(model_name, base_url=effective_url, api_key=effective_key)` — the full 9-step resolution chain (endpoint probe, local server, registries, family defaults). - Distinguish a genuinely detected value from `DEFAULT_FALLBACK_CONTEXT` so the fallback is never mislabeled as \"auto-detected\". - Persist only real detections into the `custom_providers` entry; the bare fallback stays unset (so the runtime re-resolves each startup and logs its own fallback warning). - Print one clear line either way, using `_format_context_length` (`256K`-style). - Wrap the whole probe in try/except — a failing probe must never block saving the provider. Regression tests (`tests/hermes_cli/test_custom_provider_context_feedback.py`) cover: - known model resolves to a non-fallback value - unknown model falls through to `DEFAULT_FALLBACK_CONTEXT` - the fallback sentinel is distinguishable from a real detection - blank model name never triggers a probe Note: the fallback constant on current main is 256K (`CONTEXT_PROBE_TIERS[0]`), not the 128K mentioned in the issue — the messages reference `DEFAULT_FALLBACK_CONTEXT` directly so they stay correct if the tier list changes.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85499",
          "createdAt": "2026-08-13T17:46:24Z",
          "updatedAt": "2026-08-13T17:52:15Z",
          "timestamp": "2026-08-13T17:52:15Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "area/config",
            "P3",
            "sweeper:risk-compatibility"
          ],
          "author": "Luna161",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:b0ecbad5d83fc8ea8e68",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85498",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt",
          "labels",
          "state"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85498",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): default context lookup for empty model IDs (salvage #65515) — un-reds main CI",
          "text": "## Summary Every open PR's CI is currently red on one shard with: ``` FAILED tests/run_agent/test_primary_runtime_restore.py::TestTryRecoverPrimaryTransport::test_allowed_for_nous_anthropic_messages ValueError: Model has a context window of 32,000 tokens, which is below the minimum 64,000 required by Hermes Agent. ``` (Seen on #85444 slice 7/12, #85452 slice 2/12; reproduces locally on plain `upstream/main` files.) Root cause: the test constructs an agent with `model=\"\"` against the live Nous portal URL. `get_model_context_length(\"\")` reaches `_resolve_endpoint_context_length`, whose fuzzy matcher ```python if model in key or key in model: ``` is **vacuously true for an empty model** — `\"\" in key` matches every entry — so it picks an arbitrary model from the live `/v1/models` response and returns that entry's context length. The portal catalog changed recently; the arbitrary match now lands on a 32K entry, `init_agent` raises the 64K-floor `ValueError`, and the junk value is persisted under a `@https://inference-api.nousresearch.com/v1` cache key (visible in the failing log: `Cached context length @https://... -> 32,000 tokens`). This is a salvage of #65515 by @whirmill (open since July 16), rebased onto current main — the same guard, now positioned after the malformed-base_url normalization that landed since, plus an explanatory comment documenting the fuzzy-match footgun. Contributor authorship preserved on the commit. ## Changes - `agent/model_metadata.py`: a blank/empty model id falls back to `DEFAULT_FALLBACK_CONTEXT` immediately — before any cache write or network probe. - `tests/agent/test_model_metadata.py`: `test_empty_model_uses_fallback_context` (covers `\"\"` and `None`). ## Validation - The two previously failing/new tests pass: `test_allowed_for_nous_anthropic_messages` + `test_empty_model_uses_fallback_context`. - Full `tests/agent/test_model_metadata.py` + `tests/run_agent/test_primary_runtime_restore.py`: 92 passed. - Mutation check: removing the guard makes `test_empty_model_uses_fallback_context` fail (arbitrary live-catalog match returns non-default), restoring goes green. - `ruff check` clean. Closes #65515.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85498",
          "createdAt": "2026-08-13T17:43:57Z",
          "updatedAt": "2026-08-13T17:52:11Z",
          "timestamp": "2026-08-13T17:52:11Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "P1"
          ],
          "author": "kshitijk4poor",
          "state": "closed",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:322cf7fc79e97d7ad638",
        "signalId": "github:NousResearch/hermes-agent:issue:85497",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt",
          "labels"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85497",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "[Feature]: kanban HERMES_TENANT has no memory-isolation effect — worker memory pollutes across tenants",
          "text": "### Problem or Use Case Kanban workers receive a `HERMES_TENANT` env var from the dispatcher (`kanban_db.py:_default_spawn`, ~line 9018) when a task is created with `--tenant <slug>`, but that variable has **no memory-isolation effect**. All workers — regardless of board, tenant, or assignee profile — write to the same flat global memory store (`~/.hermes/memories/MEMORY.md`), so concurrent tasks across tenants pollute each other's persistent memory. The kanban docs describe `--tenant` as providing \"data isolation by workspace path and memory key prefix\" ([docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban)). The workspace-path half works; the **memory key prefix half is not implemented**. In a multi-project setup where one role profile (e.g. `implementer`) serves several tenant boards concurrently, the same profile's worker processes write unrelated project context into the same global memory — the next task on any tenant reads the previous tenant's memories. ### Proposed Solution When a kanban worker is spawned with `HERMES_TENANT=<slug>`, scope its memory reads/writes to a per-tenant namespace, mirroring the `context_id` scoping proposed in #47552 and #34352: 1. In `_default_spawn`, map `task.tenant` → the same `context_id` mechanism from #47552 (`MemoryStore` context scoping — writes into `memories/contexts/{context_id}/`, reads merge global + scoped), or 2. Set a dedicated env (e.g. `HERMES_MEMORY_CONTEXT_ID=tenant`) consumed by the memory tool, keeping kanban independent of gateway message-source derivation. Either way, the existing `HERMES_TENANT` plumbing in `_default_spawn` is the natural hook point — the isolation is already half-wired. ### Alternatives Considered - **Manual review of MEMORY.md** — current workaround; fragile at scale with 10+ tenant boards. - **Per-profile memory dirs** (`memories` under each profile) — doesn't help when one role profile serves multiple tenants concurrently, which is the exact kanban pattern. - **Separate boards as hard isolation** — boards already isolate task queues and workspaces, but worker *memory* still lands in one global store regardless of board; board isolation alone doesn't solve this. ### Feature Type - Configuration option / Performance & reliability (memory isolation for multi-tenant kanban workers) ### Scope - Small to Medium (hook `HERMES_TENANT` into the existing `context_id` plumbing in `_default_spawn` + memory tool; tests for scoped reads/writes) ### Contribution - [ ] I'd like to implement this myself and submit a PR --- ### Evidence (source inspection + runtime test) - `HERMES_TENANT` is set at `kanban_db.py:9017-9018` in `_default_spawn`. - The only reader is `tools/kanban_tools.py:1217` (default tenant for `kanban_create`) — no memory path consults it. - Runtime test 2026-08-14: two tasks on two different boards (`cognitive-tree-csharp`, `envoy-task`), both assigned to `decomposer`, each created with `--tenant <board>`. Both workers' memory writes landed in the single global `~/.hermes/memories/MEMORY.md` (mtime during the run window); the profile's own `memories/` dir stayed empty. ### Related - #34352 (Solving the Multi-Tenant Hermes Problem) - #47552 (feat(memory): opt-in context_id scoping — open PR, not merged) ### Environment - Hermes agent, commit aec331899 (recent main) - macOS 26.5, kanban dispatcher in gateway (dispatch_in_gateway: true)",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85497",
          "createdAt": "2026-08-13T17:43:33Z",
          "updatedAt": "2026-08-13T17:52:02Z",
          "timestamp": "2026-08-13T17:52:02Z",
          "metrics": {
            "reactions": 0,
            "comments": 0
          },
          "labels": [
            "type/feature",
            "comp/cron",
            "tool/memory",
            "P3",
            "needs-decision",
            "sweeper:risk-session-state",
            "area/memory"
          ],
          "author": "hua-hua3321",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:d8d1e203c5d2cc16ae6b",
        "signalId": "github:NousResearch/hermes-agent:pull_request:83878",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:83878",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(telegram): hold inbound messages across disconnect instead of destroying them",
          "text": "## Summary Current main's disconnect drop-guard (#55971) correctly refuses to dispatch buffered Telegram updates into a torn-down session (`_should_drop_delayed_delivery` / `_drop_delayed_deliveries`). The implementation still **destroys** the inbound event: debug-level `return` after `pop()` (or before enqueue) at the text/photo/media-group batch sites, and `_cancel_pending_delivery_tasks` clears pending maps with no salvage. By then python-telegram-bot has already accepted the update and advanced the polling offset, so Telegram will never redeliver. Result: silent permanent loss — no WARNING, no error, no retry. Operator signature: messages visible in Telegram (later quoted via `reply_to_id`) never appear as gateway inbound or in `state.db` for that turn — consistent with drop-on-enqueue while the drop-guard is true during polling recovery. ## Problem | Site | Before | |---|---| | `_enqueue_text_event` | drop → debug return | | `_flush_text_batch` | `pop` → drop → return (event gone); cancel after pop also lost | | `_enqueue_photo_event` | drop → debug return | | `_flush_photo_batch` | `pop` → drop → return; cancel after pop lost | | `_queue_media_group_event` | drop → debug | | `_flush_media_group_event` | `pop` → drop → return; cancel after pop lost | | `_cancel_pending_delivery_tasks` | cancel flushes, clear maps, **no salvage** | Invariant preserved: still never call `handle_message` into a torn-down session. ## Fix **Hold, don't destroy. Lifecycle-track redispatch. Drain on reconnect.** 1. `_hold_inbound_event(event, where=...)` — WARNING, `HELD_INBOUND_MAX=64`, identity dedup 2. All six drop sites hold instead of silent return 3. text/photo/media-group flush: `CancelledError` after `pop` holds 4. `_cancel_pending_delivery_tasks`: - salvage text/photo/media-group maps into the hold queue before clear - **cancel+await** `_held_inbound_redispatch_task` (same lifecycle rule as #72037 review: no untracked dispatch after teardown starts) 5. `_mark_connected` schedules one tracked `_redispatch_held_inbound(prior=...)` 6. Mid-drain disconnect/cancel re-holds the remainder 7. Non-retryable fatal discards the hold queue with WARNING (no silent death on permanent auth failure) ## Interaction with OOF-156 (connect-failure classification) Rebased onto current main (zero textual conflicts), which adds `retryable=False` fatals for `InvalidToken`/`Forbidden` at connect time (`telegram_auth_error`). The two mechanisms compose by design: | Fatal kind | Hold queue | Why | |---|---|---| | retryable (`telegram_connect_error`, network) | **preserved** | reconnect is precisely the drain trigger | | non-retryable (`telegram_auth_error`, revoked token) | discarded + WARNING, producers fenced | no reconnect will ever drain; holding would orphan | Covered by regression tests on both paths. ## Scope boundary | PR | Failure mode | Relation | |---|---|---| | **#72037** (open) | Follow-up chunk cancels in-flight flush after `pop` during normal supersession | Orthogonal — shield; this PR does not add shield | | **#81528** (closed, superseded by **#81371**, open) | Discard buffers at conversation boundaries (fragment leak) | Opposite direction — hold queue is **reconnect-scoped**, not a substitute for boundary discard | | **#55971** (merged) | Drop-guard introduction | Contract kept; destructive impl fixed | | **This PR** | Drop-guard / teardown / cancel-after-pop **destroy** inbound | Hold + tracked redispatch | Held queue is not cleared by conversation boundaries; that remains #81371's job if fragments must die on `/new`/`/stop`. ## Test plan 16 tests in `TestHoldInboundAcrossReconnect` — **no wall-clock races** (delay=0 and/or entered/release `asyncio.Event`): - [x] late enqueue held + redispatched on reconnect - [x] flush post-pop drop holds (delay=0) - [x] flush cancel-after-pop holds (Event sync) - [x] teardown salvages pending maps - [x] redispatch task cancel+await on teardown (lifecycle) - [x] photo + media-group enqueue held - [x] identity dedup - [x] queue cap drops oldest - [x] redispatch aborts cleanly if disconnect returns mid-drain - [x] non-retryable fatal discards held (OOF-156 auth path) - [x] **retryable fatal preserves held for reconnect drain (OOF-156 network path)** - [x] production terminal step (`_enqueue_text_event` as `_handle_text_message` ends) holds when disconnected - [x] permanent fatal teardown discards pending, never re-holds - [x] permanent fatal late enqueue discards - [x] connected hold schedules redispatch (no orphan) - [x] redispatch failure re-holds current + remainder ```bash scripts/run_tests.sh tests/gateway/test_telegram_text_batching.py -q # 21 passed scripts/run_tests.sh tests/gateway/ -q # 5446 passed, 0 failed, 29 skipped ``` Full suite verified against a pristine-`main` baseline in the same environment: zero delta introduced by this branch (remaining failures are identical pre-existing environment failures — daytona/fal/hindsight/acp extras not installed — present on both sides).",
          "url": "https://github.com/NousResearch/hermes-agent/pull/83878",
          "createdAt": "2026-08-11T12:20:05Z",
          "updatedAt": "2026-08-13T17:51:25Z",
          "timestamp": "2026-08-13T17:51:25Z",
          "metrics": {
            "reactions": 0,
            "comments": 4
          },
          "labels": [
            "type/bug",
            "comp/plugins",
            "platform/telegram",
            "P1",
            "sweeper:risk-message-delivery"
          ],
          "author": "dvbaecker",
          "state": "open",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:1aa48498a54f643aeb93",
        "signalId": "github:NousResearch/hermes-agent:issue:85495",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85495",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "`--in <dir>` is silently overridden by restored session cwd in one-shot mode (`-z`)",
          "text": "v0.20.0 (2026.8.3), Windows native. In headless one-shot mode, `--in <dir>` is documented to set the working directory, but when a previous session has a saved cwd, that cwd wins silently: we invoked `hermes -z \"…\" --in C:\\<throwaway-dir> --ignore-user-config` from `C:\\<throwaway-dir>` itself, and the agent operated in `C:\\dev` (the restored cwd of an earlier session) — created the requested file there and reported on that directory's git state. No warning was emitted. Impact: headless automation can silently act in the wrong directory — in our case the file landed in an unrelated real workspace. Likely the same flag-handling gap as #67458 (`-w/--worktree` silently ignored in `-z` mode). Expected: `--in` takes precedence over any restored cwd (or hard error if both are given and conflict); at minimum a warning. Workaround for others: pass `--no-restore-cwd` alongside `--in` (verified mitigating in our runs where shell cwd and `--in` agreed).",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85495",
          "createdAt": "2026-08-13T17:37:21Z",
          "updatedAt": "2026-08-13T17:51:18Z",
          "timestamp": "2026-08-13T17:51:18Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/cli",
            "P2",
            "sweeper:risk-compatibility",
            "sweeper:risk-platform-windows"
          ],
          "author": "EirikWolf",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:3f4209186be864ce2d7c",
        "signalId": "github:NousResearch/hermes-agent:pull_request:65515",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt",
          "state"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:65515",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "fix(agent): default context lookup for empty model IDs",
          "text": "## Summary This is the focused current-main replacement for the unique safety fix in #26873. `get_model_context_length()` can be called while a runtime/model selection is still empty. Before this guard, an empty or `None` model flowed into provider-prefix normalization and then into cache/probe logic. Return the documented 256K fallback immediately instead. The replacement intentionally excludes the unrelated runtime-switch, Feishu, cron, auxiliary-client, metadata, and compressor changes from #26873. Those concerns overlap with later upstream work and should not be resurrected as one conflicted patch. ## Verification - `scripts/run_tests.sh tests/agent/test_model_metadata.py -q` — 122 passed - `ruff check agent/model_metadata.py tests/agent/test_model_metadata.py` - `python -m py_compile agent/model_metadata.py tests/agent/test_model_metadata.py` - `git diff --check` Closes the unique empty-model failure mode extracted from #26873; the historical PR remains separate until maintainers decide how to handle its now-overlapping runtime-switch changes.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/65515",
          "createdAt": "2026-07-16T08:47:19Z",
          "updatedAt": "2026-08-13T17:49:10Z",
          "timestamp": "2026-08-13T17:49:10Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "P2",
            "sweeper:risk-compatibility",
            "sweeper:blast-contained"
          ],
          "author": "whirmill",
          "state": "closed",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:6186038e6b43250a931f",
        "signalId": "github:NousResearch/hermes-agent:issue:85464",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt",
          "metrics"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:issue:85464",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "issue",
          "title": "Prompt caching is disabled for Anthropic-compatible providers when Claude is selected through a bare alias",
          "text": "## Summary Prompt caching is silently disabled when a Claude model is selected through a **bare runtime alias** (for example `fable` or `opus`) on a third-party provider using `api_mode: anthropic_messages`. The downstream gateway resolves those aliases to canonical Claude models (`claude-fable-5`, `claude-opus-5`), but Hermes evaluates its prompt-cache policy before that downstream resolution. The current policy uses the runtime model string: ```python model_lower = eff_model.lower() is_claude = \"claude\" in model_lower ... if is_anthropic_wire and is_claude: return True, True ``` Therefore: ```text fable / opus -> (False, False) claude-fable-5 / claude-opus-5 -> (True, True) ``` No `cache_control` breakpoint is generated for the bare aliases, even though the request is sent over the native Anthropic Messages transport and the gateway ultimately selects a cache-capable Claude model. This is a follow-up to #8294 and #17332. #8294 was fixed by #12846 for third-party Anthropic transports with Claude-named models, but the remaining model-name gate does not account for aliases that are canonicalized downstream. Companion OmniRoute issue: https://github.com/diegosouzapw/OmniRoute/issues/10276 That issue covers the router-side part of the compatibility gap: its `auto` policy does not defensively add breakpoints when a normal Anthropic-compatible client supplies none. ## Affected topology ```text Hermes runtime model alias: fable or opus provider: named custom provider api_mode: anthropic_messages | | no cache_control emitted v Anthropic-compatible gateway/router alias resolves downstream: fable -> claude-fable-5 opus -> claude-opus-5 | | ordinary request, no cache breakpoint v Claude API / model service cache_creation_input_tokens = 0 cache_read_input_tokens = 0 ``` Responsibility is split across three layers: 1. **Hermes** constructs the stable agent prefix and decides where to emit `cache_control` breakpoints. 2. **The gateway/router** preserves, translates, or optionally injects those markers. 3. **The model service** creates and reads the actual server-side KV/prompt cache and reports usage. Hermes does not store the KV cache itself; the issue is that it fails to declare cacheable prefix boundaries for this alias shape. ## Minimal reproduction Use a custom Anthropic-compatible provider whose router understands short aliases: ```yaml model: default: fable provider: custom:anthropic-proxy base_url: http://127.0.0.1:PORT providers: anthropic-proxy: base_url: http://127.0.0.1:PORT transport: anthropic_messages api_key: \"[REDACTED]\" models: fable: context_length: 1000000 opus: context_length: 1000000 ``` At the router, configure: ```text fable -> claude-fable-5 opus -> claude-opus-5 ``` Then: 1. Start a new Hermes session using `fable`. 2. Send two or more turns with a sufficiently large, unchanged prefix (system prompt + tool definitions + prior history). 3. Capture only cache-policy diagnostics and usage fields; do not publish the request text. 4. Repeat using the runtime model name `opus`. 5. As controls, repeat with `claude-fable-5` and `claude-opus-5` as the model names visible to Hermes. The policy can also be reproduced directly: ```python from types import SimpleNamespace from agent.agent_runtime_helpers import anthropic_prompt_cache_policy agent = SimpleNamespace( provider=\"custom:anthropic-proxy\", base_url=\"http://127.0.0.1:PORT\", api_mode=\"anthropic_messages\", model=\"\", ) for model in [\"fable\", \"claude-fable-5\", \"opus\", \"claude-opus-5\"]: print(model, anthropic_prompt_cache_policy(agent, model=model)) ``` Observed: ```text fable: (False, False) claude-fable-5: (True, True) opus: (False, False) claude-opus-5: (True, True) ``` ## Sanitized request evidence The following summaries were extracted from request artifacts. Request text, system prompts, user content, credentials, headers, and connection identifiers are intentionally omitted. ### Hermes -> bare alias `fable` -> canonical `claude-fable-5` ```json { \"client\": \"Hermes\", \"canonicalModel\": \"claude-fable-5\", \"claudePromptCache\": { \"applied\": false, \"totalBreakpoints\": 0, \"systemBreakpoints\": [], \"toolBreakpoints\": [], \"messageBreakpoints\": [] }, \"usage\": { \"cacheRead\": null, \"cacheWrite\": null } } ``` ### Hermes -> bare alias `opus` -> canonical `claude-opus-5` ```json { \"client\": \"Hermes\", \"canonicalModel\": \"claude-opus-5\", \"inputTokens\": 26448, \"claudePromptCache\": { \"applied\": false, \"totalBreakpoints\": 0 }, \"usage\": { \"cacheRead\": null, \"cacheWrite\": null } } ``` ### Control: another client -> same router -> `claude-opus-5` ```json { \"canonicalModel\": \"claude-opus-5\", \"inputTokens\": 78802, \"claudePromptCache\": { \"applied\": true, \"totalBreakpoints\": 3 }, \"usage\": { \"cacheRead\": 78553, \"cacheWrite\": 247 } } ``` The control is important: it shows that the same router and upstream can preserve/use Claude prompt caching. The missing-cache samples are not evidence that Fable or Opus lacks server-side cache support; the marker was absent before the request reached that layer. ## Production impact observed One long-running Hermes session using the bare `fable` alias produced: ```text API calls: 88 cumulative input: 28,624,144 tokens cumulative output: 137,051 tokens largest single input: 459,339 tokens cache read: 0 cache creation/write: 0 ``` This is cumulative per-call input, not a claim that all 28.6M tokens were unique or cacheable. In an agent loop, the system prompt, tool schemas, and conversation prefix are repeatedly sent, so failure to emit breakpoints causes the reusable portion to be processed at the uncached input rate on every turn. ## Theoretical cost impact using public API prices This section is an API-price counterfactual only. It does not describe the account type or commercial route used for the observed traffic. Anthropic's public Claude Fable 5 pricing currently lists: - base input: **$10 / MTok** - 5-minute cache write: **$12.50 / MTok** - cache hit/read: **$1 / MTok** - output: **$50 / MTok** Source: https://platform.claude.com/docs/en/about-claude/pricing At public base-input pricing, the observed 28,624,144 input tokens correspond to: ```text 28.624144 MTok * $10/MTok = $286.24 of theoretical uncached input processing ``` The 137,051 output tokens would add about `$6.85`, but output cost is unaffected by prompt caching and is not part of the cache-loss estimate. The exact avoidable amount cannot be inferred from cumulative input alone because each turn has a different cacheable boundary, cache writes cost 1.25x for the 5-minute TTL, and new conversation suffixes remain uncached. A transparent sensitivity range is more appropriate. If `p` is the fraction of cumulative input that could have been served as cache reads, the gross read-vs-uncached difference before cache-write overhead is: ```text 28.624144 MTok * p * ($10 - $1) / MTok ``` | Reusable/readable share `p` | Gross theoretical difference | |---:|---:| | 50% | $128.81 | | 75% | $193.21 | | 90% | $231.86 | These are **not claimed savings**; they are bounded scenarios illustrating why silent cache disablement is operationally expensive. Actual savings would be lower after cache-write premiums, uncached suffixes, TTL expiry, prefix changes, and cold starts. For a stable prefix of `S` tokens reused over `R` calls within a 5-minute cache lifetime: ```text without caching = R * S * $10 / 1M with caching = S * $12.50 / 1M + (R - 1) * S * $1 / 1M ``` The API documentation notes that a 5-minute write pays back after one cache read. ## Expected behavior A third-party provider using `anthropic_messages` should not silently lose caching merely because the model visible at the Hermes boundary is an alias. At least one of these should be supported: 1. Resolve aliases to a canonical model/capability before evaluating prompt-cache policy. 2. Allow custom provider/model declarations such as `prompt_caching: true` or `supports_cache_control: true`. 3. Treat `anthropic_messages` as cache-capable when the provider explicitly opts in, independent of model spelling. 4. Emit a warning when a large Anthropic-wire request is sent with no cache breakpoints. A pure `api_mode == anthropic_messages` default may be too broad because not every compatible endpoint guarantees cache support; an explicit capability override plus known-provider defaults would be safer than a string heuristic. ## Suggested regression tests 1. `anthropic_messages` + runtime alias `fable` + canonical capability `claude-fable-5` enables native cache layout. 2. `anthropic_messages` + runtime alias `opus` + canonical capability `claude-opus-5` enables native cache layout. 3. Explicit `prompt_caching: true` works for a non-Claude alias. 4. Explicit `prompt_caching: false` remains authoritative. 5. Unknown Anthropic-compatible providers do not change behavior unless capability is declared, if backward compatibility requires that default. 6. A two-turn integration test verifies the second response reports `cache_read_input_tokens > 0` when the upstream supports it. ## Acceptance criteria A fixed Hermes -> gateway test should show all of the following: ```text client = Hermes canonical model = claude-fable-5 or claude-opus-5 claudePromptCache.applied = true totalBreakpoints > 0 second turn cache_read_input_tokens > 0 ``` Checking only that `cache_control` exists is insufficient; the second-turn usage field is the end-to-end acceptance signal. ## Related work - #8294 — third-party `anthropic_messages` prompt caching - #12846 — transport/provider fix that closed #8294 - #17332 — model-name gating excluded cache-capable non-Claude model IDs - https://github.com/diegosouzapw/OmniRoute/issues/813 — proxy-side automatic prompt-cache injection and observability - https://github.com/diegosouzapw/OmniRoute/pull/708 — preserving `cache_control` in Claude passthrough - https://github.com/diegosouzapw/OmniRoute/issues/1712 — prefix mutation breaking cache hits ## Environment - Hermes Agent: v0.20.0 - Host: macOS - Provider type: named custom provider - Transport: `anthropic_messages` - Gateway used for the observed artifacts: OmniRoute v3.8.49 No credentials or complete request artifacts are attached because those artifacts contain private system prompts, tools, and conversation content.",
          "url": "https://github.com/NousResearch/hermes-agent/issues/85464",
          "createdAt": "2026-08-13T17:21:33Z",
          "updatedAt": "2026-08-13T17:48:07Z",
          "timestamp": "2026-08-13T17:48:07Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/bug",
            "comp/agent",
            "provider/anthropic",
            "P0",
            "sweeper:risk-caching"
          ],
          "author": "xiaoyaner0201",
          "state": "open",
          "assignees": [],
          "change": "updated"
        }
      },
      {
        "id": "event:e5f46865a8f1ceadd3cb",
        "signalId": "github:NousResearch/hermes-agent:pull_request:37865",
        "event": "discovered",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:37865",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(messaging): add explicit active handoff primitives",
          "text": "## Summary - add opt-in `send_message(trigger_agent=True)` active handoff result plumbing while keeping passive sends as the default - add Kanban `notify-subscribe --trigger-agent` persistence and gateway notifier support - actively wake the originating gateway agent for gateway-created Kanban task terminal ACKs - preserve Kanban origin ACK edges for CLI/script-created graphs: - child tasks inherit parent/root terminal notification subscriptions - explicit `Origin/return_to: Discord ... (<#channel_id>)` body prose infers a notify subscription for CLI-created cards - document that headless Kanban workers should write board verdicts instead of directly sending Discord/Telegram final ACKs ## Safety - default behavior remains passive unless `trigger_agent=True` / `--trigger-agent` is explicitly requested or a CLI-created task carries a narrow, machine-readable Discord `Origin/return_to` line - active wake is best-effort after a successful visible platform send; it is not an atomic send+wake transaction - visible platform send and active wake are reported separately via `triggered_agent` / `trigger_error` - Kanban subscriptions wake agents only when they carry `trigger_agent=1` - active wake failures are isolated from passive delivery so the notifier does not crash or retry the same terminal event just because wake is unavailable - the `Origin/return_to` parser intentionally recognizes only explicit Discord channel-id shapes such as `<#123...>` or `chat_id=123...`; it is not a general natural-language parser ## Tests - `python -m pytest tests/hermes_cli/test_kanban_notify.py tests/gateway/test_kanban_notifier.py tests/gateway/test_kanban_notifier_watcher_dispatch_gate.py tests/tools/test_send_message_tool.py -q` → 160 passed, 2 warnings - `git diff --check` - `python -m compileall gateway/kanban_watchers.py gateway/slash_commands.py hermes_cli/kanban.py hermes_cli/kanban_db.py tools/send_message_tool.py tests/hermes_cli/test_kanban_notify.py -q` ## Refresh notes - rebased onto current `origin/main` (`4829f8d2c` at refresh time) - force-pushed with `--force-with-lease` from old head `f3c9a6453` to new head `9829f4ee0` Local operator policy and AgentFlow supervisor hooks remain intentionally out of scope.",
          "url": "https://github.com/NousResearch/hermes-agent/pull/37865",
          "createdAt": "2026-06-03T04:05:25Z",
          "updatedAt": "2026-08-13T17:48:00Z",
          "timestamp": "2026-08-13T17:48:00Z",
          "metrics": {
            "reactions": 0,
            "comments": 19
          },
          "labels": [
            "type/feature",
            "comp/gateway",
            "comp/tools",
            "comp/cron",
            "P3",
            "blocked",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery",
            "sweeper:risk-security-boundary",
            "sweeper:risk-compatibility",
            "sweeper:blast-moderate"
          ],
          "author": "verybigdog",
          "state": "closed",
          "assignees": [],
          "change": "new"
        }
      },
      {
        "id": "event:3e0d083468c2fa68f87e",
        "signalId": "github:NousResearch/hermes-agent:pull_request:85487",
        "event": "changed",
        "observedAt": "2026-08-13T18:01:55.420671Z",
        "changedFields": [
          "updatedAt",
          "state"
        ],
        "signal": {
          "id": "github:NousResearch/hermes-agent:pull_request:85487",
          "source": "github",
          "group": "coding-agent-infrastructure",
          "project": "NousResearch/hermes-agent",
          "kind": "pull_request",
          "title": "feat(kanban): explicit notify/wake delivery modes with faithful wake session routing (salvage #37865)",
          "text": "## Summary Kanban notify subscriptions gain explicit delivery modes — `notify`, `notify+wake`, `wake` — and woken turns now land in the creator's REAL session instead of a context-less parallel one. Salvage of #37865 by @verybigdog onto current main (authorship preserved; original commits carried a local placeholder identity, re-attributed to the contributor's public email), plus one follow-up fix of ours. ## Changes - `hermes_cli/kanban_db.py`: `delivery_mode` + `chat_type` + `user_id_alt` columns with idempotent migration; child tasks inherit the parent's notify return path (ACK-edge inheritance) - `gateway/kanban_watchers.py`: wake gated on `delivery_mode` instead of unconditional; passive send suppressed in pure-`wake` mode - `gateway/session_context.py` / `tools/kanban_tools.py`: persist `chat_type` and alt user id so the wake replay reconstructs the same `build_session_key`; gateway autosubscribes as `notify+wake`, TUI as `notify` - `hermes_cli/kanban.py`: `--delivery-mode` CLI flag; `gateway/slash_commands.py` autosubscribe; docs updated - **Follow-up (ours):** migration backfills pre-existing gateway subscriptions to `notify+wake` on first-add of the column — before this column existed, wake was unconditional, so defaulting legacy rows to `notify` would have silently disabled active wake on upgrade. Backfill runs only on first-add; explicit downgrades survive re-migration. Sabotage-verified regression tests. ## Validation | | Before | After | |---|---|---| | wake session key | could land context-less (user_id vs alt id divergence) | reconstructs creator's session | | legacy gateway subs after upgrade | silently lose wake | backfilled to notify+wake | | targeted tests | — | 60/60 pass (kanban_notify + kanban_tools + kanban_notifier) | Supersedes #37865; closes it with credit. Triage previously anointed #37865 canonical over dupes #49593/#49749/#49751. ## Infographic ![Kanban delivery modes](https://files.catbox.moe/se1zot.png)",
          "url": "https://github.com/NousResearch/hermes-agent/pull/85487",
          "createdAt": "2026-08-13T17:31:01Z",
          "updatedAt": "2026-08-13T17:47:43Z",
          "timestamp": "2026-08-13T17:47:43Z",
          "metrics": {
            "reactions": 0,
            "comments": 1
          },
          "labels": [
            "type/feature",
            "comp/cli",
            "comp/gateway",
            "comp/cron",
            "P3",
            "sweeper:risk-session-state",
            "sweeper:risk-message-delivery",
            "sweeper:risk-compatibility"
          ],
          "author": "teknium1",
          "state": "closed",
          "assignees": [],
          "change": "updated"
        }
      }
    ]
  }
}
