hermes-agent/hermes_cli/config.py
Teknium 9acc4b47f5
perf(state): external-content FTS + tool-row-free trigram index (schema v23) (#65798)
* fix(desktop): refresh repo status on session switch with unchanged cwd (#68208)

fix(desktop): refresh repo status on session switch with unchanged cwd

* fix(checkpoints): honor gateway config and task cwd (#68195)

* fix(gateway): wire checkpoint config into agents

* fix(checkpoints): resolve gateway file paths by task cwd

* ci: live-updating PR review comment with structured job statuses

Replace the static comment-pending + comment-results two-job pattern
with a live-updating comment system that polls the GitHub Actions API
every 15s, re-assembles the review comment from whatever results are
available, and upserts it via the <!-- hermes-ci-review-bot --> marker.
The comment updates in real time as each job finishes — no waiting for
the full pipeline.

Every CI job that wants to appear in the review comment emits a
review_status output — a JSON array of objects, each with a source
and a results array:

    [
      {
        "source": "review-label-gate",
        "results": [
          {"kind": "action_required", "title": "...", "summary": "...",
           "how_to_fix": "..."},
          {"kind": "info", "title": "...", "summary": "..."}
        ]
      },
      {
        "source": "ci timing",
        "results": [
          {"kind": "warning", "title": "CI timings", "summary": "...",
           "detail": "...", "link": "..."}
        ]
      }
    ]

One job can emit multiple results of different kinds. The source field
is used to exclude the corresponding job from the synthesized error
list (case-insensitive, hyphen-normalized matching against GitHub
Actions job display names).

| job                        | source                   | kind (on failure)         | section              |
|----------------------------|--------------------------|---------------------------|----------------------|
| review-labels              | review label gate        | action_required / info    | Action required      |
| lockfile-diff              | lockfile-diff            | action_required           | Action required      |
| ci-timings                 | ci timing                | warning / info            | Warnings             |
| supply-chain scan          | supply chain             | error / (none)            | Job failures         |
| supply-chain dep-bounds    | supply chain             | action_required / (none)  | Action required      |
| osv-scanner                | osv scan                 | warning / (none)          | Warnings             |
| uv-lockfile-check          | uv.lock check            | action_required / (none)  | Action required      |
| history-check              | unrelated histories      | action_required           | Action required      |
| contributor-check          | contributor attribution  | action_required           | Action required      |

Jobs that find nothing emit [] (empty array) — no noise info items.

A single comment-live job polls the GitHub Actions API every 15s,
classifies jobs into (completed, pending), assembles the comment, and
upserts it. Merges review_status outputs from all needs jobs via
toJSON(needs.*.outputs.review_status), and downloads the ci-timings
artifact when it becomes available. Shows commit SHA + message below
the header.

The assembler has ZERO job-specific knowledge. It just:
1. collect_from_statuses() — flattens all nested status objects into ReviewItems
2. collect_failed_jobs() — synthesizes errors for failed jobs with no declared status
3. _attach_job_urls() — fills in per-job log links for ALL items
4. render_comment() — groups by severity, renders with group headers

Each item shows links inline next to the title: View report (job-emitted
URL) and View job (auto-attached logs link). Each info item is its own
collapsible <details> block.

    # ૮ >ﻌ< ა ci review

    running on abc1234 — commit message first line

    ##  Job failures
    ### {title} · [View job](url)
    {summary}

    ## ⚠️ Action required
    ### {title} · [View job](url)
    {summary}
    **How to fix:**
    {how_to_fix}

    ## ⚠️ Warnings
    ### {title} · [View report](url) · [View job](url)
    {summary}
    {detail}

    <details><summary>{title}</summary>
    {content}
    </details>

    Still running 3 jobs: ci-timings, docker

- test_assemble_review_comment.py (48 tests): collect_from_statuses,
  collect_failed_jobs with exclude_sources, _attach_job_urls,
  render_comment (group headers, inline links, commit info, per-item
  details, pending footer), assemble integration
- test_live_comment.py (16 tests): classify_jobs pure function
- test_timings_report.py (10 tests): generate_review_status nested format
- test_lockfile_diff.py (6 tests)
- test_classify_changes.py (32 tests, pre-existing)

* ci: migrate AUTOFIX_BOT_PAT to GitHub App token

Replace the long-lived fine-grained PAT (AUTOFIX_BOT_PAT) with short-lived
(1-hour) installation access tokens minted via a new get-app-token composite
action wrapping actions/create-github-app-token@v3.2.0.

The PAT was used in 13 spots across 8 workflow files for gh CLI / GitHub API
calls. The per-repo GITHUB_TOKEN (1,000 req/hr) was getting rate-limited when
multiple workflows fire concurrently (deploy-site, skills-index, ci-timings,
supply-chain-audit, js-autofix). App installation tokens get 5,000 req/hr
per installation and are scoped to the App's permissions, not a user account.

New composite action: .github/actions/get-app-token/
  - Wraps actions/create-github-app-token@bcd2ba49 (v3.2.0, SHA-pinned)
  - Reads APP_ID + APP_PRIVATE_KEY repo secrets
  - Outputs a 1hr installation token via steps.app-token.outputs.token

Requires two new repo secrets (set after creating the GitHub App):
  - APP_ID: the App's numeric ID
  - APP_PRIVATE_KEY: the PEM private key

App installation permissions needed:
  contents: write    (js-autofix push, pypi release upload)
  pull-requests: write (js-autofix PR create/merge, supply-chain comment)
  issues: write       (skills-index-freshness issue creation)
  actions: write     (skills-index workflow trigger)
  workflows: write   (skills-index triggers deploy-site.yml)

The AUTOFIX_BOT_PAT secret can be deleted once CI passes on this PR.
The comment in js-autofix.yml noting that PAT pushes trigger downstream
workflows is updated — App tokens have the same property (they are not
GITHUB_TOKEN), so the concurrency-cancel loop logic is unchanged.

* style(desktop): satisfy merged eslint/prettier config

The SSH modules predate the stricter lint config that landed on main (curly, no-empty, perfectionist sorting, prettier). Mechanical lint:fix + fmt pass, empty catch blocks filled with the codebase's void-0 convention, and inline no-control-regex disables on the three deliberate control-char patterns (same pattern as lib/ansi.ts).

* fix(ci): pass App secrets as inputs to composite action

Composite actions cannot access the secrets context — the runner's
template engine rejects secrets.* references at load time with
'Unrecognized named-value: secrets'.

Move APP_ID and APP_PRIVATE_KEY from direct secrets.* references inside
the composite action to inputs passed by each calling workflow. The
fallback logic (GITHUB_TOKEN when APP_ID is empty, for fork PRs) stays
in the composite action's check step.

* fix(ci): add detect to all-checks-pass needs so its failure blocks merge

If detect fails, all downstream sub-workflows get SKIPPED (they have
needs: detect). all-checks-pass used if: always() and only checked the
sub-workflows — which all showed as 'skipped' (= success) — so it passed
even though the root cause (detect) failed. This made the PR mergeable
despite a broken CI pipeline.

Add detect to all-checks-pass needs so its failure propagates to the
gate job and blocks the merge.

* fix(desktop): bump skills test timeout to fix cold-start flake (#68235)

Test 1 in skills/index.test.tsx pays the full cold-start cost (jsdom env
init + module transform + the @/hermes/@/store/profile import graph),
which pushed past vitest's 5000ms default under load — caught at 8871ms
on one run, 6.6s pure test time on another. Tests 2-4 are ~30-130ms
each because all that setup is already cached, so only test 1 was at
risk of timing out.

Bump the describe-level timeout to 15s. Verified with 10 consecutive
runs, 4 of which took 5.5-6.6s of test time and would have hard-failed
under the old 5s default.

* feat(desktop): open multiple full app windows (electron)

Add createInstanceWindow() — a full-chrome peer of the primary that
renders the complete app (sidebar, routing, its own draft) against the
shared backend, so several GUI windows can run at once. Mirrors the
primary's window options + chatWindowWebPreferences (backgroundThrottling
stays off so a streamed answer never stalls when blurred) but never
overwrites the mainWindow global and doesn't respawn the backend — the
renderer's getConnection() joins the running one. New windows cascade off
their source via the pure, tested instanceWindowBounds().

Exposed via the hermes🪟openInstance IPC and a "New Window" File
menu item. Per-window fullscreen state now targets the window itself, and
titlebar/native-theme repaints reach every open chat window instead of
only the primary.

Retires the now-orphaned compact new-session pop-out (its only caller was
⌘⇧N, repointed in the follow-up commit): drops createNewSessionWindow,
the hermes🪟openNewSession handler, and the newSession/new=1 URL
flag.

* feat(desktop): wire New Window to ⌘⇧N + command palette

Repoint session.newWindow (⌘⇧N) from the compact new-session pop-out to
openNewWindow(), which opens a full peer instance via the new openWindow
bridge, and add a "New Window" entry to the ⌘K palette (shown with its
hotkey hint, gated on canOpenNewWindow()). Relabel the action "New window".

Drops the retired openNewSessionWindow bridge and the vestigial
isNewSessionWindow()/new=1 flag; renames the shared opener helper.

* fix(desktop): de-dupe cross-window cues so peers don't spam

With multiple full windows, each renderer independently reacts to the
same backend event, so one-shot cues fired N times: OS notifications
(the per-renderer throttle can't see other windows), the turn-end sound
(playCompletionSound runs on every message.complete, ungated by focus),
and auto-spoken replies (double voice when a chat is open in two windows).

Add a single race-free owner in the main process (electron/event-dedupe.ts):
main handles IPC serially, so the first window to claim a key within a
short window wins and peers stay quiet. Notifications collapse at the
hermes:notify choke point; the sound and spoken replies claim via a new
hermes:ambient:claim IPC (keyed by session / reply id). Off Electron the
claim falls back to "emit", preserving single-window behavior.

The sound's mute check runs before the claim so a muted window can't win
the cue and silence an audible peer.

* refactor(desktop): tidy the cross-window deduper

Drop the unused DEDUPE_WINDOW_MS export and rename its interval so
"window" isn't overloaded against BrowserWindow in a multi-window
feature (windowMs → intervalMs). DRY the completion-sound play path.
No behavior change.

* nix: add cage to devDeps

* fix(desktop): avoid false remote gateway reauthentication (#68250)

* fix(desktop): avoid false remote gateway reauthentication

Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com>
Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai>

* fix(desktop): harden remote revalidation state

---------

Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com>
Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai>

* fix(desktop): keep composer draft across compression tip rotation (#68079)

* fix(desktop): keep composer draft across compression tip rotation

Auto-compression swaps the live stored session id while the user may still
be typing. Scope the composer/queue key on the lineage root and migrate any
tip-keyed draft/queue entries onto that durable key when the tip rotates so
the in-progress prompt does not vanish when the response lands.

* test(desktop): cover draft survival across compression tip rotation

Add regression coverage for migrateSessionDraft, lineage-scoped composer
keys, and the rotation path that previously wiped an in-progress draft.

* fmt(js): `npm run fix` on merge (#68305)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): Stop parks the queue instead of firing the next queued prompt

Interrupting a busy turn with the Stop button (or Esc) settles the
session to idle, and the edge-independent auto-drain immediately submits
the head of the composer queue. The user pressed Stop to halt the agent,
but it looks like Stop skipped the current turn and kept going — and the
queued text is hard to find, since its only surface is the collapsed
'N queued' pill above the composer.

The old userInterruptedRef latch (a23728dcc) fixed this but was removed
in #40221 because it also suppressed the drain that send-now-while-busy
depends on. This reintroduces the halt with source awareness instead of
a blanket latch:

- Explicit halts (Stop button, composer Esc, chat-focus Esc, the
  streaming message's hover Stop, runtime cancel) park the session's
  queue before interrupting. Parked queues are skipped by both
  auto-drain paths (mounted ChatBar + background drainer).
- Interrupts that exist to advance the queue (send-now-while-busy)
  unpark first, so the settle drain they rely on still flows.
- The park lifts on any renewed intent: resume, a manual drain (Enter
  on empty composer or the per-row send arrow), queueing a new prompt,
  or emptying the queue. It migrates with entries on a runtime re-key
  and is deliberately not persisted (a fresh process starts unparked).
- The queue panel expands on park, switches to 'N Queued — paused' with
  a pause icon, and grows a Resume action, so the held prompts are
  visible instead of reading as vanished.

Store contract, hook wiring, and background-drain coverage included;
docs updated.

* fix(cli,tui): recall real paste content on up-arrow

Large pastes collapse to a placeholder in the composer, but input history
stored the placeholder — so up-arrow recall showed a truncated reference
(CLI) or lost the content entirely (TUI, where the `[[…]]` label has no
backing snip after submit).

Store the expanded content in history instead:
- CLI: `_inline_pastes()` expands `[Pasted text #N -> file]` into the buffer
  before `reset(append_to_history=True)`; also reused by the external editor
  (dedup). History nav suppresses re-collapse of recalled content.
- TUI: `dispatchSubmission` pushes `expandSnips(pasteSnips)(full)`; idempotent
  on label-free text so re-submitting a recalled entry stays stable.

* fix(cli): suppress CPR on POSIX local TTYs under load

Delayed ESC[6n replies leak as ^[[row;colR into the classic CLI on
SSH/slow PTYs (#13870) and on local POSIX TTYs under heavy subagent
load. Suppress CPR on non-Windows platforms (layout hint only); keep
native Windows on prompt_toolkit's default pending native coverage.
Wire selection through _select_classic_cli_pt_output.

* test(cli): prove local CPR leak and Application CPR-disabled wiring

Add a delayed-CPR PTY harness (no SSH) plus selection/Application
assertions for POSIX local and Windows preserve-default. Update the
gating unit test to the new contract.

* refactor: drop platform kwarg, fix PTY test cleanup

- Remove redundant platform= test seam from _terminal_may_leak_cpr();
  use monkeypatch.setattr(sys, 'platform', ...) consistently in both
  test files.
- Wrap PTY tests in try/finally for fd cleanup on assertion failure.
- Guard select.select() in terminal thread against OSError after fd
  close (fixes PytestUnhandledThreadExceptionWarning).
- Trim PR-number reference from test module docstring.

* docs(portal): remove retired Nous Chat references

* fix(web/ddgs): isolate DuckDuckGo search in a disposable process

ThreadPoolExecutor timeouts cannot fire when primp holds the GIL in
native code (#68096). Run each search in a child process the parent can
terminate/kill, and honor tools.interrupt between polls.

* test(web/ddgs): cover GIL-hold timeout, interrupt, and worker reap

Regression tests for #68096: native GIL-hold and sleep hooks must time
out or interrupt promptly with no orphaned search workers.

* fix: sanitize subprocess env for DDGS worker

os.environ.copy() passes all Hermes secrets (gateway tokens, API keys,
dashboard session tokens) into the DDGS child process. Use
_sanitize_subprocess_env() to strip Hermes-managed secrets before
spawning the worker.

* fix(agent): pass persisted-prefix boundary when rotation flushes on cold resume (#68196)

The legacy rotation branch in agent/conversation_compression.py flushes the
current turn to the OLD session before ending it (#47202) via
_flush_messages_to_session_db(messages) with no conversation_history boundary.

On the first turn after a cold Desktop resume, the restored transcript rows
live in the message list as plain dicts that have not yet been stamped with
_DB_PERSISTED_MARKER — the normal turn flush that stamps them runs after
preflight compression. With no boundary, _flush_messages_to_session_db builds
an empty history_ids set and treats every restored row as new, durably
re-appending the whole transcript to the parent session. Repeated
restart/resume + threshold compression keeps growing the parent transcript.

Pass messages[:_persist_user_message_idx] (the already-durable prefix that
turn_context anchors before preflight runs, guarded for int/bounds) as
conversation_history so the flush skips the persisted rows by identity and
writes only the current turn's new messages.

Adds a regression test that pre-populates SQLite, cold-loads the transcript,
appends one current user row, and forces rotating compression: it fails before
this change (parent grows to 5 rows) and passes after (parent holds the two
originals plus the single new turn).

* fix(desktop): prevent contentEditable composer input from visually collapsing to near-zero height

Fix #68095

The composer input box (contentEditable div) randomly shrank to a tiny/pixelated
size when typing character-by-character (paste worked fine). Root cause: during
per-keystroke input, the normalizeComposerEditorDom cleanup could briefly leave
the contentEditable with zero child nodes, and without intrinsic content the
browser collapsed it visually despite the CSS min-height.

Two-pronged fix:
1. Add min-h-[1.625rem] bracket syntax alongside the CSS variable min-height
   to ensure the minimum height is enforced even if the CSS variable resolution
   is delayed or overridden by browser defaults.
2. In normalizeComposerEditorDom, ensure the contentEditable always has at
   least one <br> child when empty, giving it intrinsic height that the browser
   cannot collapse. This is a belt-and-suspenders approach with the CSS min-height.

Closes #68095

* fix(agent): circuit-break AttributeError from commit-splice and detect code skew

Fix #68178

The git-install auto-updater rewrites source while the desktop backend
is live. Because agent/conversation_loop.py is imported lazily on the
first API call, a process can end up running two different commits
spliced together — one commit's AIAgent against another commit's
conversation_loop. When the interface differs, every turn fails
permanently with an AttributeError, and the loop retries indefinitely,
burning provider API calls (576 failures, 149 wasted API calls observed).

Three-prong fix:

1. Circuit-break AttributeError on agent objects: the outer-loop error
   classifier now detects AttributeError targeting agent/run_agent
   modules and breaks immediately instead of continuing the retry loop.

2. Code skew detection for desktop/serve backend: run_agent.py now
   snapshots the checkout revision at import time and exposes a cheap
   per-iteration check that the conversation loop uses to refuse new
   work with a clear 'restart required' message before the lazy import
   can crash.

3. Informative error message: when code skew is detected, the user
   gets a clear explanation of the mismatch (boot revision vs current
   revision) and actionable guidance to restart the application.

* fix(telegram): preserve fatal recovery handoff

Release the current polling-recovery task's ownership before invoking
the fatal-error handler. The runner bounds adapter cleanup in a child
task; disconnect() cancels the tracked polling-recovery task, so
retaining the current notifier in _polling_error_task would cancel the
fatal callback before the runner can finish its reconnect-queue or
shutdown decision.

The new _handoff_polling_fatal_error() helper clears
_polling_error_task only when it is the current notifier. Other
recovery tasks remain tracked and are still cancelled and awaited
during teardown.

Covers both network retry exhaustion and polling-conflict exhaustion.
Replaces the misleading "Restarting gateway" message with "Escalating
to gateway recovery".

Fixes #68406.

* fix(telegram): widen fatal handoff to heartbeat watchdog path

The wedged-recovery heartbeat watchdog (line 2526) calls
_notify_fatal_error() directly from the heartbeat task. disconnect()
cancels _polling_heartbeat_task unconditionally (no current_task guard,
unlike _polling_error_task). Same bug class as #68406: the child
disconnect cancels the heartbeat parent before the runner can queue
reconnect.

Widen _handoff_polling_fatal_error() to also clear
_polling_heartbeat_task when it is the current task, and route the
heartbeat watchdog call site through the handoff helper.

Co-authored-by: Imgaojp <6065749+Imgaojp@users.noreply.github.com>

* fix(tests): make the live-system-guard canary fail closed

tests/test_live_system_guard_self_test.py executes real kill primitives
(os.kill(-1, SIGTERM), os.killpg, pkill -f python) and depends entirely on
the autouse _live_system_guard fixture in tests/conftest.py to intercept
them. That makes the canary fail-OPEN: in any collection context where the
file is present but its home conftest is not — a published sdist that ships
tests/ but not tests/conftest.py, a tree assembled by copying test*.py (that
glob does not match conftest.py), pytest --noconftest, or a foreign rootdir —
the primitives fire for real, and os.kill(-1, SIGTERM) SIGTERMs every process
the invoking user owns (a full desktop-session kill was reported in the field).

Add an autouse fixture that refuses to run any canary test unless the guard is
provably active. The one thing the canary can detect about its own safety is
that the guard monkeypatches os.kill with a plain Python function, whereas the
unguarded primitive is a C builtin — so the probe keys off that. Tests marked
@pytest.mark.live_system_guard_bypass still opt out, matching the guard's own
bypass contract (e.g. test_bypass_marker_disables_guard). With the guard loaded
every canary test behaves exactly as before; without it each test refuses at
setup with zero side effects.

Fixes #68311

* fix(billing): rename user-facing "terminal billing" copy to Remote Spending (#68355)

* fix(billing): rename user-facing "terminal billing" copy to Remote Spending

The capability was renamed Remote Spending on the portal (consent CTA:
"Allow Remote Spending"; per-terminal states Granted/Stopped), but the
terminal, desktop, and docs still said "terminal billing" everywhere.

- Feature name: Remote Spending in titles/labels, lowercase mid-sentence.
- Step-up action verb is now "allow", matching the portal consent CTA.
- Kill-switch-off recovery copy points at the actual control ("a billing
  admin can turn it on from the portal's Hermes Agent page") instead of
  the dead-end "manage it on the portal".
- Per-terminal revoke copy uses the portal vocabulary ("stopped").
- Wire identifiers (cli_billing_enabled, cli_billing_disabled, ...) are
  unchanged; copy, comments, docs, and test expectations only.

* fix(billing): correct the post-step-up denial diagnosis + finish the desktop rename

Adversarial review findings: (1) a repeated insufficient_scope after a
successful step-up is a per-terminal authorization failure, but the copy
blamed the org kill-switch and pointed at the wrong recovery control —
now: "Remote Spending still isn't active for this terminal — the
authorization didn't take. Retry, or make this change on the portal."
(2) the desktop step-up flow started in Remote Spending vocabulary but
finished in "billing management access" — renamed both end states.
(3) prettier formatting on the touched files (matches the post-merge
fmt bot).

* feat(tui): show the plan catalog in /subscription on Free (#68357)

* feat(tui): show the plan catalog in /subscription on Free

The server returns the tier list even with no subscription, but the
overlay hid the picker behind can_change_plan && !isFree, so a Free
account got only "Start a subscription" with no idea what the plans
cost. Now:

- Overview on Free offers "Choose a plan" whenever the catalog has
  enabled paid tiers.
- The picker on Free lists each plan as name · price · monthly credits
  (no upgrade/downgrade hints — there is nothing to move from), and
  picking one opens the portal, where starting a subscription actually
  happens (card capture + checkout live there; the upgrade RPC requires
  an existing subscription).
- Paid-plan behavior (preview → confirm → apply) is unchanged.

* refactor(tui): compute the picker row suffix once

Review feedback: the isFree fork duplicated the label template and run
handler; only the suffix differs.

* fix(tui): arm the busy guard before the Free portal handoff

Adversarial review: the Free branch returned before setting busyRef, so
a double-Enter could open the portal twice; and the picker narrated a
handoff that openManageLink already narrates (duplicate on success,
contradictory on failure). Guard first, let the helper do the talking.

* fix(tui): monthly credits are dollars — label them as such

The Free picker showed "1000 credits/mo" for what is $1,000 of monthly
credit — render "$1,000 credits/mo" (grouped, dollar-signed).

* feat(tui): render the Free-plan catalog inline in the /subscription overview

Sid ruling: the upsell belongs where the user already is — no
intermediate "Choose a plan" hop. On Free the overview lists each paid
plan (name · $/mo · $credits/mo) as a pickable row; picking opens the
portal (openManageLink narrates). The generic "Start a subscription"
row survives only when the catalog is empty. The picker reverts to its
original change-only form (Free never reaches it).

* feat(desktop): tier catalog chips on the Subscription row

Desktop parity with the TUI inline catalog (Sid ruling): accounts that
can act see the plans where they already are — Free gets the upsell
list (every chip opens the portal), a subscriber sees all tiers with
the current one marked inert. Members and team contexts see no chips.
Chips learn an optional url (portal handoff) in the shared row model.

* chore(tui): fixture harness mirrors the live tier catalog

The dev screenshot fixtures showed invented plans ($50 Super / $99
Ultra, "1,000 credits"); align with the real catalog ($20/$100/$200
with $22/$110/$220 monthly credits) so fixture renders cannot be
mistaken for product truth. The overlay itself always reads tiers from
the subscription API.

* chore: trim narration comments

* fmt(js): `npm run fix` on merge (#68462)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(relay): attach metadata.user_id on guild replies for egress fallback (#68320)

The relay adapter re-attaches an egress discriminator on outbound replies
so the connector can resolve the owning tenant. It captured scope_id for
scoped (guild) messages and user_id for DMs, but as MUTUALLY EXCLUSIVE:
a scoped inbound hit an early return, so the author's user_id was never
recorded, and _with_scope only attached user_id when there was no
scope_id. Guild replies therefore went out with scope_id only.

That's fine while the guild has a provision-time route row. But a MANAGED
Discord agent joins guilds dynamically (the shared bot is added to /
removed from servers at runtime), and GATEWAY_RELAY_ROUTE_KEYS — the only
thing that writes guild route rows — is a self-hosted, static field never
stamped for managed agents. So their guild has no route row, the
connector's guild-route lookup misses, and with no user_id on the frame
there's nothing to fall back to → every guild reply is declined
"discord egress declined: target not routed to an onboarded tenant"
even though INBOUND resolved the same guild fine (via the author-first
SharedSocketRouter.targets() fallback).

Fix: capture the authentic author user_id for EVERY inbound (DM and
scoped alike) and re-attach it on the outbound reply alongside scope_id.
The connector consults it only on a route/scope miss, so carrying both
never overrides routing-table resolution. This is the gateway half of the
paired gateway-gateway change (makeDiscordTenantOf guild-route-miss
author-binding fallback); together they make guild replies resolve the
same observed-author way inbound already does.

Tests (tests/gateway/relay/test_relay_adapter.py): a guild reply now
carries both scope_id AND user_id; a scoped inbound with no author still
yields scope_id only (never invents one). Verified fail-without /
pass-with.

* build: declare pywin32 as a direct win32 dependency

hermes_cli/windows_ssh_runtime.py imports win32security/win32file/etc.
directly but pywin32 only arrived transitively via concurrent-log-handler
-> portalocker. Declare it with a sys_platform gate so the Windows SSH
runtime doesn't depend on the logging dep chain. Review follow-up on
PR #68130.

* fix(desktop): preserve dragging with empty titlebar slots

* Revert "fix(agent): circuit-break AttributeError from commit-splice and detect code skew"

This reverts commit 3a9b9d65d5.

* fix(context): revalidate Codex OAuth context windows

* test(context): document Codex cache persistence coverage

* fix(context): scope Codex catalogue cache by credential

* test(context): cover Codex context rollback

* fix(compression): report live-resolved Codex window in the autoraise notice

The autoraise banner hardcoded '272K' for the gpt-5.4/5.5/5.6 family, but
the Codex /models catalog is authoritative and shifts server-side (gpt-5.6
served 372K during July 9-18, 2026 before OpenAI rolled it back). Pass the
compressor's live-resolved context_length through so the notice reports the
window the session actually got; the static 272K/128K text remains as the
fallback when no resolved value is available.

* fix(codex): send ChatGPT-Account-Id on /models probes

The Codex backend returns the per-account model catalog only when the
ChatGPT-Account-Id header is present. Without it, GET /backend-api/codex/models
responds 200 OK with {"models":[]} and the picker silently degrades to the
hardcoded fallback list — which is stale or wrong for the active plan
(no GPT-5.6 family, wrong context windows).

This was the upstream bug behind slow first responses and HTTP 520/120s SSE
hangs: Hermes was sending invalid slugs because the probe never saw them in
the catalog, and Codex's request builder also depends on the same JWT claim
that's now being threaded through both probe paths.

Fixes the probe-side paths in hermes_cli/codex_models.py and
agent/model_metadata.py by extracting chatgpt_account_id from the OAuth JWT
(mirroring the request-side logic already in auxiliary_client.py) and sending
it as a header.

Verified live:
- _fetch_models_from_api now returns the 10-model catalog (gpt-5.6-sol,
  gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4, gpt-5.4-mini,
  gpt-5.3-codex-spark, 3x -pro variants) instead of [].
- _fetch_codex_oauth_context_lengths resolves all 8 account models to 272K
  context (matches direct API probes of the same account).
- end-to-end: hermes chat -m gpt-5.6-sol -q 'Reply with one word: pong'
  returns 'pong' cleanly via the openai-codex route.

Same class of bug as PR #64760.

* test(codex): cover ChatGPT-Account-Id header on /models probe

Add regression tests locking in the new behavior: a JWT carrying a
chatgpt_account_id claim causes the probe to send ChatGPT-Account-Id,
while a malformed token omits the header instead of crashing.

* fix(tools): make the tool-search context gate provider-aware (#68589)

_resolve_active_context_length() called get_model_context_length() with the
model id alone, so provider-enforced windows (e.g. Codex OAuth's 272K for
gpt-5.x vs the direct API's 1.05M) never reached the tool-search activation
gate — it sized against generic metadata for the same slug.

Resolve the runtime provider for the configured model and pass provider,
base_url, and api_key through. If credential resolution fails (offline, no
keys), degrade to a provider+base_url-only lookup so the static
provider-aware fallbacks still apply; explicit model.context_length keeps
short-circuiting as before (#46620). Gap flagged during review of #16735.

* feat(skills): bundle docx, xlsx, and pdf office skills; refresh powerpoint (#68595)

Non-technical users asking for Word docs, spreadsheets, or PDF work had
no bundled skill coverage — docx/xlsx creation required discovering and
installing hub skills, and PDF manipulation had no skill at all beyond
OCR extraction and nano-pdf edits.

- skills/productivity/docx: create (docx-js), edit (unzip -> XML -> zip),
  tracked changes, comments, validation. Adapted from anthropics/skills.
- skills/productivity/xlsx: openpyxl creation/editing, mandatory
  LibreOffice recalc gate, formula-compatibility rules, financial-model
  conventions. Points at optional excel-author for finance-grade work.
- skills/productivity/pdf: merge/split/rotate/watermark/encrypt, form
  filling (AcroForm + flat overlay scripts), text/table extraction,
  reportlab creation, forms.md + reference.md companions.
- skills/productivity/powerpoint: synced to current upstream pptx skill —
  richer pptxgenjs corruption footguns, template workflow, validate.py +
  validators + thumbnail.py, font-substitution QA guidance; drops the
  stale pack.py/editing.md/pptxgenjs.md workflow files.
- Cross-linked ocr-and-documents, nano-pdf, excel-author via
  related_skills so each office skill routes to its siblings.
- deliverable-mode docs mention the new skills; regenerated per-skill
  docs pages, catalogs, and sidebar.
- tests/skills/test_office_document_skills.py: frontmatter contracts,
  referenced-script existence, schema-map integrity, cross-link
  resolution, script compilation.

E2E validated: docx create->render->edit->validate, xlsx recalc
(SUM + _xlfn.TEXTJOIN evaluate correctly), pdf create->merge->extract,
pptx generate->validate->thumbnail.

* fix(approval): raise gateway approval timeout to 300s, honest stale-tap UX, offer Always on mixed prompts (#68597)

Three related messaging-approval fixes:

1. approvals.timeout default 60 -> 300. PR #63501 collapsed the gateway
   wait onto the canonical approvals.timeout (previously
   gateway_timeout=300), silently shrinking messaging approval windows
   to 60s. Push-notification approvals routinely arrive later than a
   minute; taps landed after the wait had already failed closed.

2. Stale-tap honesty: adapters resolved the approval AFTER rendering
   '<checkmark> Approved by <user>' (Telegram/Discord/Slack), or ignored a zero
   resolve count (WhatsApp Cloud/Feishu). A tap on an expired prompt
   claimed approval while the command had already been denied. All
   button paths now resolve first and render 'Approval expired -
   command was not run' when nothing was waiting.

3. Mixed-warning prompts (dangerous pattern + tirith finding) now offer
   Always: the persistence layer already permanently allowlists the
   pattern key and downgrades the tirith key to session scope, but the
   UI hid Always whenever ANY tirith warning was present. Pure-tirith
   prompts still withhold Always (content findings are session-max by
   design), and Smart-DENY overrides remain once-only.

* feat(secrets): one-command token rotation + actionable startup errors for all secret sources (#68605)

* feat(secrets): one-command token rotation + actionable startup errors for all secret sources

When a Bitwarden machine-account token expired, users saw a raw Rust
error dump (invalid_client + Location: + backtrace hints) and the only
fix was manually editing .env or re-running the whole setup wizard.

- New `hermes secrets bitwarden token` / `hermes secrets onepassword
  token`: paste a new token (masked prompt or flag), the command probes
  the backend BEFORE persisting — a rejected token changes nothing; a
  good one is written to .env and the fetch caches are cleared.
- New optional SecretSource.remediation(kind, cfg) hook: startup
  warnings now print a '→ Run `hermes secrets <name> token`…' fix-it
  line after any fetch error, for bundled AND plugin sources (generic
  per-ErrorKind defaults in the ABC).
- bws stderr is summarized to its cause line (Location:/backtrace noise
  dropped) and invalid_client/invalid_grant/400 identity rejects are
  now classified AUTH_FAILED (was INTERNAL) with a plain-English
  explanation naming the token env var.
- op whoami probe accepts a candidate token so rotation validates the
  NEW credential, not the ambient one.

Additive hook with defaults — no SECRET_SOURCE_API_VERSION bump.

* docs: fix MDX parse error in secret-source-plugin hook table

Escaped backticks around a <name> placeholder made MDX parse it as an
unclosed JSX tag, breaking the docs-site build.  Use a plain code span
instead.

* feat(desktop): configure repository discovery (supersedes #67630) (#68642)

* feat(desktop): configure repository discovery

* fix(config): preserve additive default migration

* fix(desktop): stabilize session-actions-menu gateway mock for repo-scan subscribe

projects.ts now runs $gateway.subscribe(syncReposScanning) at module load, and
nanostores fires the subscriber synchronously. session-actions-menu.test.ts
reaches projects.ts transitively via the session store but mocked
@/store/gateway without $gateway, crashing the whole desktop vitest suite
("No \ export is defined"). Simply adding $gateway: atom(null) exposed a
second issue: the synchronous subscriber calls the mock's activeGateway()
during the transitive import, before the module-level const initializes (TDZ).

Hoist the mock fns via vi.hoisted() so activeGateway is defined before the
hoisted vi.mock factory runs, and add $gateway: atom(null) to the mock. Mirrors
the self-contained mock pattern already used in projects.test.ts. Also maps the
PR author's commit email for attribution.

Supersedes #67630; incorporates review feedback from that PR.

Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>

---------

Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>
Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com>

* fix(desktop): ⌘W closes visible file tab when preview selection is stale (#68639)

* fix(desktop): make ⌘W close visible file tab on stale preview selection

When the live preview target is gone but $rightRailActiveTabId still points
at preview, file tabs remain on screen while ⌘W fell through to a workspace
no-op. Close the visible file tab instead.

* test(desktop): cover ⌘W close for file tabs and ghost preview selection

Lock the happy path and the stale-preview regression so ⌘W keeps closing
the file tab the rail is actually showing.

* fmt(js): `npm run fix` on merge (#68681)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* feat(billing): plan chips and rows deep-link their tier (#68666)

* fix(desktop): drop the decorative top-up credits bar (#68649)

The bar rendered full-or-empty (value 1|0) because top-ups have no
denominator — the wire carries only the current balance and the pool is
open-ended, so a fill fraction is fiction. Show the amount alone;
subscription credits and the monthly cap keep their bars (real
denominators).

* fix(ci): route critical supply-chain findings through review gate (#68833)

Let the scanner report critical findings without failing. The review-label
gate owns the action-required status and blocking result, allowing the
ci-reviewed label rerun to clear both CI and the PR comment.

* fix: `tool_calls` double-encoding on import (#68856)

* nix: add `cage` to devShell

* test(desktop): add pre-filled sessions support

Exports createSandbox, writeMockProviderConfig, writeEnvFile,
buildAppEnv, findElectron, and launchDesktop from fixtures.ts so
specs can compose their own seeded-backend fixtures without duplicating
the sandbox/config/launch logic.

* test(desktop): auto-fail e2e tests on error banner

Adds a shared test fixture (e2e/test.ts) that wraps @playwright/test's
page with an error-banner guard. When any [role="alert"] element
(error notification toast) appears in the DOM during a test, the test
fails with the error message text.

The guard uses:
- A MutationObserver (injected via addInitScript) that watches for
  [role="alert"] elements appearing at any point during the test
- A final DOM scan in afterEach for alerts still visible at teardown
- Deduplication so the same error text only fires once

All existing e2e specs updated to import { test, expect } from './test'
instead of '@playwright/test'. No per-spec setup needed — the guard is
auto-installed on every page via the extended fixture.

This catches issues like the "resume failed" error banner that can
appear during session loading — previously the test would pass while
an error toast was silently visible on screen.

* fix(state): parse tool_calls JSON string before re-serializing

_insert_message_rows and append_message both do json.dumps(tool_calls)
to serialize the field for SQLite storage. But when tool_calls arrives
as a JSON string (from import_sessions / export_session, which store it
as TEXT), json.dumps double-encodes it — wrapping the already-serialized
string in quotes and escaping the inner quotes.

When _rows_to_conversation later does json.loads(row['tool_calls']),
the double-encoded string parses back to a plain string (not a list).
_history_to_messages then iterates this string character-by-character,
calling tc.get('function', {}) on each char — 'str' object has no
attribute 'get'.

This was a pre-existing bug (on main), but only triggered by the
import_sessions path (the live agent always passes tool_calls as a
Python list). The e2e error-banner guard caught it via the 'Resume
failed' notification toast.

Fix: in both append_message and _insert_message_rows, parse tool_calls
with json.loads first if it's a string, then re-serialize.

* fix(desktop): exempt boot-failure from error guard

- boot-failure: add allowErrorBanners() beforeEach — these tests
  deliberately trigger boot errors, so error toasts are expected
- test.ts: export allowErrorBanners() opt-out + reset flag in afterEach

* feat(status-bar): add /battery toggle for a color-coded battery read-out

Add an opt-in battery indicator to the CLI and TUI status bars, shown as
the first element and colour-coded by charge (green/yellow/orange/red, or
green while charging). Off by default and a no-op on machines without a
battery.

- agent/battery.py: shared psutil-backed reader with a short TTL cache,
  category bucketing, and a compact 🔋/ label. Fails open to
  "unavailable" everywhere.
- CLI: /battery [on|off|status] toggle persisted to display.battery,
  rendered first in every status-bar width tier.
- TUI: /battery slash command, config sync, a system.battery RPC polled
  while enabled, and a pinned first segment in StatusRule.

* fix(approval): restore session approval for Tirith-flagged commands

Adds an allow_session flag to the gateway approval payload so adapters
can render the session tier independently of the permanent tier. Matrix
gains a session reaction (🌀) and a reaction legend; pure-tirith prompts
now offer once/session/deny instead of collapsing to once/deny.

Salvaged from PR #67312, adapted to the allow_permanent semantics that
landed in #68597 (Always offered when any dangerous-pattern warning is
persistable; pure-tirith prompts stay session-max).

* fix(approval): honor allow_session across all button adapters

Widen the allow_session tier from Matrix to every adapter the gateway
notifies: Telegram, Discord, Slack, Feishu, and Teams gate their Session
button on it; WhatsApp Cloud and qqbot accept the kwarg (no session tier
in their button sets). Also thread allow_session through the plugin-
escalation gate, the execute_code guard payload, and the plain-text
fallback so every notify path carries the same capability flags.

* test(approval): cover allow_session tiers in Matrix reaction seeding and gateway payload

Update the Matrix reaction-seeding contract to the four-reaction default
(once/session/always/deny), add tirith-tier (session without always) and
no-session-tier cases, and assert allow_session=True in the tirith
gateway payload.

* fix(desktop): wrap missing sidebar icon-button tooltips (#67500)

* fix(desktop): wrap sidebar icon buttons in Tip tooltips

Several icon-only buttons in the sidebar (header actions, workspace
menu, project menu, session actions, load-more) had aria-label but
no visual tooltip on hover. Wrap them in the existing <Tip> component,
matching the pattern already used elsewhere (e.g. ProfilePill).

No behavioral changes -- purely wraps existing buttons.

Adds vitest coverage asserting the Tip wrapper (data-slot=tooltip-trigger)
for 6 of 7 files; index.tsx is a 1500+ line top-level page component and
was verified manually via screenshots instead.

* fix(desktop): satisfy consistent-type-imports lint rule in project-dialog test

* test(desktop): update session-row mocks for restored sessionColorById

* fix(desktop): compose Tip around the real trigger instead of inside it

Tip was being placed as SessionActionsMenu's/PlatformAvatar's DIRECT child,
which asChild then cloned instead of the actual button/span. Neither Tip nor
PlatformAvatar forwarded the injected onClick/ref, so both silently dropped
the wiring:

- session-actions-menu.tsx: Tip now wraps DropdownMenuTrigger internally
  (new 	ooltip prop) instead of the caller wrapping its children in Tip.
- platform-icon.tsx: PlatformAvatar now forwards ref and spreads rest props
  onto its span so a wrapping Tip's trigger actually attaches.
- session-row.tsx: updated call site to use the new tooltip prop.
- Added session-actions-menu.test.tsx exercising the real DropdownMenu open
  behavior end-to-end (no Tip/Dropdown mocks).
- session-row.test.tsx no longer mocks PlatformAvatar's behavior; it now
  exercises the real (fixed) component for the handoff-avatar tooltip.

* fix(desktop): compose Tip outside PopoverAnchor in ProjectMenu (#67500)

* test(desktop): update session-row test for the tooltip-prop composition (cbbbeb2fd)

* fix(desktop): satisfy consistent-type-imports in session-row.test.tsx mocks

* chore: retrigger CI

* test(desktop): stop mocking PlatformAvatar's behavior (#67500, third pass)

The mock was re-introduced by a prior edit that fixed an unrelated lint
error, silently undoing the earlier fix where this test started exercising
the real (forwardRef) PlatformAvatar. Removed the mock; updated the two
handoff-avatar tests to query the real component's rendered span instead of
text content, since it renders a brand SVG icon for known platforms rather
than the platform name as text.

* fmt(js): `npm run fix` on merge (#68867)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(gateway): detect stale lock when macOS psutil returns valid start_time for recycled PID

On macOS, the lock record's start_time is None (no /proc at creation),
but psutil.Process(recycled_pid).create_time() returns a valid float
for the unrelated process that now owns the PID. The old condition
required both sides to be None before falling back to cmdline checking,
so the recycled PID was never detected as stale.

Change the fallback condition from AND to OR: when either side's
start_time is missing, fall back to cmdline-based gateway detection.

Fixes #53763

* fix(gateway): handle PermissionError on stale root-owned lock file

When the macOS launchd service runs in a Background session, the gateway
process spawns as root and creates a root-owned gateway.lock. On restart
as the normal user, open() on that file raises PermissionError, crashing
the gateway immediately and entering a launchd crash loop.

Catch PermissionError in is_gateway_runtime_lock_active(), remove the
stale lock file, and return False so the new process can start cleanly.

Fixes #42685

* fix(gateway): guard acquire_gateway_runtime_lock against root-owned lock PermissionError

Widen the PermissionError handling from is_gateway_runtime_lock_active
(#42689) to the sibling open() in acquire_gateway_runtime_lock: a stale
root-owned gateway.lock left by a launchd Background session previously
crashed the acquiring process. Unlink the stale file and retry once; if
the unlink or retry fails, return False cleanly instead of raising.

* fix(gateway): make stale scoped-lock removal atomic via tombstone rename

Replace the unlink()+O_EXCL sequence in acquire_scoped_lock with an
atomic os.replace() of the stale lock to a <lock>.stale tombstone
followed by the existing O_EXCL create. With plain unlink(), two racing
starters could both judge the lock stale and the second unlink() would
silently delete the first racer's freshly-created lock — both would then
'win'. os.replace() guarantees exactly one racer claims the stale file;
the loser gets FileNotFoundError and falls through to O_EXCL, which
admits at most one winner. Tombstones are cleaned up immediately;
behavior is otherwise identical.

* fix(gateway): detect stale gateway_state.json in `gateway status` (TTL + PID liveness)

Verified: applies cleanly and the patched module compiles. Tests are
described in the PR body (not bundled in this commit).

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

* test(gateway): cover stale gateway_state.json detection (TTL + PID liveness)

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

* fix(gateway): take over live platform-lock token holders once

When --replace misses a cross-HERMES_HOME Telegram token holder, platform
connect used to retry forever. Terminate a verified gateway holder once
(with the takeover marker) and re-acquire the scoped lock (#65176).

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(contributors): map jaretbottoms@gmail.com -> jbbottoms (PR #65178 salvage)

* fix(gateway): reap the replaced gateway's orphaned children on POSIX

Builds on jbbottoms's #65178 takeover fix (cherry-picked as the previous
commit). Windows --replace already tree-kills via taskkill /T, but the
POSIX paths signalled only the recorded gateway PID — adapter
subprocesses that outlived their parent kept holding scoped token locks
and blocked the replacement gateway.

- gateway/status.py: _snapshot_gateway_children() captures the old
  gateway's descendants (psutil, recursive) while it is still alive;
  reap_gateway_children() SIGTERMs verified orphans after the main PID
  is confirmed dead, waits bounded, SIGKILLs survivors. Identity-aware
  (psutil is_running is PID+create-time), skips zombies and children
  whose ppid still equals the old gateway (parent actually alive), and
  never raises — best-effort with debug/info logging only.
- take_over_scoped_lock_holder() snapshots before terminating and reaps
  only on a confirmed successful handoff.
- gateway/run.py: start_gateway --replace snapshots before SIGTERM and
  reaps after the old PID is confirmed gone, mirroring taskkill /T.
- tests/gateway/test_replace_child_reap.py: reap/skip/never-raise unit
  coverage plus end-to-end --replace ordering (snapshot → terminate →
  reap) and the no---replace path never touching the old process.

* chore(contributors): map emails for PRs #66906, #66420, #63398 salvage

* fix(state): probe FTS5 read path in _db_opens_cleanly so partial index corruption is detected (#66724)

`hermes sessions repair --check-only` opens cleanly on state.db files
with partial FTS5 index corruption — base tables read fine, the rolled-back
write probe from #50502 succeeds, and `PRAGMA integrity_check` returns
"ok". But every session_search / /resume title resolution / feature
backed by MATCH / snippet / rank queries errors out with
`database disk image is malformed` because internal shadow-table segments
are bad. The official repair tool then gives false confidence.

Add a representative FTS5 read probe against both `messages_fts` and
`messages_fts_trigram` (the latter backs title resolution). Empty MATCH
strings are accepted by every FTS5 index without requiring populated
content, so the probe is safe on a freshly-init'd DB; missing-table /
missing-column errors fall through to the existing "not yet a populated
DB" branch, matching the write-probe's behaviour. Any other OperationalError
is surfaced as the check reason, which sends `hermes sessions repair` to
its existing FTS 'rebuild' path (repair_state_db_schema, line 616).

Single-file change in hermes_state.py::_db_opens_cleanly. No public API
change. No new imports. Fixes #66724.

* fix(state): also catch sqlite3.DatabaseError in FTS5 read probe (#66724)

The FTS5 read probe in _db_opens_cleanly() only caught
sqlite3.OperationalError. But the corruption class #66724 actually
wants caught — partial shadow-table damage where MATCH / snippet / rank
queries raise DatabaseError("database disk image is malformed") — is a
DatabaseError, not OperationalError. Without this catch the probe
crashes the caller instead of returning a reason, which is exactly the
silent-fail mode the issue describes.

Move the try/except inside the for-loop so each FTS table is probed
independently (one table corrupted should still surface as a reason),
add a separate except clause for DatabaseError that surfaces the same
reason format, and use continue instead of pass so the loop still walks
both tables when only one is missing on a brand-new DB.

Tested by hand: with a corrupted messages_fts_trigram shadow table the
function now returns 'fts5 read probe failed on messages_fts_trigram:
database disk image is malformed' instead of crashing out. Without this
fix it would still crash.

* fix(state): preserve degraded-runtime read probe + use canonical FTS5 classifier

Two follow-ups on top of f842733 (the FTS5 read probe added in #66906):

1. The original probe query used MATCH '', which FTS5 rejects with
   'fts5: syntax error near '. Empty MATCH syntax is not valid FTS5.
   Switch to MATCH '""' — a quoted empty phrase that parses, scans
   zero rows, and exercises the same shadow-table read path the
   search tools use. The probe previously never reached the shadow
   segments at all on a healthy DB; the read-corruption class was
   only being detected because the existing write probe happens to
   fail first on a DatabaseError.

2. The probe's degraded-runtime branch only checked the substrings
   'no such table' / 'no such column'. On a SQLite build without the
   fts5 module, MATCH against a legacy messages_fts table raises
   'no such module: fts5' (a different OperationalError class). The
   substring check would misclassify that as corruption and trigger
   repair, whose final fallback deletes the messages_fts% schema
   (#66906 review). Use SessionDB._is_fts5_unavailable_error() — the
   canonical classifier already used by the degraded-runtime init
   path — to recognize both 'no such module: fts5' and
   'no such tokenizer: trigram' as capability errors.

Add tests covering:
- Partial shadow-table damage (read-corruption class)
- Repair brings reads back online
- Healthy degraded DB without fts5 module stays healthy (regression
  for the misclassification risk)
- Healthy degraded DB without trigram tokenizer stays healthy

Closes #66906 review feedback
Refs #66724

* fix(state): self-heal FTS corruption on the SessionDB search path too

Complements #66296 (self-heal on the write path): search_messages()'s main
FTS5 MATCH query caught only sqlite3.OperationalError (a query-syntax error →
return empty). A corrupt FTS index raises the malformed / "fts5: corrupt
structure record" class, which is a sqlite3.DatabaseError — the parent of
OperationalError, so it was NOT caught and propagated straight out of
search_messages, crashing session/history search.

The write path now rebuilds and retries on that class, but a read-only
session (cron/CLI history search, or a search issued before any write) never
triggers a write, so its search stayed broken until the next process restart
ran the offline repair.

Catch the DatabaseError corruption class on the search MATCH read too and
route it through the existing one-shot _try_runtime_fts_rebuild(), then retry
the query. The catch is moved outside `with self._lock` so rebuild_fts() can
re-acquire the lock (mirrors _execute_write). The one-shot guard is shared
with the write path, so a single instance never loops on a genuinely
unrecoverable index. OperationalError syntax handling is unchanged (caught
first).

Adds a regression test: with a corrupted messages_fts and no post-corruption
write, search_messages() rebuilds in place and returns the match; without the
fix it raises DatabaseError.

* fix(state): extend search-path FTS self-heal to the CJK/trigram branch

The trigram MATCH branch in search_messages() had the same
OperationalError-only catch that #66420 fixed on the main FTS5 branch: a
corrupt messages_fts_trigram shadow table raises the malformed /
'fts5: corrupt structure record' class (sqlite3.DatabaseError, parent of
OperationalError), which propagated straight out of search_messages and
crashed CJK session/history search for read-only sessions.

Route that class through the shared one-shot _try_runtime_fts_rebuild()
and retry the trigram query (catch moved outside self._lock so
rebuild_fts() can re-acquire it, mirroring the main branch). If the
rebuild is refused (guard consumed / FTS disabled / different error) or
the retry fails, fall through to the existing LIKE substring fallback —
which reads only the canonical messages table — instead of raising, so
CJK search degrades gracefully rather than crashing.

Adds two regression tests: trigram search self-heals in place after
shadow-table corruption (answers from the rebuilt trigram index, not the
LIKE fallback), and degrades to LIKE without raising when the one-shot
rebuild was already consumed.

Follow-up to #66420; refs #66296 #66724

* fix(state): add REINDEX strategy to repair stale B-tree indexes (#63386)

When PRAGMA integrity_check reports 'wrong # of entries in index' for
B-tree indexes (e.g. idx_sessions_handoff_state), the existing repair
strategies (FTS rebuild, sqlite_master dedup, drop-FTS+VACUUM) don't
address the mismatch. Add Strategy 0.5: run REINDEX to rewrite the
index b-tree from canonical table rows before escalating to more
destructive strategies.

* test(state): exercise REINDEX repair against a REAL stale B-tree index

Replace the mocked test for #63398's REINDEX strategy: the original
monkeypatched _db_opens_cleanly to return the corruption string, so the
REINDEX pass itself was never exercised against actual index corruption —
the test would pass even if REINDEX didn't fix anything.

New fixture _corrupt_btree_index() builds genuine on-disk staleness with a
writable_schema hack: rewrite the index definition to a partial index
(WHERE 0), REINDEX so the b-tree is rebuilt empty, then restore the full
definition. integrity_check then reports the real
'wrong # of entries in index idx_messages_session' / 'row N missing from
index' class from #63386 — no mocks anywhere.

The rewritten test asserts end-to-end with real function calls:
- the real _db_opens_cleanly detects the stale index,
- repair_state_db_schema repairs it with strategy 'reindex_btree',
- post-repair the detector and raw PRAGMA integrity_check both report
  healthy, and a query forced through the rebuilt index (INDEXED BY) sees
  every row.

Adds a second test asserting the REINDEX strategy is non-destructive
(all sessions/messages survive, readable via SessionDB).

Follow-up to #63398; refs #63386

* fix(kanban): auto-repair index-only kanban.db corruption via REINDEX

_guard_existing_db_is_healthy previously failed closed on ANY
integrity_check failure, including the index-scoped class ('wrong # of
entries in index <name>' / 'row N missing from index <name>') where the
table b-trees are intact and REINDEX rebuilds the damaged indexes
losslessly. Boards hit by that class were bricked until manual surgery
even though SQLite can fix them in-place.

Now, when integrity_check output consists ONLY of index-scoped errors
(index name parsed generically from the message — no hardcoded list):

  1. quarantine the corrupt bytes FIRST via the existing content-
     addressed _backup_corrupt_db,
  2. under the caller-held cross-process init flock, REINDEX each named
     index (falling back to bare REINDEX if a parsed name doesn't
     resolve),
  3. re-run integrity_check and proceed only if it comes back clean.

Any non-index error class (page corruption, malformed image, freelist
damage) — or a REINDEX whose re-check is still dirty — fails closed
exactly as before: backup + KanbanDbCorruptError, no silent recreation.
Transient OperationalError (locked/busy) still propagates raw with no
quarantine.

Tests build a real board DB and corrupt a live index via the
writable_schema/partial-index REINDEX trick to produce the genuine
'wrong # of entries in index' shape, then assert auto-repair recovers
with data intact, page corruption still raises, and a dirty re-check
fails closed.

* fix(kanban): cap corrupt-backup retention at 10 files per board DB

Content-addressed quarantine backups dedupe identical corrupt bytes,
but corruption that keeps mutating between failures (partial repairs,
further damage across dispatcher retries, multi-profile fleets) mints a
new sha-named backup every round — a user accumulated 124
.corrupt.*.bak files with no bound.

After each NEW backup is created, prune oldest-by-mtime backups beyond
_CORRUPT_BACKUP_RETENTION (module constant, default 10), including the
copied -wal/-shm sidecars. The just-created backup is always exempt
(copy2 preserves the source mtime, which can be older than existing
backups). Pruning is best-effort and never masks the corruption error
about to be raised; dedupe of identical corrupt bytes is unchanged.

* feat(kanban): periodic WAL checkpoint (TRUNCATE) on the dispatcher tick

Kanban connections set wal_autocheckpoint=100, but SQLite's passive
autocheckpoint backs off whenever any reader holds an open snapshot —
on a busy multi-process board the -wal file can grow without bound
between gateway restarts.

After each successful dispatch tick, while still holding the board's
single-writer dispatch flock, run PRAGMA wal_checkpoint(TRUNCATE)
best-effort at a coarse interval (>=5 min since this process last
checkpointed that board; module-level per-path monotonic timestamp, so
multi-board dispatchers checkpoint each board on its own clock).
Success and busy/locked skips are both logged at DEBUG; a failing
checkpoint can never fail the tick.

* feat(kanban): add `hermes kanban repair` CLI verb

Adds kanban_db.repair_db() — a structured, non-raising wrapper around
the same narrow repair policy as the connect-time guard: probe with
PRAGMA integrity_check under the board's cross-process init flock;
quarantine the corrupt bytes FIRST via the content-addressed backup;
REINDEX only when every integrity message is index-scoped; re-check;
report ok / repaired / corrupt / missing. Locked/busy OperationalError
still propagates raw (a locked healthy DB is not corruption and gets
no quarantine), and a repair invalidates the per-process healthy-path
cache so the next connect() re-probes.

The CLI verb reports status human-readably (or --json), exits 0 for
ok/repaired/missing and 1 when the DB is still corrupt (non-index
corruption stays fail-closed with manual-recovery guidance). It
dispatches BEFORE kanban_command's auto-init: init_db() raises
KanbanDbCorruptError on a corrupt board, which previously would have
made a repair verb unreachable on exactly the boards that need it.

CLI tests drive the real argparse surface (build_parser +
kanban_command) against real corrupted SQLite fixtures.

* fix(packaging): graft web_dist in MANIFEST.in and add sdist regression test

Wheels ship hermes_cli/web_dist via pyproject package-data, but the sdist
did not: MANIFEST.in had no graft and .gitignore excludes web_dist, so
source tarballs installed a dashboard-less package. Graft the directory
and add an sdist regression test that builds the tarball and asserts
index.html is inside.

Salvaged from #29661; the PR's [web]-extra 404-message change was dropped
per maintainer review (misleading guidance for source installs).

* fix(dashboard): attempt one recovery build when --skip-build finds no dist

--skip-build with a missing web_dist/index.html previously hard-failed
with sys.exit(1) (issue #59288). The desktop launcher passes
--build-mode skip on every boot, so a wiped or never-populated dist
bricked the dashboard until the user manually rebuilt.

Now the default-dist path logs a clear warning and attempts exactly ONE
recovery build through the existing _build_web_ui path. If the recovery
build also fails to produce index.html, the original fatal behavior is
preserved with a clear message. A custom HERMES_WEB_DIST stays fail-fast:
the build writes to the default dist location and cannot populate a
caller-managed directory.

Closes #59288

* fix(web): guard _serve_index against a missing or unreadable index.html

mount_spa degrades to a JSON 404 catch-all when the dist directory is
fully missing, but _serve_index read index.html unguarded — so a dist
dir that exists while index.html is missing (partial build, wiped dist,
permissions) raised FileNotFoundError on EVERY request instead of
returning a useful error.

Catch OSError around the read and return the same JSON 404 payload the
fully-missing-dist path uses, so clients get a consistent signal. The
route recovers automatically once a rebuild restores the file.

* fix(dashboard): content-hash web UI freshness check

Replace mtime-based web UI dist staleness with a content-hash stamp under HERMES_HOME, matching the desktop build freshness model.

This avoids false stale/fresh decisions when git operations rewrite source mtimes without changing bytes, while preserving the existing stale-dist fallback. Also treats malformed or missing stamp data as needing a rebuild.

* fix(cli): serialize concurrent web-UI builds with an exclusive flock

Concurrent dashboard boots (desktop retry loop) each spawned their own
npm install + vite build over the same tree; parallel builds starved
each other, the dist sentinel never advanced, and every boot re-triggered
the build — cascading into orphan backends, port collisions, and CPU
storms that also knocked out the Telegram gateway's heartbeat (2026-07-12).

One process now builds under flock; the rest serve the existing dist
(stale is acceptable) or wait for the first-ever build to finish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(cli): run the web-UI staleness walk once, under the build lock

The flock wrapper checked _web_ui_build_needed twice (pre-lock fast path
and post-lock re-check) and _do_build_web_ui checks it again internally,
so a boot that actually built walked the whole web/ source tree three
times. The callee's own check already runs under the lock on every path
through the wrapper, so it alone is sufficient: drop both wrapper checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(git): ignore the web UI build lock file

The cross-process build flock (.web_ui_build.lock at the repo root) is
an empty coordination file created on first dashboard boot; it must
never be tracked or show up as untracked noise. Also keeps it out of
the content-hash staleness digest, which skips gitignored paths.

* test(cli): cover the web UI build flock contention paths

PR #63455 shipped the flock without tests. Cover the three contention
paths: contended+dist serves stale without a second build, uncontended
builds under the lock (creating the lock file), and contended+no-dist
blocks then skips the rebuild because the staleness re-check runs under
the lock and sees the winner's stamp. Also pin the lock filename into
.gitignore via a regression assertion.

* style(cli): open the build lock file with explicit encoding (ruff PLW1514)

* chore(contributors): map eazye19@users.noreply.github.com -> eazye19 (PR #42387 salvage)

* fix(mcp): reconnect immediately on transport TaskGroup drop instead of backoff/park

Streamable-HTTP / SSE MCP transports run their stream pump inside an anyio
TaskGroup. A transient stream drop (idle timeout, brief backend blip,
server-side TCP close) surfaces as a BaseExceptionGroup escaping the
transport context manager. It reached run()'s error path, which applied
exponential backoff (1s..16s) and eventually parked the server for 300s and
deregistered its tools — turning a sub-second glitch into a multi-minute
tool outage even though the POST path was still healthy.

_run_http now wraps all three transport branches (SSE, new + deprecated
Streamable-HTTP) and routes a BaseExceptionGroup through a new
_reconnect_or_reraise_group() helper that returns 'reconnect' (immediate
rebuild, no backoff/park/deregister). It re-raises instead of masking when
shutdown is in progress, when the group carries a real CancelledError
(cancellation must propagate, cf. #9930), or when no live session was
established this attempt (a connect/handshake failure that should fall
through to run()'s backoff rather than hot-loop).

Fixes #66092

Co-authored-by: 雨哥 <hanyu1212@users.noreply.github.com>

* fix(mcp): charge immediate reconnects against a rapid-drop budget (#62212)

Harden the immediate-reconnect path from #66271:

- _reconnect_or_reraise_group re-raises when the transport TaskGroup
  carries a KeyboardInterrupt / SystemExit leaf — fatal signals must
  propagate to the interpreter, never be converted into a reconnect.

- Rapid-drop budget: a completed handshake alone no longer clears
  _reconnect_retries/backoff on clean transport return. A session is
  UNPROVEN until it demonstrates real health — survived >=1 full
  keepalive interval (keepalive success path) or served >=1 successful
  tool call (_mark_session_proven). Unproven clean returns are charged
  against _MAX_RECONNECT_RETRIES, so a flapping transport that
  handshakes fine and drops moments later still reaches the park
  instead of respawning forever (#62212: 6212 spawns in 63h).
  Proven sessions keep the #57604 behaviour: budget clears, transient
  blips over a long-lived session never accumulate toward parking.

* fix(mcp): unwrap exception groups and park permanent failures immediately (#65673)

- Add module-level _unwrap_exception_group (ported from
  hermes_cli/mcp_config.py, adapted): handles nested groups, prefers
  non-cancellation leaves over the CancelledErrors anyio sprays across
  sibling tasks, and re-raises KeyboardInterrupt/SystemExit leaves.

- Add _classify_mcp_failure(exc) -> 'permanent'|'transient'.
  Permanent: auth 401/403, NonMcpEndpointError, InvalidMcpUrlError,
  FileNotFoundError/ENOENT on the stdio command. Transient: everything
  else (network/EOF/ClosedResource/TaskGroup drops). Permanent failures
  park immediately without burning the retry ladder — every retry
  against a missing binary or a revoked credential hits the same wall.

- Every log site in run() and the keepalive failure log now logs the
  unwrapped root cause as 'TypeName: message', so a dead stdio pipe
  says 'BrokenPipeError' instead of the opaque
  'unhandled errors in a TaskGroup (1 sub-exception)' (and empty
  str(exc) dead-pipe errors are no longer blank).

* fix(mcp): one WARNING per state transition, DEBUG retry chatter, jittered backoff

Log-storm hygiene for the reconnect machinery (#65673, #66092):

- State transitions carry exactly one WARNING each:
  connected → degraded (keepalive failure), degraded → parked
  (budget exhausted / rapid-drop park / permanent error),
  parked → connected (revival proven healthy, via
  _mark_session_proven).
- Per-attempt retry logs (initial-connect attempts, connection-lost
  reconnect attempts, per-cycle rebuild notices, park-wake probes)
  demoted to DEBUG.
- Backoff sleeps get +/-20% uniform jitter (_jittered) so a herd of
  servers that lost the same backend doesn't retry — and log — in
  lockstep.

* fix(mcp): re-register tools during parked revival

* fix(mcp): isolate a single failing stdio server from the bridge (#50394)

* fix(mcp): clear connect-cooldown state on every shutdown path

Builds on trevorgordon981's #50589 (cherry-picked as the previous commit).
The #50394 cooldown reset only ran inside the async _shutdown coroutine,
which is skipped on the empty-_servers fast path — the most common state
when a server failed to connect (failed servers are never recorded in
_servers). It was also skipped when the MCP loop wasn't running.

Clear _server_connect_retry_after/_server_connect_failures on the fast
path and in a final unconditional sweep so a full shutdown/restart always
re-attempts every configured server immediately. Adds regression tests
for both paths.

* chore(contributors): map diffen77 + fazerluga-creator emails

For the #66547 and #66981 salvage cherry-picks in this branch.

* fix(mcp): reconnect message-less closed transports

* test(mcp): isolate resource error breaker state

* fix(mcp): harden nested interruption detection

* fix(mcp): make transport classification cycle safe

* fix(mcp): cycle-guard cause/context traversal in transport classifier

Builds on diffen77's #66547 (cherry-picked as the previous commits).
Extend _is_session_expired_error's iterative traversal to follow
__cause__/__context__ in addition to ExceptionGroup .exceptions — SDK
wrappers often raise a generic RuntimeError *from* the message-less
ClosedResourceError, leaving the transport signal reachable only via
the chain. The identity-visited set guards chain cycles (handlers
re-raising previously seen exceptions), and a bounded node budget
(_EXC_TRAVERSAL_MAX_NODES) caps pathological acyclic graphs.

Adds regression tests: cause/context chain detection, interruption
precedence through chains, cyclic cause/context termination, and
budget-bounded termination.

* fix(mcp): allow background discovery retry after a run that connected nothing

start_background_mcp_discovery() sets _mcp_discovery_started once and never
resets it. If the first background run exits without connecting any MCP
server (startup cancellation, OOM restart, transient network failure), every
later call returns immediately and the process is permanently stuck with
zero MCP tools until a full restart.

Fix: when discovery is marked started but the thread is dead and no server
is connected, reset the flag and spawn a fresh discovery thread. Also log a
WARNING when a discovery run completes with zero connected servers, so the
condition is visible instead of silent.

Caught in production on a long-running gateway fleet where a gateway
restarted under memory pressure and came back with all MCP tools missing.

* fix(tui): centralize stdio TUI MCP discovery on the shared owner

Review follow-up: the stdio hermes --tui path spawned its own one-shot
discovery thread, so the retry-after-zero-connected semantics added to
start_background_mcp_discovery() did not cover it.

Spawn TUI discovery through the shared owner and make the entry-side
wait_for_mcp_discovery() fall through to the shared owner when no local
thread exists (mcp_discovery_in_flight/join_mcp_discovery already consult
both owners). Keeps the cheap no-mcp-servers config guard on the TUI path.

Adds regression tests for the entry-side wait delegation.

* fix(tui): give the stdio TUI discovery a retry-after-zero-connected path

Builds on fazerluga-creator's #66981 (cherry-picked as the previous two
commits). start_background_mcp_discovery()'s retry allowance only fires
when the function is CALLED again, but tui_gateway/entry.py main() calls
it exactly once at startup — so a first discovery run that connected
nothing still latched the stdio TUI MCP-less for the whole session.

Re-invoke the idempotent spawn from wait_for_mcp_discovery() (the
per-agent-build wait) when the process is MCP-enabled, gated on a flag
set in main() so non-MCP sessions never pay the MCP import on the wait
path. Adds regression tests for both the retry re-invocation and the
non-MCP skip.

* fix(tui): gate the shared-owner MCP discovery wait on the stdio TUI flag

The TUI retry-allowance follow-up made tui_gateway.entry.wait_for_mcp_discovery
delegate to hermes_cli.mcp_startup unconditionally when no entry-local thread
exists. But server._make_agent already calls the startup wait directly for
dashboard /api/ws sessions, so every non-stdio agent build paid the bounded
wait twice (caught by test_make_agent_waits_for_shared_mcp_discovery). Gate
the retry-spawn AND the delegated wait on _mcp_discovery_enabled, which only
the stdio TUI arms in main().

* fix(status-bar): address Copilot review on /battery

- TUI /battery matches the CLI surface: adds `status` (live reading via
  system.battery), and the help/usage strings now consistently read
  [on|off|status].
- batteryLabel() renders `--` for an unknown percent so a null can never
  surface as "null%" even without the showBattery guard.
- Move the system.battery RPC out of the config section into
  "Methods: tools & system" where system.* RPCs belong.

* fix(dashboard): cap gateway health probe timeout

* fix(gateway): report runtime source version in /health, not stale dist-info metadata

/health and /health/detailed resolve the version via importlib.metadata
first, falling back to hermes_cli.__version__. On editable/source
checkouts — including the standard git-based install that hermes-setup
performs — hermes_agent-*.dist-info can survive a source update
unchanged, so the health endpoints keep reporting the previous release
even though the running code (CLI, dashboard, release tags) is newer.
Stale metadata does not raise, so the source fallback never fires.

Flip the preference: use hermes_cli.__version__ (the runtime source of
truth shared by the CLI and dashboard) first, and fall back to
distribution metadata only when the source import fails. The
never-raise contract of the version probe is unchanged.

Observed live: CLI, dashboard, and pyproject all reported 0.18.2 while
/health returned 0.18.0 from a stale hermes_agent-0.18.0.dist-info left
behind by a source update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api-server): count "stopping" runs as active in readiness work counts

_readiness_work_counts()'s active_api_runs set is {"queued", "running",
"waiting_for_approval"} — it excludes "stopping", the status
_handle_stop_run() sets while a run is being interrupted. Since the stop
is fully cooperative (the run stays "stopping" — doing real
executor-thread work — until the agent actually notices the interrupt and
the task settles to "cancelled", an unbounded window, not a fixed
timeout), /health/detailed's background_queues.active_api_runs
undercounts real active work for that whole duration.

Fix: add "stopping" to the active-status set. background_queues.status
itself is hardcoded "ok" (gateway/readiness.py), so this doesn't change
overall readiness — it only corrects the count value external monitoring
tooling reads from this endpoint.

* chore(contributors): map yingwaizhiying@gmail.com -> tianma-if (supersedes stale msh01 entry)

* feat(dashboard): component-level health rollup on /api/status (#68662)

The dashboard's own liveness surface could report healthy while every
authenticated request 500'd (e.g. wedged state DB) — /api/status carried
gateway-only fields, no storage/dashboard signal, and no middleware
counted unhandled exceptions.

- DashboardHealth state holder: rolling 5-min deque of unhandled-error
  timestamps + last self-test result. last_error_type/last_error_path
  are internal-only diagnostics — snapshot() exports counts/enums/
  timestamps exclusively (PUBLIC_API_PATHS no-secrets contract).
- Outermost @app.middleware('http') (registered last) wraps call_next
  in try/except: records + re-raises unhandled exceptions, and records
  responses with status >= 500.
- /api/status gains 'components' {gateway, storage, dashboard,
  platforms} + top-level 'overall' ok|degraded. storage reuses the
  gateway readiness state_db probe (read-only, 1s-bounded) in an
  executor; platforms derive ok/degraded from existing
  gateway_platforms states.
- Authenticated self-test task started in the lifespan: every 60s an
  in-process httpx ASGITransport GET of /api/sessions?limit=1 with the
  real _SESSION_TOKEN, feeding the dashboard component. Skips cleanly
  when httpx is unavailable and while the OAuth gate is engaged (the
  legacy token is not honoured there).

Tests: middleware increments on raising route and on 5xx, window
expiry, components shape + overall, storage degraded when the state_db
probe fails, dashboard degraded after an error, no secret-bearing
fields in the public payload, self-test pass/fail recording (mocked
client) + a real ASGI round trip.

* fix(status): make gateway_updated_at a stable RFC3339-or-null contract (#68657)

The /api/status gateway_updated_at field and the gateway /health/detailed
updated_at field passed through whatever gateway_state.json contained,
untyped. All current writers emit RFC3339 via _utc_now_iso(), but legacy
gateways wrote unix epoch floats, and a corrupt or hand-edited state file
can inject numbers or arbitrary garbage — while the frontend types
(web/src/lib/api.ts) declare string | null.

Add normalize_updated_at() in gateway/status.py as the single funnel:
- str: accepted iff datetime.fromisoformat parses (trailing Z tolerated);
  naive timestamps coerced to UTC; canonical isoformat returned
- int/float: treated as unix epoch seconds -> UTC ISO string, with a
  plausibility guard (reject < 2000-01-01, > now+1day, non-finite)
- bool: rejected explicitly (int subclass, but never a timestamp)
- anything else: None

Apply it at both emit sites: the dashboard /api/status handler (covers
both the local read_runtime_status() branch and the remote
/health/detailed cross-container fallback branch) and the gateway API
server's /health/detailed response.

Contract tests: parametrized /api/status normalization (epoch float/int,
garbage string, None, bool, dict, absent key), remote-health numeric and
garbage bodies, dashboard shape test round-trip assertion, direct
normalize_updated_at units (range guards, Z suffix, naive coercion,
non-finite floats), and a write_runtime_status -> read_runtime_status
round-trip proving the writer side stays tz-aware parseable.

* feat(desktop): type-to-focus the composer from empty chat chrome

Printable keys and soft `/`/Enter pull focus back to the composer when
nothing else owns the key (dialogs, terminal, buttons on Enter, …).

* feat(ui-tui): widget-grid 2-axis layout engine + overlay primitives (#20379 rebase)

Rebase of the widget-grid PR onto current main, then grow it into a real
2-axis engine: resolveGridTracks (grid-template tracks — fixed cell counts
and weighted fr shares with mins), layoutWidgetGrid (1D auto-packing flow),
and layoutGridAreas (2D absolute placement with row/col spans and implicit
row growth). Overlay/Dialog primitives give zoned viewport-level modals
with an optional scrim. /grid-test (interactive: areas, nesting, zoom,
gap/padding) and /grid-test streams (4x3 mission-control GridAreas board
with promote-to-main) exercise everything end to end.

* refactor(ui-tui): route production surfaces through the widget-grid engine

Banner (responsive tiers: full logo -> compact rule -> text -> hidden),
SessionPanel (fixed hero track + flexible info track, the desktop pane
shell's fixed-vs-flex contract), floating overlays, prompt zone, and the
pickers all render through WidgetGrid instead of hand-rolled flex math.
Pickers gain maxWidth so grid cells can cap them.

* feat(ui-tui): background-aware theme adaptation + paired palettes + /theme pin

OSC-11 asks the terminal for its actual background at startup (env
heuristics are blind on xterm.js hosts) and the theme re-derives against
the answer: desktop-contract adaptation (contrast floors + fill polarity),
a shared list-row selection primitive instead of per-picker panel fills,
paired light_colors/dark_colors skin blocks with a machine audit, and a
/theme auto|light|dark pin (display.tui_theme) for hosts whose probe lies.
E2E coverage for the OSC reply chain + /theme-info diagnostics.

* feat(ui-tui): theme engine — seeds to derived-tone ladder + flash-free boot

The desktop color-mix system, ported: a theme is a handful of identity
SEEDS (text, primary, accent, border, status hues); every secondary tone
(muted, label, surfaces, chips, selection) is a color-mix derivative
against the real terminal background. lib/color.ts consolidates the
primitives (parse/mix/luminance/contrast/retone + xterm.js's multiplicative
liftForContrast). Knobs are grid-search fitted so the math reproduces the
classic hand-tuned literals (contract-tested). The display shim renders
authored palettes RAW and only rescues near-invisible colors. Boot reads
the last resolved theme from $HERMES_HOME/tui-theme-boot.json so the first
frame paints in the right palette (no default-dark flash), and the
placeholder cursor follows the bubbles textinput pattern.

* perf(tui): skin switches stay hot — MCP gating, pooled reload, swap repaint

Four responsiveness/hardening fixes surfaced by rapid /skin switching:
config.get mtime now carries an mcp_rev hash so the TUI reloads MCP only
when MCP-relevant config changed (cosmetic /skin writes cost seconds of
reconnects before); reload.mcp runs on the RPC pool serialized by a lock
(inline it froze the stdio reader for the duration of a flapping server's
retry loop — config.set/complete.slash sat unread and the TUI appeared
dead); theme swaps schedule one full repaint (incremental diffs after a
recolor tear — stale cells keep the old palette, read as "shadows"); and
OSC-11 pure-black answers are distrusted universally (unset-default
fingerprint on xterm.js hosts and tmux). Parent EIO zombie fixed: a dead
PTY made every render write throw once a second forever; exit after 5
consecutive dead-stream errors.

* fix(ui-tui): light mode renders the vivid palette RAW, not WCAG-darkened

Pixel-sampled against the reference screenshots: the beloved classic
light-mode look is the vivid authored golds rendered essentially raw
(#FFD700 shows as bright #F5C242) — transparent terminal profiles apply no
contrast lift of their own, and pre-darkening every foreground to WCAG 4.5
produced the reported mustard mud. Light-mode display floors become
near-invisible rescues only (1.18 display / 1.6 semantic; dark keeps
1.45/2.2); the default skin ships a fills-only light_colors OVERLAY
(polarity-flip the navy menu/status fills, foregrounds inherit the vivid
colors), and themeForSkin merges polarity overlays instead of replacing.
Palette audit reworked: base colors audited fully, overlays for valid keys
and fill polarity.

* fix(ui-tui): eradicate every transparent-terminal black-slab trigger

On terminal.background #00000000 xterm paints "drawn blank" and
attribute-styled cells against an opaque black RGB the user never sees
elsewhere. Verified by PTY byte capture + stateful SGR replay that we emit
no black backgrounds — then removed every trigger: the banner's opaque
space-fills, the scrollbar's non-scrollable space column and SGR-dim
track, the placeholder's SGR inverse cursor and dim fallback, and the bold
full-width banner rule. Chrome styling is now explicit truecolor only:
scrollbar thumb rides primary (accent on hover/drag) over a blended track,
the placeholder cursor is a theme-colored chip (48;2 bg + luminance-picked
ink), hints always carry an explicit 38;2 foreground.

* fix(ui-tui): session-panel hierarchy + polarity-proof placeholder tone

Tool/skill rows rendered labels in muted and member lists in text — which
inverts per skin (muted is the strong family tone on gold skins but the
weak gray on blue ones; slate/poseidon read backwards). Labels now lead in
the theme's label tone, values recede via an explicit fade toward the
surface; audited across all 9 built-ins x both poles. The composer
placeholder lands on muted — the "(and N more toolsets…)" tone — a
mid-luminance family color that reads receded on both poles even when
polarity detection is wrong.

* feat(ui-tui): OSC-10 foreground polarity tiebreaker for transparent terminals

Transparent profiles make OSC-11 useless (xterm reports the unset default,
pure black, regardless of the composited surface) so polarity detection
lagged editor theme flips. OSC-10 reports the theme's REAL foreground on
those hosts — its luminance reveals the pole. hermes-ink grows a foreground
slot (shared reportedColorSlot factory), App.tsx queries both in the
startup batch (background first so a trusted answer wins without churn),
and the app commits an inferred pole only when the background was
distrusted AND the foreground is decisive (bright=dark theme, dark=light;
mid-grays and #000/#fff defaults commit nothing). User pins still outrank.

* fix(ui-tui): session-panel fade anchors on muted, not the surface — readable on every pole

mix(text, surface) was invisible whenever text was already pale (the
light-rendered default: cream blended toward white = nothing) and inherited
wrong-polarity detection through the surface. mix(muted, text, .5) is
pole-proof: muted is mid-luminance by construction, so the midpoint stays
readable everywhere. Audited 9 skins x both poles: 2.0-2.9:1 on white,
5.6-9.3:1 on dark (worst case 1.92 for a light-authored skin on dark).

* style(skins): default light overlay = goldenrod ladder, not neon or mustard

On white, the vivid #FFD700/#FFBF00 read as glare and the WCAG-darkened
mustard reads as mud. The sweet spot is the statusbar's goldenrod family
(hue kept, saturation tamed, mid luminance): title #C8961E, headers
#D89B04, labels #A97E10, muted #B8860B unchanged, warm bronze ink body
#5C4718, deepened semantics + shell blue. Hierarchy on white: ink 8.9:1 >
fade 5.2 > label 3.7 > muted 3.3 > title 2.7 > headers 2.4. Dark mode
renders the vivid block untouched (explicitly approved as-is).

* refactor(ui-tui): DRY the chrome primitives — shared scrollbarColors + hintRgb

scrollbarColors(t, hover, grabbed) in overlayPrimitives is now THE scheme
for both scrollbars (was duplicated formulas); textInput's hex parsing
collapses into one hintRgb helper feeding colorizeHint and hintCursorCell.
Comment bloat trimmed. No behavior change — 1243 tests byte-green.

* fix(ui-tui): completions popover — aligned name track + neutral descriptions

Two-column grid: the name track auto-sizes to the widest visible command so
descriptions align in their own column instead of running under the names.
Descriptions render in the neutral statusFg gray — label and muted are
near-twins on the gold skins, which made command and description read as
one unparseable run.

* refactor(ui-tui): every selectable list rides the shared selection chip — zero inverse left

/agents, /journey, model picker, skills hub, plugins hub, pet picker, and
the approval/confirm prompts all used SGR inverse for the active row —
terminal-interpreted against unknowable defaults (black slab on transparent
profiles) and visually divergent from completions/session-switcher. New
spreadable chipRowProps(t, active) in overlayPrimitives (chip bg + lifted
ink + bold; spread after `color` so chip ink wins) converts each site to
the one selection treatment. rg confirms zero inverse={} remaining in
components/.

* fix(ui-tui): overlay width caps are absolute — clampOverlayWidth (Copilot review)

Every picker/hub forced width >= 24 AFTER applying the caller's maxWidth,
so a grid cell narrower than 24 (or a FloatBox cell under 28 with its 4
cols of chrome) overflowed and clipped at the terminal edge. One shared
clampOverlayWidth(preferred, maxWidth, min=24): the caller's cap is
ABSOLUTE (a cell knows its budget), the usability floor applies only when
the cap allows it, uncapped keeps the old floor semantics. Five call
sites (model/pet pickers, skills/plugins hubs, session switcher) route
through it; the grid-test FloatBox drops its own 24 floor. Contract-tested
including the sub-floor cap case from the review.

* fix(tui): reload.mcp lock scope + coalesced refresh + macOS polarity flip (Bugbot)

Three real findings from the review of the reload.mcp pooling + OSC-10 work:

- Lock released too early: the leader now holds _mcp_reload_lock across
  shutdown+discover AND its own agent refresh — releasing after discover let
  a second reload tear the registry down while the first was still reading it
  to rebuild the session's tool snapshot.
- Coalesced reload skipped the agent refresh: a follower returned
  "reloaded" without rebuilding ITS OWN session's snapshot, so a coalesced
  session kept stale tools. Followers now wait, then refresh their agent
  against the freshly-built registry (under the lock, skipping the redundant
  shutdown/discover). Shared _finish_reload tail for the `always` opt-out.
- macOS AppleInterfaceStyle fallback never set `resolved`, so a late OSC-10
  foreground reply could re-flip the committed inference (visible churn). It
  now marks resolved; a real OSC-11 background measurement still corrects it
  (that listener intentionally doesn't gate on resolved — measurement beats
  inference).

Bugbot's other four findings were diff-truncation false positives (color.ts,
themeBoot.ts, and the mcp_rev/light_colors/dark_colors types all exist;
typecheck + build green). 384 tui_gateway + TS suites pass.

* fix(tui): mcp_rev includes mcp_servers; reload survives leader failure; /theme persists first (Bugbot r2)

- mcp_rev hash now covers `mcp_servers` (the server DEFINITIONS the classic
  CLI watches) alongside `mcp`/`tools` — editing a server previously bumped
  mtime but not mcp_rev, so the TUI skipped reload.mcp and new servers never
  connected until a manual /reload-mcp.
- reload.mcp coalescing survives a failed leader: a completed-generation
  counter (bumped only after a successful shutdown+discover) gates the
  follower. If the leader threw (flapping server) the follower re-runs the
  full reload itself instead of returning a bogus success over an empty
  registry.
- /theme applies AFTER config.set confirms (mirrors /indicator) with a
  guardedErr catch — a failed persist no longer leaves the session showing a
  theme that reverts on restart.

384 tui_gateway + 1246 TS tests, lint, build green.

* fix(ui-tui): first-swap repaint + config-auto respects shell theme pin (Bugbot r3)

- commitTheme compares the first commit against the SEED theme uiStore
  mounted with (boot cache/default), not null — a cold start that resolves
  to a skin differing from the default previously skipped the anti-tearing
  forceRedraw on that first swap.
- applyConfiguredTuiTheme('auto') now clears only a pin CONFIG set (tracked
  via configPinnedTheme), never a HERMES_TUI_THEME the user exported in their
  shell — that env override outranks auto-detection per detectLightMode's
  documented priority, and a config hydrate was wiping it.

(Bugbot's recurring "GatewaySkin missing paired palette fields" is a
diff-truncation false positive — gatewayTypes.ts declares light_colors/
dark_colors, typecheck green.) 81 handler + build/lint green.

* fix: restore package-lock.json @esbuild platform entries (Bugbot r4)

The branch had stripped every node_modules/@esbuild/* optional-platform
entry from the lockfile (442 deletions, 0 additions) — collateral from an
earlier local @esbuild/darwin-arm64 reinstall that rewrote the lockfile to
this machine's platform only. esbuild still declares them as
optionalDependencies, so `npm ci` on Linux CI would skip the platform
binary and break Vitest / ui-tui builds. No dependency was added on this
branch (the only package.json delta is a dev `visual` script), so the
lockfile is restored to match main exactly.

* fix(ui-tui): seed mcp_rev baseline at startup so first cosmetic write doesn't reload MCP (Bugbot r5)

The startup config.get seeded mtimeRef but not mcpRevRef; after a normal
boot mtime is already non-zero so the poller's baseline branch never runs,
leaving mcpRevRef empty. The first /skin (or other cosmetic) write bumped
mtime with an unchanged mcp_rev, and empty !== hash tripped a full
reload.mcp — exactly the reconnect this optimization removes. Seed the
revision alongside mtime at boot.

* fix(ui-tui): record config theme pin even when env already matches (Bugbot r6)

Regression from the r3 shell-pin fix: applyConfiguredTuiTheme('light'|'dark')
returned early when HERMES_TUI_THEME already matched, leaving
configPinnedTheme false — so a later '/theme auto' refused to clear the pin
and the session stayed forced to the old mode while config said auto. Set
configPinnedTheme before the match short-circuit.

Bugbot's other three r6 findings (GatewaySkin paired-palette fields,
ConfigMtimeResponse.mcp_rev, picker maxWidth props) are diff-truncation
false positives — all declared in gatewayTypes.ts / the picker prop
interfaces; typecheck is green.

* feat(ui-tui): shimmer skeleton for the lazy tools section

The lazy-loaded Available Tools section rendered a BLANK gap that popped
when data landed. It now shows animated shimmer rows shaped like the real
content (label block + value run, diagonal band sweep), and the summary
line prints "… tools · … skills" instead of "0 tools · 0 skills" while
counts load.

components/loaders.tsx ships the primitives (shimmerSegments band math,
Shimmer, useShimmerPhase, ShimmerRows) — colors are caller-owned theme
tones, one interval per composition. Cherry-picked from the SDK-shape
branch so the skeleton lands with this PR; the widget-SDK consumers stay
in the follow-up.

* fmt(js): `npm run fix` on merge (#68938)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fmt(js): `npm run fix` on merge (#68976)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fmt(js): `npm run fix` on merge (#68977)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* test(mcp): stamp breaker-open time on the monotonic clock, not a literal (#69003)

test_session_expired_retry_waits_for_new_session hardcoded
_server_breaker_opened_at["hindsight"] = 123.0 to simulate a circuit breaker
whose cooldown has already elapsed. But the breaker in tools/mcp_tool.py
compares that stamp against time.monotonic() (age = monotonic() - opened_at,
elapsed when age >= _CIRCUIT_BREAKER_COOLDOWN_SEC). time.monotonic()'s origin
is arbitrary and small on a freshly-booted CI container, so age worked out to
only a few seconds there (< the 60s cooldown) — the breaker stayed open, the
half-open probe never fired, and the retry returned the "unreachable" error
instead of "bank ok". It passed on long-uptime dev boxes (large monotonic)
and failed under CI, with the reported "Auto-retry available in ~Ns" drifting
run to run as the container's monotonic clock varied.

Stamp opened_at relative to the same clock the code reads
(time.monotonic() - _CIRCUIT_BREAKER_COOLDOWN_SEC - 1.0) so the cooldown is
provably elapsed regardless of the monotonic origin, exercising the intended
half-open transition deterministically.

* fix(windows): share one bounded, tree-killing git probe across both call sites (#68997)

subprocess.run(["git", ...], timeout=...) deadlocks on Windows: run()'s
post-timeout cleanup calls an unbounded communicate() after killing git.
Killing the PATH-resolved launcher can leave a suspended descendant git.exe
holding duplicates of the captured stdout/stderr handles, so the pipes never
reach EOF and the reader-thread join blocks forever — leaking a process +
two reader threads per fired timeout (the accumulating git.exe load behind
Windows Defender CPU spikes).

Two fail-open probe call sites had this identical flaw:
  - tui_gateway/git_probe.py::run_git — on the Desktop agent-build path
    (_start_agent_build -> _session_info -> branch() -> run_git), where the
    hang turned an optional branch label into "agent initialization timed
    out" (#68609).
  - agent/coding_context.py::_git — hangs the agent turn inside
    build_coding_workspace_block under an ACP host (#66037).

Consolidate both onto one shared bounded_git_probe() in
hermes_cli/_subprocess_compat.py (both files already import from there, so
no new import surface):
  - explicit communicate(timeout), then on ANY failure a tree-kill —
    proc.kill() AND, on Windows, best-effort taskkill /T /F so the suspended
    descendant that holds the pipe writers dies too — plus a bounded 1s
    post-kill drain; if the pipes are still held they're abandoned (the
    orphaned reader threads are daemonic and cost nothing).
  - fail open to "" on every path: spawn error, timeout, kill() raising
    (access denied / already reaped — a raise inside the except handler
    previously escaped the contract), and non-timeout communicate() failures
    now also terminate the child instead of leaving it running.
  - the taskkill spawn can't re-enter the deadlock class: it captures no
    pipes (DEVNULL), so its own timeout cleanup has no reader threads to join.

Normal-path spawn contract is preserved byte-for-byte: PIPE/PIPE/DEVNULL,
text + utf-8 errors="replace", hidden-window creationflags on Windows only,
nonzero returncode -> "". Each call site keeps its own timeout (1.5s / 2.5s).

Supersedes #68622 (Sora-bluesky — git_probe fix + tree-kill) and #66038
(iamwongeeeee — coding_context fix), folding both into one shared helper so
the two sites can't drift and every timeout tree-kills the descendant. Tests
consolidated onto the helper, incl. the previously-missing assertion that a
Windows timeout escalates to taskkill /T /F.

Co-authored-by: Sora-bluesky <sora.bluesky.dev@gmail.com>
Co-authored-by: iamwongeeeee <wykim777@naver.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(tui): revision-aware reload.mcp — an ack now means the revision was LOADED

Review on #20379, finding 1 (High). Two ways an MCP config revision could
be silently acknowledged without ever being applied:

Client: the poll advanced its accepted mcp_rev BEFORE calling reload.mcp,
and quietRpc collapses failures to null — a reload that failed against a
temporarily broken server left the revision recorded as applied, and no
subsequent poll retried until an unrelated MCP edit. The handshake is now
syncMcpReload(): send the observed rev with the request, advance `accepted`
only when the server answers status=reloaded (to the server's loaded_rev,
falling back to the requested rev on older gateways), and re-compare on
EVERY poll tick — decoupled from mtime — so a transient failure heals on
the next tick. An in-flight guard stops the 5s poll from stacking requests
behind a slow reload.

Server: generation-only coalescing let a follower triggered by revision B
ack against revision A's registry when the config changed under a slow
leader. The leader now re-hashes the MCP-relevant config after discovery
and repeats until stable (bounded), records _mcp_reload_loaded_rev, and a
follower coalesces only when the revision it was asked to load matches —
otherwise it re-runs the full reload itself. Responses carry loaded_rev.

Deterministic tests for the exact failure sequences: failed reload → no
ack, no generation advance; A-then-B overlap → follower re-runs; matching
rev → coalesces; failed leader → follower re-runs; legacy no-rev callers
keep generation-only coalescing (thread ordering via an instrumented lock,
no sleeps). Client: 6 vitest cases on the ack/retry/in-flight contract.

* fix(ui-tui): boot theme cache gets provenance — a stale hint can't pin the terminal

Review on #20379, finding 2 (High). The boot cache seeded the previous
session's background into HERMES_TUI_BACKGROUND, the same slot a CURRENT
OSC-11 answer occupies — so a cache written on a light terminal pinned a
now-pure-black terminal to light forever: the new OSC-11 #000000 answer is
distrusted by design, the pure-white OSC-10 foreground is distrusted too,
and the macOS appearance fallback refuses to run while the slot is set.

Seeding now records provenance (themeBoot.seedBootEnvironment, extracted
and testable against a passed env). When the current terminal answers the
background probe with the untrusted fingerprint, the gateway handler calls
invalidateBootBackground(): the slot clears ONLY while it still holds the
seeded value (a trusted answer that overwrote it is authoritative), OSC-10
gets first claim in the same startup batch, and a short settle pass re-
derives from the live fallback chain if nothing answered.

The cache is also pin-coherent now: commitTheme persists the config mode
pin (display.tui_theme) alongside the resolved theme + physical background.
Previously "/theme light" on a dark terminal cached a light theme next to a
dark background — the next launch painted light, flipped dark when the skin
resolved against the seeded background, then flipped light again on config
hydration: the exact multi-stage flash the cache exists to eliminate. The
seeded pin counts as config-owned (bootSeededPin), so a later 'auto' can
still clear it instead of mistaking it for a user shell export.

Tests cover the review's sequences: stale-light cache vs current dark
terminal (invalidate → fallback chain), stale-dark vs ambiguous light,
pinned light on a dark physical background across restart (and the
inverse), trusted-overwrite protection, and the explicit-signal guards.

* fix(ui-tui): stacked dialog gets input before the grid under it

Review on #20379, finding 3 (Medium). /grid-test's `d` opens a dialog on
top without clearing the grid, but the grid's input branch ran FIRST — so
Esc/q/Enter mutated the hidden grid (close/unzoom/promote) instead of
closing the visible dialog, contradicting its "Esc/q/Enter close" hint.

Input routing now follows visual stacking: the dialog/grid dispatch is
extracted into handleStackedModalInput() with the dialog branch first, the
hook consumes through it, and tests drive the real dispatch against the
overlay store — each advertised close key closes only the dialog (grid
byte-identical), grid keys don't leak through while the dialog is up, and
the same keys route to the grid again after it closes.

* fix(ui-tui): make `npm run visual` portable off POSIX — zero new deps

Review on #20379, finding 4 (Medium). Three portability defects in the
visual verification harness:

- `FORCE_COLOR=3 COLORTERM=truecolor tsx ...` POSIX env assignment does not
  work under the Windows npm command shell. The script is now a plain Node
  launcher (scripts/visual/run.mjs) that sets the env itself and spawns tsx
  via require.resolve('tsx/cli') — no cross-env, no shell syntax.
- Hardcoded /tmp/tui-visual.{html,png} resolve to a drive-root path like
  C:\tmp on native Windows (and fail when that directory doesn't exist).
  Both scripts now derive the output directory from a shared paths.mjs
  helper: os.tmpdir()/hermes-tui-visual (created recursively;
  HERMES_TUI_VISUAL_DIR overrides for CI or side-by-side runs).
- electron was undeclared by ui-tui and only worked via hoisting luck. The
  launcher now resolves it EXPLICITLY from the install tree the desktop
  workspace already provides (require('electron') in plain Node returns the
  binary path), with an ELECTRON_BIN override and a clear error pointing at
  the repo-root install when it's absent — instead of declaring a second
  ~100MB dependency on a TUI workspace for a dev-only harness.

Also fixes the trailing-whitespace line in render.tsx that `git diff
--check` flags. Verified end-to-end: render writes the HTML scene sheet
and the electron shot step produces the screenshot from the tmpdir path;
the missing-electron error path prints the guidance message.

* perf(ui-tui): shimmer loaders share one clock and stop after a bounded period

Review on #20379, finding 5 (Perf). Every ShimmerRows mounted its own 90 ms
setInterval — the session panel can show lazy skills AND lazy tools at
once, and a lazy watch session stays lazy indefinitely, so an otherwise-
idle TUI ran ~22 React state updates per second forever.

All shimmer compositions now subscribe to a single module-level clock: one
interval regardless of how many skeletons are on screen, updates delivered
in one timer callback so React batches them into a single render pass, and
the interval is torn down with the last subscriber. Each mount's animation
is also bounded (SHIMMER_ANIMATE_MS, 30 s): after the budget the skeleton
freezes in place — it still reads as "loading" — and stops costing renders
entirely.

Tests: fake-timer coverage that N subscribers share one timer in lockstep,
the interval stops with the last unsubscribe, and a late subscriber
restarts the clock cleanly.

* fmt(js): prettier pass on the new review tests

* chore: gitignore node_modules symlinks, not just directories

Worktrees symlink node_modules to the main checkout; the dir-only
node_modules/ pattern doesn't match symlinks, so one slipped into a
commit and broke npm ci on CI (ENOTDIR). Dropping the trailing slash
matches both.

* fix(desktop): stop long-session transcript from drifting to old turns (#69019)

content-visibility:auto on turn groups (perf: off-screen turns skip
style/layout/paint) pairs with contain-intrinsic-size:auto, which only
remembers a turn's size after it renders. A turn that finished streaming
near the bottom had its smaller mid-stream size remembered; once it
scrolled off the top edge and got skipped, it collapsed to that stale
height. With overflow-anchor:none the viewport can't self-correct, so the
stick-to-bottom lock drifts and the view creeps up over older turns — the
'long session eventually shows old responses' visual glitch.

Exempt the newest turns (live tail) from virtualization so a turn is only
ever skipped after its layout has settled at its final size (remembered ==
real -> skipping changes no height). Off-screen older turns still skip, so
the dialog/popover whole-document recalc win on long transcripts is kept
(it scales with the hundreds of old turns, not the small tail).

* ci: retrigger (transient setup-uv manifest fetch flake)

* feat(ui-tui): widget-app SDK — registry, host, dispatch; demos become apps

The SDK the desktop app already has, ported to the TUI: a WidgetApp contract
(id/help/mode/init/reduce/render/usage), a registry, and a host that owns the
active widget, routes input to its reducer, and renders it. The grid-test and
dialog-test debug surfaces are reimplemented as widget apps instead of bespoke
overlay state, and slash commands are generated from the registry. Input for an
open widget is owned by the active app (supersedes the demo-only stacked-modal
routing) — the single active widget enforces topmost-owns-input structurally.

* feat(ui-tui): weather reference app — the async-data contract, themed ASCII art

/weather [location]: wttr.in current conditions behind a Dialog, art bucket
table-driven off WWO weather codes, every tint a theme family tone (sun =
primary, rain = shell blue, thunder = warn). Proves the async story the
demos don't: init returns a loading phase and fires the fetch; results land
through the new host.updateWidget, which patches state ONLY while the app
is still active — a late resolution can never resurrect a closed app or
clobber a different one. `r` refetches; Esc/q/Enter close.

Four async-contract tests (loading→ready via updateWidget, late-resolution
guard, error phase, keymap). 1253 TS tests green.

* feat(ui-tui): ambient widget mode — registry-driven slash catalog + in-flow dock

Widgets can render as ambient (glanceable, non-blocking) instead of modal,
docked in the normal layout flow above/below the status bar rather than taking
over the screen. The slash catalog is generated from the widget registry so new
apps surface automatically, and /ticker lands as the first live-animation
ambient demo.

* feat(ui-tui): self-authored widgets — user-widget loader, hot-load, skill

Hermes can write its own widgets: a loader discovers $HERMES_HOME/tui-widgets/*.mjs,
fs.watch hot-loads them the moment they land (no restart), and a tui-widgets skill
teaches the agent the contract and the openWidget-at-register auto-open recipe.
Load/error/remove events announce themselves in the transcript; a lazy intro
skeleton covers the first paint.

* feat(ui-tui): widget primitives — charts, accordion, shimmer, stable streams

Reusable render primitives the SDK exposes to widget authors: sparkline/gauge/
hbars chart helpers (dimension-stable so live updates never resize the card),
an Accordion for expand/collapse sections, animated shimmer loaders, and a
streams demo that no longer reserves a phantom icon column on unfocused titles.

* docs(skill): tui-widgets — auto-open recipe (openWidget at end of register)

* feat(ui-tui): ambient zone system + widget crash boundary

A full placement grid so the agent can put a widget where it asks — dock-top/
bottom and corner zones, with corners as reserved rails that take real space
instead of floating over content. A per-widget error boundary plus lenient
ShimmerRows means generated widget code can't crash the TUI.

* refactor(ui-tui): host placement router + grid-test width-floor fix

host.tsx collapses to one placement router over a shared render context, and the
grid-test app drops its width floor too (carrying the #20379 review rule). Final
formatting pass folded in.

* feat(themes): cross-surface theme SDK — one skin themes CLI, TUI, and desktop

Make the Python skin engine the single source of truth for a canonical theme
shape consumed by every surface, so a skin authored in $HERMES_HOME/skins/*.yaml
(by a user or by Hermes from a prompt) themes the CLI, TUI, and desktop GUI at
once — the theme analogue of the plugin SDK.

- @hermes/shared: canonical `HermesSkin` token shape + `SKIN_COLOR_TOKENS` enum,
  consumed by both TS surfaces (TUI `GatewaySkin` and desktop dedup onto it).
- Desktop: `skinToDesktopTheme` resolver (skin → CSS-var palette, VS Code-style
  derive-from-seed) + `backend-sync` that registers backend skins into the theme
  registry (Appearance/Cmd-K/`/skin`) and applies on a real change. Seeds on
  gateway.ready (never stomps a persisted pick), applies on skin.changed and the
  post-turn `config.get skin` poll (catch-all for agent-edited config.yaml).
- TUI: `fromSkin` now maps the status bar + `background` keys it was dropping.
- Gateway: `config.get skin` also returns the full resolved palette (additive).
- Skill: `hermes-themes` teaches the agent to author + activate a skin.

Each surface keeps its own normalizing resolver (ansi for the TUI, CSS vars for
the desktop, prompt_toolkit/Rich for the CLI).

* fix(themes): activate skins via `hermes config set`, never a config.yaml hand-edit

The skill told the agent to `patch` display.skin into config.yaml; a stray indent
corrupts the file and breaks the live gateway (the reported "/ menu broke"), and
a raw file edit never live-applies in a running CLI/TUI ("nothing happened").
Route activation through the safe writer (`hermes config set display.skin`), and
state plainly that a tool call can't hot-switch a running CLI/TUI — the user runs
`/skin <name>` (desktop still auto-repaints on the next turn).

* feat(themes): agent-authored skins switch live via a gateway skin watcher

A skin Hermes activates (`hermes config set display.skin X`) or recolors in
place now goes live on every surface (CLI, TUI, desktop) within ~half a
second, on its own — no `/skin`, no tool-hook timing, no user action.

A gateway daemon polls the resolved skin signature `(name, active-file mtime)`
every 0.5s and broadcasts `skin.changed` on any real move — a name switch OR a
live color edit to the active skin. It routes through the SAME path `/skin`
uses, so all surfaces repaint identically. The watcher seeds its baseline at
gateway.ready (stdio + ws) so it only fires on a real change; the `/skin` RPC
seeds the baseline too so it never double-broadcasts.

Subsumes the desktop's post-turn `config.get skin` poll (its skin.changed
handler already applies).

* feat(themes): TUI paints its own background from the skin (OSC 11)

The TUI inherited the terminal's background; now a skin's `background` paints the
whole surface via OSC 11 when a skin is applied, and clears back to the terminal
default (OSC 111) on revert and on exit (ridden in through resetTerminalModes).
Opt-in: a skin with no `background` leaves the terminal untouched, and the
restore only fires if we actually painted. Desktop already themed its own bg;
this closes the loop so Hermes owns its background on every surface.

* feat(themes): element tokens (ui_tool, ui_thinking) + skinnable diffs

Theming was semantic-only: the gold tool `●` was `accent`, shared with
headings/links/chevrons, so "recolor tool calls" was impossible and the agent
had no key to point at. Add `ui_tool` (● + tool spinner) and `ui_thinking`
(reasoning body) tokens that fall back to accent/muted — defaults unchanged,
but now independently settable. Make diffs skinnable too (`diff_*`), which
fromSkin previously hardcoded. Document the full element→key map in the skill so
Hermes knows which knob turns what.

* fix(themes): tweak the ACTIVE skin in place, never fork default

Changing one color ("make the tool ● cyan") forked `default` — which has no
`background` — so applying it reset the terminal to its own (black) default and
dropped the active skin's palette. Teach the skill to edit the active skin's file
in place for a tweak (watcher repaints on the mtime bump), and to fork a built-in
only by carrying its full palette. Hard pitfall: never fork `default` for a tweak.

* feat(themes): `hermes skin set` — deterministic one-color tweak, bg untouched

Changing a single color kept wrecking the rest because the agent hand-authored a
new skin (often from `default`, which has no `background`, resetting the terminal
to black). Add `hermes skin set <key> <hex>`: edits the ACTIVE skin's one key in
place (a built-in is forked into an editable copy carrying its full palette), so
everything else — background included — is preserved. Plus `skin use` / `skin
list`. The skill now points tweaks at this command instead of hand-authoring.

* feat(themes): dedicated code-syntax palette keys

Code highlighting reused brand tokens (accent/text/border/muted), so it couldn't
be themed independently. Add syntax_string/number/keyword/comment skin keys →
syntax* theme tokens (defaulting to those brand tokens, so defaults are
unchanged) and point the highlighter at them. Documented in the element→key map.

* test(themes): E2E live skin switch — config write → skin.changed broadcast

* fix(themes): reconcile element/syntax tokens with main's derive+adapt pipeline

Element tokens (ui_tool/ui_thinking), skinnable diffs, and code-syntax keys
flow through buildPalette → adaptColorsToBackground instead of a hand-mapped
color block, so they inherit #20379's contrast/polarity machinery. thinking
and syntaxComment track the EFFECTIVE muted (banner_dim override included);
the skin's `background` feeds the surface (it also paints the terminal via
OSC 11); statusFg falls back through ui_text/banner_text. Tests assert the
routing/independence contracts rather than pre-adaptation hexes.

* fix(themes): apply a runtime switch back to default on the desktop

ingestBackendSkin returned early for name === 'default' even when
apply=true, so a real runtime switch to the default skin (/skin default
on CLI/TUI, or config.set display.skin=default) emitted skin.changed but
never repainted the desktop. 'default' is no-opinion on the PALETTE (the
desktop keeps its own nous default, so we still never register a converted
theme under it), but it IS a valid apply TARGET: setTheme normalizes
'default' -> nous, so switching back repaints to the desktop default.
Skip only the registry step for 'default' and let it flow through the
apply guard. Addresses Copilot review.

* fix(tui_gateway): serve candidate-inclusive display on warm/live resume

#65919 persists verification candidates (finish_reason=verification_required
/ verify_hook_continue) to state.db but collapses them out of the in-memory
model history via repair_message_sequence. The eager session.resume + REST
paths read the verbatim display lineage (candidate present), but the
warm/live-reuse payload (_live_session_payload) built its user-visible
messages from the collapsed in-memory model history — so switching to a
still-live session dropped the substantive verification answer that a cold
resume of the SAME session showed. That divergence is the cross-session
"substantive text vanishes on switch" class, and the direct sibling of the
resume-duplication regression fixed in #68149.

Reconcile the persisted display lineage (candidate-inclusive, the same
get_messages_as_conversation(..., include_ancestors=True) read the eager
resume + REST paths use) with the fresh in-memory tail in
_live_visible_history, so all three surfaces agree by construction while a
not-yet-flushed live turn is still shown. Extracted
_reconcile_display_with_live as a pure, DI-testable function (anchors on the
last persisted row's (role, text); appends only the uncovered in-memory tail;
trusts the DB display when the tail can't be anchored).

Tests: unit coverage for candidate-inclusion, freshness, empty/raising-DB
fallback, and the combined candidate+fresh-tail case. The existing freshness
guard (test_session_resume_live_payload_uses_current_history_with_ancestors)
stays green.

* fix(tui_gateway): candidate-inclusive display on child-watch resume + E2E

Complete the #65919 warm/live-payload fix across its sibling path and add
real-SessionDB cross-builder coverage.

- Child-watch (lazy) resume: the delegated-subagent watch window served
  _history_to_messages(repaired_history) for its user-visible messages, which
  collapses out persisted verification candidates just like the warm-payload
  path did. Build the visible messages from the verbatim child-only display
  projection (repair_alternation=False) while the repaired history still feeds
  live replay; fall back to the repaired history if the display read fails.

- E2E cross-builder consistency (real SessionDB, not mocks): a persisted
  verification candidate is collapsed out of the model projection but kept in
  the display projection, and _live_visible_history now equals the eager
  session.resume display projection (candidate present). Adds the combined
  candidate + fully-flushed-second-turn case and a lazy child-watch handler
  test that asserts the candidate survives in resp["result"]["messages"].

* fix(cli): add skin to _BUILTIN_SUBCOMMANDS for plugin gating

The new hermes skin subcommand must be declared so startup plugin
discovery can skip when the user targets it.

* fmt(js): `npm run fix` on merge (#69048)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* feat(desktop): Billing page revamp — current-plan card, in-app plans view, tier art (#68722)

* feat(desktop): revamp Billing page — plan card, in-app plans view, tier art

Reshape the desktop Billing settings per wayfinder ticket 09. New page order:
Plan → Payment → One-time top-up → Automatic refill → Usage, with the at-a-glance
summary strip unchanged at the top.

- CurrentPlanCard replaces the old Subscription row: tier name + price + renewal
  and at most one button — "View plans" (free/no-sub + can_change_plan),
  "Change plan" (subscriber + can_change_plan), or none for teams / non-changers.
  Teams keep the portal "Adjust plan ↗" link so they are not stranded. The button
  navigates in-app to the plans sub-view.
- bview=plans sub-view mirrors the settings pview/kview pattern (useRouteEnumParam,
  default overview). BillingPlansView renders a grid of PlanCard from live tiers[]
  (is_enabled, sorted by tier_order, free tier included).
- PlanCard: tier art + name + $/mo + monthly credits as dollars ("$110 credits/mo").
  Current tier is highlighted + inert; higher/no-current tiers get "Choose ↗"
  (opens portal with plan=<tierId>); lower tiers are a DISABLED "Downgrade" with a
  caption — downgrades move in-app in ticket 11 (gateway pending-change flow), so
  this PR intentionally links them out/disabled rather than wiring the money path.
- buildManageSubscriptionUrl gains an optional third arg (tierId) → appends
  plan=<tierId>. Signature kept identical to draft PR #68666 for a trivial rebase;
  NAS #748 validates the param server-side.
- Tier art: four NAS hero webps rendered as ~40px thumbnails over a Nous-blue well
  with per-tier blend modes (the only place Nous blue appears). Keyed by lowercase
  tier NAME (free/starter→connect, plus→memory, super→automation, ultra→sandbox);
  unknown name → text-only card. Imported via vite static imports for packaged
  file:// + webSecurity.
- Top-up vs auto-refill disambiguated by section label + first sentence: "One-time
  top-up" / "Buy credits now" vs "Automatic refill" / "Refill when low" (configured
  copy reads "Charges $X automatically when your balance falls below $Y.").
- Variant-A auto-refill editing: Manage swaps the row's left side (caption → the two
  $ fields with a pre-allocated error line) and the action column (Manage → Save/
  Cancel) in place, with the row height reserved for the tallest state so the Usage
  section never shifts. Fixes the spurious on-open validation error (errors now show
  only after an edit or a save attempt). Save/disable API calls + confirm-disable
  flow unchanged.
- Remove subscriptionTierChips and the subscription-row chips; reshape (not delete)
  deriveBillingView to expose plan + tiers. Buy-credits row keeps the chips seam.
- Dev fixtures: add free-personal and subscriber-personal (personal orgs, full
  4-tier Free/Plus/Super/Ultra catalog) so the plans view is exercisable.

Tests: update/extend index.test.tsx + use-billing-state.test.ts, add tier-art.test.ts;
delete the old chips tests. Desktop billing suite 70/70 green, typecheck clean.

* fix(desktop): mark the free/lowest tier current (not an upgrade) when there is no subscription

Visual verification caught a spec-fidelity bug: in the plans grid, an account with
no active subscription rendered the Free tier ($0/mo, tier_order 0) as a "Choose ↗"
upgrade — clicking would deep-link the portal to "subscribe to Free".

Ruling: current-card = tier.is_current OR (subscription.current == null AND the tier
is the lowest-order / $0 tier). derivePlanTiers now falls back to the lowest-order
tier as the stand-in current plan when there is no subscription, so the free card
renders exactly like is_current (inert, "Current plan") and — being the lowest order
— no tier can be a downgrade; every paid tier is a "Choose ↗" upgrade.

CurrentPlanCard is unaffected (still "Free" + "View plans"); subscriber-personal is
unchanged (Free stays a disabled Downgrade below the current Plus tier).

Tests: free-personal grid now asserts Free = current/inert, no downgrade state, three
Choose buttons; text-only unknown-tier test gains a free tier so the unknown paid tier
is unambiguously an upgrade. Billing suite 70/70 green, typecheck + lint clean.

* chore(desktop): shrink bundled tier art to 128px thumbnails

The plan-card wells render the art at ~40px; shipping the full landing
images added 2.7 MB to the repo for no visible difference. 128px covers
2x displays; total is now 26 KB.

* fix(desktop): address 6 adversarial-review findings on the Billing revamp

1. Grandfathered current tier (BLOCKER). NAS marks a grandfathered current tier
   is_enabled:false; the enabled-only filter dropped it, leaving currentOrder
   undefined so every lower tier rendered as an actionable "Choose ↗". derivePlanTiers
   now resolves current identity/ordering against the UNFILTERED tiers and keeps the
   grandfathered current tier in the grid as the inert "Current plan" card; downgrades
   classify against its tier_order. (Non-current disabled tiers are still dropped.)

2. Dead plan-card button. derivePlanCard offered "View plans"/"Change plan" purely on
   can_change_plan, but the grid could be empty / current-only and showPlans refused,
   so the button no-oped. It now offers the in-app action ONLY when the grid has ≥1
   actionable (non-current) tier; otherwise it falls back to the portal link.

3. Deep-link bypass. showPlans now gates on the same capability that renders the button
   (view.plan?.action), so a team / non-changer deep-linking bview=plans always falls
   back to overview instead of a grid of live Choose buttons.

4. Lost portal escape hatch. Whenever the card has no in-app action (teams, non-changers,
   refused subscription, empty catalog) it now ALWAYS carries the "Adjust plan ↗" portal
   link built from subscription?.portal_url ?? billing.portal_url — the refusal caption
   no longer promises a portal the UI didn't render.

5. Choose URLs dropping org_id/plan. (a) derivePlanTiers now threads billing.portal_url
   as the fallback base for the Choose URL. (b) buildManageSubscriptionUrl treats the
   hard-coded FALLBACK_PORTAL_BILLING_URL as a last-resort ORIGIN (applying org_id/plan)
   instead of a bare return, so a null portal_url never strips the routing params.

6. Zero-shift on narrow panes. Replaced the magic min-h-28 (under-reserved once the two
   inputs stack below @2xl) with exact reservation: the edit form is always rendered and
   both states share one grid cell ([grid-template-areas:'stack']), invisible+aria-hidden
   when not editing — the row equals the tallest state at every width, no breakpoint math.
   The refusal stays inside the reserved layer.

Tests: +12 (grandfathered current, no-dead-button + empty-catalog portal link, team &
personal deep-link fallback to overview, billing.portal_url-backed Choose URL, fallback
org_id/plan, reserved-form-mounted); updated the two portal-link expectations for §4.
Billing suite 78/78 green; typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): reuse the shared openExternalLink helper in the plans view

* fix(desktop): honor the auto_reload wire contract — null card + disable amounts

A full-stack contract sweep (desktop ↔ shared types ↔ gateway ↔ NAS) surfaced two
real desktop bugs in the auto-refill row:

A. auto_reload.card can be null. The gateway's _parse_auto_reload_card returns None
   for a missing/unknown-kind card and _serialize_billing_state emits `card: null`,
   but the shared BillingAutoReload.card union had no null arm and use-billing-state
   dereferenced `autoReload.card.kind` bare — a crash on the enabled path. Add `| null`
   to the shared union (contract honesty) and guard the read (`card?.kind`); null now
   falls through to the default enabled path, same as a canonical card.

B. Disable was rejected by the gateway. billing.auto_reload unconditionally requires
   threshold + top_up_amount, so `updateAutoReload({ enabled: false })` came back
   invalid_request. (The TUI always sends both; desktop fixture mode stubbed it.)
   disable() now sends the current threshold_usd/reload_to_usd from the autoReload
   prop alongside enabled: false, matching the TUI.

Tests: enabled auto_reload with card:null renders the normal enabled row (derivation
+ render, no crash); disable call carries both current amounts. Billing suite 80/80
green; typecheck (app/electron/e2e) + lint clean.

* fix(tui): guard the nullable auto_reload card in the auto-reload screen

The shared BillingAutoReload.card union gained its honest null arm (the
gateway emits card: null for a missing/unknown card); the TUI's only bare
dereference follows the same default path as a canonical card.

* fix(desktop): align billing inputs to the sm control height

The three billing inputs used an ad-hoc h-8 (32px) next to size=sm
buttons (24px). They now use the control system's size=sm with a
py-[3px] compensation for the input's real 1px border — buttons draw
theirs as an inset shadow, so sm alone still sits 2px taller. All five
controls in the buy row now measure 24px.

* fix(desktop): plan-card actionability + billing view-model hardening

Code-quality review of the Billing revamp (PR #68722).

BLOCKING — a top-tier subscriber (only downgrades/current below them) opened a
plans grid with zero enabled actions AND no portal link. The plan card gated its
in-app button on `tiers.some(state !== 'current')`, which counts the (disabled)
downgrade tiles. It now gates on an actual UPGRADE being present
(`capable && tiers.some(state === 'upgrade')`); with no upgrade the card falls back
to its "Adjust plan ↗" portal link, and the bview=plans deep link (gated on the same
plan.action) falls back to overview.

Reviewer structural items:
- One "plans capability" verdict (personal + can_change_plan + subscription ok) is
  derived once in deriveBillingView and threaded to BOTH derivePlanCard and
  derivePlanTiers; the grid only mints upgrade actions when capable, so the invariant
  lives in one place.
- BillingPlanTierView is now a discriminated union (`current` | `downgrade` w/
  disabledCaption | `upgrade` w/ required action), and BillingPlanCardView is an
  action-XOR-link union — deleting the `tier.action?.url ?? ''` and `plan.link?.url`
  defensive branches in the consumers.
- `findCurrentTier(subscription)` replaces the repeated is_current||id predicate at
  its three sites (plan card price, grid ordering, summary plan line).
- BillingView exposes named `paymentRow` / `topupRow` / `refillRow` instead of an
  `accountRows[]` + three `.find(id)` lookups.
- The auto-refill row that edits in place carries an explicit `manageInApp: true`;
  AutoReloadRow keys off it instead of sniffing the action label/url.
- tier-art header comment no longer cites an internal repo path; dead `?.` removed
  from RowValue (via a destructured const) and the plan-card link handler.

Behavior is identical except the blocking fix. Billing suite 82/82 green; typecheck
(app/electron/e2e) + lint clean.

* refactor(desktop): adopt inline-review nits on the billing plan card

Resolves the inline suggestion threads:
- plan-card gate reads a named `hasActionableTier` = "a tile carries an action"
  (union-safe `'action' in tier`, equivalent to the old upgrade-only check).
- re-narrow link/action inside the click callbacks (`plan.link && …`,
  `tier.action && …`) rather than relying on outer narrowing.

Behavior unchanged; billing suite 82/82 green, typecheck + lint clean.

* fmt(js): `npm run fix` on merge (#69050)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split (#68689)

* feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split

Bring the plain (non-TUI) CLI billing surface to parity with the desktop/TUI
billing changes:

- /subscription on Free (admin/owner, interactive) prints the plan catalog
  (name · $/mo · $credits/mo, from the same tiers[] data the TUI uses; monthly
  credits render as dollars). A numbered pick opens the manage-subscription
  deep-link directly with plan=<tier_id> appended.
- subscription_manage_url(state, tier_id=...) appends plan=<tier_id> (the stable
  tiers[] id) when a tier was picked, org_id first — mirrors the TUI's ?plan=.
  The paid change flow's blocked/unknown-preview portal fallback carries plan=
  for upgrades only; downgrades stay generic/native.
- /topup overview splits one-time top-up from automatic refill, the distinction
  stated in each first sentence ("Add funds now — a single charge…" vs "Refill
  when low — charges … automatically …"), keeping "credits" out of the
  dollars-only surface.
- Downgrades remain native (chargeless scheduled change), unchanged.

Updates the CLI-parity section of docs/billing-lifecycle.md and tests under
tests/hermes_cli + tests/agent.

* refactor(billing): share plan-catalog helpers + harden manage-url builder

- subscription_manage_url now preserves unrelated portal query params (parse_qsl,
  popping only the contract-owned org_id/plan) and restricts to http/https schemes,
  matching the desktop URL builder — the function owns the contract.
- Lift the plan-catalog derivation into agent/subscription_view.py so the CLI Free
  catalog and the paid picker/blocked-preview branch share one implementation:
  selectable_tiers (enabled paid, not current, sorted), format_tier_row (name · $/mo
  · $credits/mo — thousands-grouped like the TUI's toLocaleString, credits suffix
  hidden when absent/zero), and is_upgrade(state, tier_id).

* fix(cli): numbered pick, canonical guarded browser opener, partial auto-refill copy

- Free catalog: accept a bare digit as a pick (the shared normalizer only knows the
  confirm-dialog digit aliases, so `1` used to resolve to None → "Cancelled"). The
  Nth digit maps to the Nth printed row.
- Extract one _open_url_in_browser used by every "open the portal" path, applying the
  device-code flows' console-browser / remote-session guard (webbrowser.open returns
  True even for lynx/w3m over SSH) and returning whether a real browser opened.
- Consume the shared selectable_tiers / format_tier_row / is_upgrade helpers from the
  Free catalog, the paid picker, and the blocked-preview branch.
- /topup auto-refill copy: the concrete "charges $X … below $Y." sentence only when
  both amounts are present and finite; otherwise the generic sentence.

* docs(billing): correct CLI-parity rows (drop cross-repo ref, downgrade invariant)

Remove the other-repo PR reference from the manage-URL row, and state the real
downgrade invariant: a blocked downgrade may print the generic manage URL but never
carries plan=<tier_id> — selected-tier deep-links are reserved for new subscriptions
and upgrades.

* feat(desktop): native in-app downgrade — chargeless preview → schedule → undo (#68761)

* feat(desktop): native in-app downgrade (chargeless preview → schedule → undo)

Ticket 11, stacked on the Billing revamp (ticket 09). Downgrades no longer bounce
to the portal — picking a lower tier runs the gateway pending-change flow in-app;
the scheduled state renders on the plan card with an undo. Upgrades keep the portal
deep link.

- api.ts: add previewSubscriptionChange / scheduleSubscriptionChange /
  resumeSubscription wrappers over subscription.preview|change|resume
  ({subscription_type_id} / {}), typed via SubscriptionPreviewResponse +
  BillingMutationResponse (now re-exported from types.ts).
- use-subscription-change.ts (new): useDowngradeFlow (preview → confirm → schedule,
  refetch + onScheduled on success; typed refusals surface via the shared
  BillingRefusalInline, so insufficient_scope drives the existing step-up exactly
  like the auto-reload save, retried in place) and useResumeFlow (confirm-less undo).
  Both accept a `simulate` switch so DEV fixtures click through with canned success.
- plans-view.tsx: downgrade tiles are now an actionable "Downgrade" that opens an
  in-card preview → confirm panel (mirrors the TUI confirm copy: "…takes effect
  <date>. No charge now; you keep your current plan until then."). The scheduled
  downgrade target renders an inert "Scheduled" marker; other lower tiers stay
  actionable (picking one reschedules).
- CurrentPlanCard: when a downgrade is pending, the caption reads "Changes to
  <tier> on <when>." with an inline Undo → resume → refetch. One line, no jumps.
- use-billing-state.ts: BillingPlanTierView gains a `scheduled` state (and drops the
  ticket-09 disabled-downgrade caption); derivePlanTiers matches the pending target
  by name (NAS sends no id for it) before the downgrade branch; BillingPlanCardView
  gains `pending`, derived from current.pending_downgrade_* .
- inline-feedback.tsx (new): extracted openExternal / BillingRefusalInline /
  StepUpInlineAction / InlineMessage so the plans view reuses the step-up-aware
  refusal renderer without a circular import; openExternal now delegates to the
  canonical @/lib/external-link opener.
- dev-fixtures.ts: add `pending-downgrade` (subscriber-personal on Plus with a Free
  downgrade scheduled for Aug 15) for the plan-card pending state + grid marker.

Tests (+16 → 94 green in the billing suite): api wrappers (preview/change/resume +
insufficient_scope refusal); view derivation (pending plan-card state, scheduled
grid marker); confirm flow (preview shown, change called with the right tier_id,
refetch on success, schedule refusal → step-up affordance); undo flow; the
use-subscription-change hooks (preview-refusal retry, cancel, simulate path).
Updated the ticket-09 downgrade tests for the now-actionable tile. typecheck
(app/electron/e2e) + lint clean.

PR (later): base sid/desktop-billing-revamp; retarget to main after #68722 (09) merges.

* fix(desktop): format downgrade credits delta as signed dollars

The downgrade preview rendered the raw wire string ("Monthly credits change:
-88."), violating the "monthly credits are DOLLARS" ruling. NAS sends
monthly_credits_delta as a bare decimal; format it as signed dollars through the
same money formatter ("−$88/mo", sign preserved, abs value formatted). Zero /
absent still hides the line.

Adds formatMonthlyCreditsDelta (exported) + unit tests (negative/positive/zero/
absent) and asserts the rendered "Monthly credits change: −$88/mo." in the confirm
flow. Billing suite 99/99 green; typecheck + lint clean.

* fix(desktop): downgrade flow hardening — concurrency guard, a11y, DEV-gated sim

Addresses the adversarial review of the native-downgrade diff.

- Concurrency: useDowngradeFlow exposes `mutating` (true only while the schedule
  RPC is in flight). While a change commits, every other Downgrade tile and the
  Back button are disabled; the active panel's Confirm/Cancel already lock. The
  plan-card Undo blocks on its own resume via `busy`. (The server also 409s
  overlapping per-org mutations — this is UI honesty, not the only defense.)
- Accessibility: the confirm panel is role="status" aria-live="polite" and takes
  focus on open (tabIndex=-1 container); closing it returns focus to the tile card,
  so keyboard focus is never stranded and the async preview text is announced.
- DEV-gated simulation: the canned preview/change/resume seam is ignored unless
  import.meta.env.DEV, so a production build never takes the simulated branch even
  if a `simulate` prop leaks through.
- Comments: documented the deliberate manual-retry-after-step-up (no auto-replay,
  matching auto-reload) and that name-matching the scheduled target is safe because
  SubscriptionTypes.name is @unique in NAS.

Tests (+5 → 104 green in the billing suite): mutating exposed only during schedule;
simulate ignored outside DEV; other downgrade tiles + Back disabled mid-schedule;
Undo disabled mid-resume; confirm panel role + focus on open. typecheck
(app/electron/e2e) + lint clean.

* fix(desktop): scheduled cancellations, downgrade-flow concurrency, inline nits

Addresses the native-downgrade review threads.

Scheduled cancellations were invisible (NEW review item). subscription.current
carries cancel_at_period_end + cancellation_effective_* and subscription.resume
clears cancellations exactly like downgrades, but the pending-transition helper only
read pending_downgrade_*, so a portal/TUI-scheduled cancellation rendered as a plain
renewal with no Undo. The pending state is now a union — { kind:'downgrade', tierName,
when } | { kind:'cancellation', when } — computed once in deriveBillingView and
threaded to BOTH the plan card and the grid. The card reads "Cancels on <date>." with
the same Undo (resume); the grid shows a Scheduled marker only for downgrades (a
cancellation has no target tier). Precedence: a downgrade wins if both fields are set
(it names a concrete target — the stronger signal), commented at the helper. Adds a
`pending-cancellation` fixture + tests (card copy, undo wiring, no grid marker,
downgrade-wins precedence).

Concurrency: confirm() takes a synchronous scheduling ref (mirroring useResumeFlow)
so two same-tick clicks — before React commits busy='schedule' — cannot fire two
schedule RPCs; the ref clears on every exit (simulated/stale/refusal/success).
useResumeFlow reorders its unlock: a refusal releases immediately, a success holds
runningRef/busy THROUGH the refetch so Undo never re-enables against the still-pending
card. Test: a synchronous double-activation fires one schedule RPC.

Inline nits: re-narrow link/action inside the click callbacks (`plan.link && …`,
`tier.action && …`) instead of relying on outer narrowing / `?? ''`.

Billing suite green (109); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): move DEV billing simulation behind the api seam

The fixture simulation lived as `simulate` / `simulateResume` prop drills and
`if (simulated)` branches inside the flow hooks, and it could not actually produce
the state it advertised (a simulated schedule never showed the pending card).

Replaced with `createSimulatedBillingApi(fixture)` — a fully in-memory BillingApi
built once, DEV-gated, in BillingSettingsWithDevFixtures where the fixture is known,
and supplied to the whole subtree via a new `BillingApiProvider` (context override on
`useBillingApi`; `null` = the real gateway api). It serves fetches from a mutable copy
of the fixture and its subscription-change mutations WRITE that copy's pending state:
schedule sets a pending downgrade, resume clears a pending downgrade OR cancellation.
Fixture mode now flows through the SAME react-query path (fetch short-circuit deleted;
queries always enabled; an effect refetches on fixture switch), so the click-through
genuinely progresses — schedule → pending card + Undo + Scheduled marker, undo → cleared.

Deleted `SubscriptionSimulation`, `simulationEnabled`, both prop drills, and every
`if (simulated)` branch — the hooks are now production-pure. Added a test driving the
full simulated loop (schedule → pending appears → resume → cleared), plus cancellation
undo and no-shared-mutation coverage. Removed the now-obsolete simulate hook tests.

Billing suite green (110); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): extract billing row/card components out of index.tsx

Purely mechanical, no behavior change: split the settings billing route file
(1065 → 593 lines) into focused siblings now that the downgrade feature has settled
their final shape.

- billing-amounts.ts — the dollar parse/format/validate/clamp helpers.
- account-row-value.tsx — RowValue (shared by AccountRow + AutoReloadRow).
- current-plan-card.tsx — CurrentPlanCard.
- auto-reload-row.tsx — AutoReloadRow (the in-place auto-refill editor).

index.tsx keeps the page shell, AccountRow dispatch, BuyCredits flow, and the fixture
wiring. Billing suite green (110); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): tighten the downgrade flow — phase union, previewMessage, tidy shared modules

Polish that composes with the api-seam rework:

- ActiveDowngrade's four nullables become a `DowngradePhase` discriminated union
  (previewing | previewFailed | ready | scheduling | scheduleFailed). Impossible
  combinations (a preview AND a refusal, "ready" with no quote) can no longer be
  represented; the hook and panel branch on one `kind`, and `mutating` is simply
  `phase.kind === 'scheduling'`.
- The five-way ternary in DowngradeConfirm is replaced by a pure `previewMessage(phase,
  fallbackTierName)` helper; the misnamed `caption` className local is renamed `captionCn`.
- inline-feedback.tsx now holds ONLY the shared refusal/step-up pieces: `openExternal`
  moves to its own `open-external.ts` (a thin wrapper over `@/lib/external-link`'s
  `openExternalLink`), and `InlineMessage` moves back into its sole consumer
  (auto-reload-row.tsx).

No behavior change. Billing suite green (110); typecheck (app/electron/e2e) + lint clean.

* fmt(js): `npm run fix` on merge (#69067)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(gateway): hard-exit CLI runner after graceful teardown

* test: update gateway run stub for hard-exit helper

* fix(gateway): hard-exit on KeyboardInterrupt path too

The KeyboardInterrupt handler in run_gateway() was the only exit path
that still used bare 'return' instead of _hard_exit_after_gateway_teardown().
While less common than service-managed restarts, a console Ctrl+C still
leaves the process vulnerable to the same Python finalization hang on
non-daemon worker threads (cron ThreadPoolExecutor jobs). Route it through
the same backstop, with a 'return' guard for test stubs that don't raise
on code 0 (production os._exit never returns).

* fix(cli): pass conversation_history on /new /resume /branch flush

Closes #68454

Root cause: cold-resumed transcript rows lack _DB_PERSISTED_MARKER until a
normal turn flush stamps them. Immediate /new,/resume,/branch flushed with
no history boundary, so every restored row was re-appended to the old session.

Fix: pass conversation_history=self.conversation_history at all three sites
(mirrors #68205). Add offline regression coverage for noop + tail-only write.

Verification: pytest tests/agent/test_session_rotation_flush_cold_resume_68454.py (4 passed)

* test: drop source-grep change-detector from #68480

The three behavior tests (control proves dup, boundary is noop, tail-only
write) fully cover the flush semantics. The source-grep test reading
cli.py + cli_commands_mixin.py as text and asserting a string appears is
a change-detector that breaks on benign refactors without adding coverage.

* test: update mock assertions for conversation_history kwarg

The /branch and /resume flush tests asserted the old positional-only
call signature. Update to match the fix from #68480.

* fix(gateway): make adapter fatal-error handoff cancellation-proof; exit if a platform is stranded

The fatal-error notification runs on the failing adapter's own polling
task, and adapter.disconnect() inside the handler can cancel that task
(its current-task guard misses because _safe_adapter_disconnect runs the
close in a wrapper task). The CancelledError killed the handler between
the fatal log and the reconnect queue, leaving the platform permanently
dead inside a live gateway process. #68447 fixed this for telegram at
the adapter layer; this hardens the shared gateway dispatch so every
platform gets the same protection (qqbot #25505/#29005, photon #68693).

- _handle_adapter_fatal_error now runs the real handler in a detached
  task, awaited through asyncio.shield() so caller cancellation cannot
  tunnel into it (Task.cancel() also cancels the task's _fut_waiter).
- If a retryable platform still ends up neither reconnected nor queued,
  the gateway exits with failure so launchd/systemd KeepAlive restarts
  it instead of running indefinitely with a dead platform (#68693).

Fixes #68693

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: map anoop.mehendale@gmail.com -> anoopmehendale-cue

For PR #69007 salvage (#69112).

* fix(update): isolate systemctl timeouts per gateway unit during fleet restart

A TimeoutExpired from one hermes-gateway*.service used to abort the whole
per-scope restart loop, leaving later profile gateways on pre-update
in-memory code after hermes update. Catch timeouts per unit, continue the
fleet, warn with the exact stale units, and exit non-zero when any remain
unrestarted (#68523).

* test(update): cover fleet restart timeout isolation (#68523)

* fix: refresh vulnerable npm lockfile entries

* chore: AUTHOR_MAP for tinetwork

* fix(compression): prevent stale-budget retry loops

* fix(compression): harden startup route scoping

* fix(providers): align custom route scoping

* test(compression): cover overflow after blocked preflight

* test(providers): cover route URL identity boundaries

* test(providers): cover query path slash identity

* test(providers): complete hermetic route coverage

* test(providers): cover URL whitespace route identity

* fix(providers): fail closed on missing active route

* fix(providers): scope route-owned runtime settings

* fix: restore base_url rstrip, extract should_clear_context_pin helper

Salvage follow-up for PR #68899:
- Restore .rstrip('/') on base_url in _swap_credential (both anthropic
  and OpenAI paths) to match every other assignment site. The route
  identity comparison still uses normalize_route_base_url which handles
  trailing slash correctly.
- Extract should_clear_context_pin() into hermes_cli/route_identity.py,
  consolidating 7 copy-pasted call sites across cli.py, gateway/run.py,
  gateway/slash_commands.py, and hermes_cli/model_switch.py into a
  single fail-closed helper.

C1 (anthropic path TLS re-application): pre-existing gap — the Anthropic
adapter (build_anthropic_client) has no TLS customization support at
all, so this is out of scope for this salvage.

* fix(compression): ignore assistant handoff summaries in tail anchor

Assistant-role compaction summaries were treated as the last visible assistant reply after head protection decayed. That pulled the tail boundary back to the summary itself and left zero new turns to summarize.

Exclude internal context summaries from both the visible-reply search and the assistant fallback, mirroring the existing user-role summary exclusion.

* chore: AUTHOR_MAP for McHermes

* fix(telegram): group authz fallback + command sender identity

- authz_mixin: add config.extra fallback for group_allowed_chats
  when observe-unmentioned mode strips user_id from env-var check
- authz_mixin: check adapter allow_from/group_allow_from for
  user authorization from config.yaml without env vars
- telegram/adapter: separate group_allow_from for group chats
  vs allow_from for DMs
- telegram/adapter: preserve sender source for command messages
  so admin-only slash commands work in groups
- telegram/adapter: add _telegram_extra fallback for
  group_allow_from config reading

* fix(telegram): address review findings from PR #67816

- Update test_observed_group_context_preserves_slash_command_text_for_dispatch
  to assert user_id is preserved for COMMAND messages (new correct behavior)
- Add _coerce_allow_set helper to handle both list and comma-separated
  string allowlist inputs (prevents character-by-character iteration bug)
- Include 'channel' in chat_type checks for group-scoped authorization
- Add _telegram_extra fallback for group_allowed_chats (consistent with
  group_allow_from fallback)
- Add AUTHOR_MAP entry for nyaruko@hermes -> tsuk1nose

* fix(telegram): update auth check tests for group_allow_from split

Update test_telegram_auth_check.py to use group_allow_from for group
messages (matching the PR's intentional behavior split: allow_from for
DMs, group_allow_from for groups). Add test_is_user_authorized_from_message_group_allow_from
to cover the new group path.

* fix(openviking): recover pending session commits

* docs: clarify OpenViking local setup

(cherry picked from commit a6807170f1)
(cherry picked from commit 6fb4e9aa8a)

* fix(openviking): serialize orphan session recovery

* fix(openviking): chunk structured session sync

Preserve ordered structured turns across OpenViking's 100-message batch limit and resume retries from the first unconfirmed message.

Based on the OpenViking batching work from commit 1a567f7067 in #58981.

* refactor: cleanup follow-up for salvaged PR #58871

- Remove dead current_sid parameter from _recover_pending_sessions
- Remove dead cleanup parameter from _release_owner_run_claim (always True)
- Set _run_lock_path after flock succeeds, not before
- Collapse redundant BlockingIOError branch (covered by OSError+errno check)
- Track _pending_marked_sids to skip re-writing marker file on every sync_turn

* fix(openviking): inject session-start memory context

(cherry picked from commit 18b474d0bd)

* fix(openviking): align session context with shared profile contract

* fix: discard both session IDs on compression for profile re-injection

The _profile_prefetched_sessions set stores whichever session_id was
passed to prefetch(), which may differ from self._session_id. On
compression, only old_session_id (self._session_id) was discarded,
missing the case where the stored key was the prefetch session_id
parameter. Discard both old and new IDs to cover all cases.

* chore: add kshitij@kshitij.dev to AUTHOR_MAP

* fix(secrets): fall back to stale disk cache when bws live fetch fails

Without this, a single DNS hiccup or BWS outage at gateway startup leaves
the whole fleet running with an empty credential pool — every model call
fails until someone restarts after the network recovers.  When a previous
successful fetch already populated the disk cache, return those secrets
with an explicit warning instead of raising RuntimeError.

`use_cache=False` (explicit opt-out) still raises so manual flows like
the setup wizard surface the original error.  The disk cache is not
re-written on the fallback path so a process restart still triggers a
proper TTL re-check.

Fixes #41925

* fix(secrets): port stale-cache fallback to current DiskCache API + gate by error kind

The stale-fallback branch called _read_disk_cache(), a helper removed in
db495b0fba when disk-cache logic moved to the
shared DiskCache class — every fallback attempt raised NameError instead of
serving cached secrets, silently defeating the PR's whole purpose. Port to
_DISK_CACHE.read().

Also tighten the fallback per DiskCache's TTL contract and the secret-source
error taxonomy:
- Gate on cache_ttl_seconds > 0 so a caller that opted out of caching
  entirely (ttl=0) never gets a secret value that didn't come from a live
  fetch, even on the failure path.
- Gate on _classify_bws_error(str(exc)) being NETWORK or TIMEOUT, reusing
  the existing classifier — an AUTH_FAILED or malformed-output failure must
  still raise, since serving stale secrets there would mask a real
  credential/config problem instead of a transient outage.

Ported the test helpers off the removed _write_disk_cache to a direct JSON
write (matching this file's existing disk-cache test convention) and added
tests for the auth-failure, malformed-output, and zero-TTL gates. Reverting
the fix and re-running confirms 7 of 8 stale-fallback tests fail with the
original NameError.

* fix(secrets): fold OP_CONNECT_HOST/OP_CONNECT_TOKEN into 1Password auth cache-key

_auth_fingerprint() built the 1Password secret cache-key from the
service-account token, OP_ACCOUNT, and OP_SESSION_* vars but omitted
OP_CONNECT_HOST/OP_CONNECT_TOKEN, which are in _OP_ENV_ALLOWLIST and are
forwarded to the op child (the Connect-server auth path). Rotating
OP_CONNECT_TOKEN or re-pointing OP_CONNECT_HOST at a different Connect
identity left the fingerprint unchanged, so both the in-process and disk
caches kept serving secrets resolved under the old Connect credentials for
the full TTL (default 300s, disk-persisted across invocations). This
contradicts the function's own docstring invariant that a value cached
under a previous identity is never served under a new one; it closes the
gap for the Connect path, matching the OP_SESSION_*/service-account paths
that are already protected.

* fix(secrets): pass OP_LOAD_DESKTOP_APP_SETTINGS through to the op child env

The 1Password secret source builds a minimal allowlisted environment for the
`op read` child process. The allowlist omits OP_LOAD_DESKTOP_APP_SETTINGS, so a
user who exports it (shell, .env, or service unit) sees it silently stripped
before it reaches `op`.

That var is `op`'s documented switch to skip the desktop-app integration probe.
When the 1Password desktop app is installed, `op` probes its settings/socket at
startup *before* evaluating service-account auth. If the desktop app's group
container is wedged (e.g. macOS 'Interrupted system call' on the 1Password group
container), that probe blocks with no timeout, so `op read` hangs indefinitely
even with a valid OP_SERVICE_ACCOUNT_TOKEN present. Setting
OP_LOAD_DESKTOP_APP_SETTINGS=false is the intended escape hatch — but stripping
it means it has no effect on exactly the headless boxes that need it.

Fix: add OP_LOAD_DESKTOP_APP_SETTINGS to _OP_ENV_ALLOWLIST so the documented
var reaches the child. No behavior change when it's unset. Adds a focused test
alongside the existing allowlist test.

Repro: on a machine with a wedged 1Password desktop container + a valid SA
token, `op read` hangs 600s+ without the var and returns in ~4s with it — but
only if it actually reaches the op process, which this allowlist entry ensures.

Co-authored-by: Minh Nguyen <menhguin@users.noreply.github.com>

* fix(mcp): pass secret-source-injected env vars to stdio servers

Surgical reapply of PR #37523 onto current main (the original branch
predates the SecretSource registry refactor).  _build_safe_env() now
forwards env vars tagged in env_loader._SECRET_SOURCES — widened from
Bitwarden-only to any registered secret source (Bitwarden, 1Password,
plugin backends), since the provenance map is source-agnostic.
Explicit server env: config still wins; untagged secrets stay filtered.

Fixes #37499.

* fix(env): stop printing Bitwarden secret names

* fix(secrets): validate bitwarden status token

Keep the env-presence row, but add a real Bitwarden probe so revoked or malformed tokens no longer look healthy in hermes secrets bitwarden status.

Also document the new status behavior and lock it in with a dedicated regression test.

Refs: NousResearch/hermes-agent#40275
Tested: ./scripts/run_tests.sh tests/hermes_cli/test_bitwarden_status.py tests/test_bitwarden_secrets.py
Tested: .venv/bin/python -m ruff check hermes_cli/secrets_cli.py tests/hermes_cli/test_bitwarden_status.py

* fix(secrets): mark _APPLIED_HOMES only after a real fetch attempt (#40597) (#69056)

_apply_external_secret_sources() added the home to _APPLIED_HOMES before
loading config, so a malformed config.yaml, a missing secrets section, or
all-sources-disabled permanently disabled secret loading for the process
— even after the user fixed the config.  Long-lived processes (gateway)
never recovered without a restart.

Now the home is marked only after apply_all() actually ran with at least
one enabled source.  Fetch errors still mark the home (so import-time
load_hermes_dotenv() calls don't re-fetch and re-print the same failure
3-5x per startup); the cheap early-exit paths stay retryable.

Fixes #40597.

* fix(secrets): fall back to os.environ on scope miss when multiplexing is off

fdab380a1 wraps every cron job in a <home>/.env secret scope regardless of
deployment mode. get_secret() treats any installed scope as authoritative,
so in single-profile deployments where provider keys live only in the
process environment (systemd Environment=, pass-cli/op run wrappers, shell
exports) every cron credential read returns empty, the OpenAI client is
built with the no-key-required placeholder, and each scheduled job 401s —
while interactive turns keep working. Scope-miss reads now fall through to
os.environ when multiplexing is off; multiplexed scopes stay authoritative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(gateway): activate multiplex flag in cross-profile env isolation test

The test installs a secret scope and asserts a scope miss does NOT fall
back to the default profile's env — that isolation guarantee only holds
under multiplexing, which the real gateway activates at startup via
set_multiplex_active().  With the #67827 overlay fallthrough (scope miss
→ os.environ when multiplex is OFF), the test needs to model the
multiplexed runtime it is actually testing.

* feat(secrets): orchestrator-level preserve_existing + profile aliasing (#69058)

Fixes the profile-clobber bug cluster at the apply_all() chokepoint so
every secret source — bundled and plugin — gets both behaviors for free:

- secrets.preserve_existing (#58073): env var names whose existing .env /
  shell value always wins, even against a source with
  override_existing: true.  Escape hatch for per-profile platform
  secrets while everything else rotates centrally.
- Profile aliasing (#51447): under a named profile, an applied
  FOO_<PROFILE> var (credential-shaped suffixes only) also hydrates the
  canonical FOO, so adapters/plugins that read fixed env names see the
  profile's value.  Direct supply beats alias; protected/claimed/
  override guards all apply; secrets.profile_alias: false disables.

Reimplements the intent of PR #58085 (tianma-if, preserve_existing on the
legacy Bitwarden apply shim) and PR #51616 (LeonSGP43, profile aliasing
inside the Bitwarden backend) on the SecretSource orchestrator that
superseded those code paths.

Fixes #58073.  Fixes #51447.

Co-authored-by: tianma-if <5895871+tianma-if@users.noreply.github.com>
Co-authored-by: LeonSGP43 <154585401+LeonSGP43@users.noreply.github.com>

* test(state): accept FTS5-specific corruption message in read-probe test

With the v23 external-content FTS layout, corrupt shadow segments surface
as 'fts5: corrupt structure record for table ...' instead of the generic
'database disk image is malformed'. Same corruption class, same variance
already documented and handled by _is_fts_write_corruption_error — widen
the test assertion to match.

---------

Co-authored-by: ethernet <arilotter@gmail.com>
Co-authored-by: brooklyn! <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
Co-authored-by: yoniebans <jonny@nousresearch.com>
Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com>
Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai>
Co-authored-by: xxxigm <54813621+xxxigm@users.noreply.github.com>
Co-authored-by: hermes-seaeye[bot] <307254004+hermes-seaeye[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
Co-authored-by: HexLab98 <liruixinch@outlook.com>
Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Co-authored-by: PRATHAMESH75 <prathamesh290504@gmail.com>
Co-authored-by: x7peeps <xtpeeps@qq.com>
Co-authored-by: Imgaojp <6065749+Imgaojp@users.noreply.github.com>
Co-authored-by: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com>
Co-authored-by: Ben Barclay <ben@nousresearch.com>
Co-authored-by: sbe27 <283218367+sbe27@users.noreply.github.com>
Co-authored-by: TARS <tars@users.noreply.github.com>
Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>
Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com>
Co-authored-by: Joshua <joshua@amokk.net>
Co-authored-by: alelpoan <155192176+alelpoan@users.noreply.github.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Cossackx <121278003+Cossackx@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jaret Bottoms <jaretbottoms@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Enough1122 <chenjin@hermes.local>
Co-authored-by: Frowtek <frowte3k@gmail.com>
Co-authored-by: Aniruddha Adak <aniruddhaadak80@users.noreply.github.com>
Co-authored-by: eazye19 <eazye19@users.noreply.github.com>
Co-authored-by: frohsinnllc <231045016+frohsinnllc@users.noreply.github.com>
Co-authored-by: hanyu1212 <28571259+hanyu1212@users.noreply.github.com>
Co-authored-by: 雨哥 <hanyu1212@users.noreply.github.com>
Co-authored-by: yungchentang <46495124+yungchentang@users.noreply.github.com>
Co-authored-by: trevorgordon981 <trevorbgordon@gmail.com>
Co-authored-by: Börje <borje@dqsverige.se>
Co-authored-by: fazerluga-creator <fazerluga@gmail.com>
Co-authored-by: yoma <yingwaizhiying@gmail.com>
Co-authored-by: Kennedy Umege <kenmege@yahoo.com>
Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
Co-authored-by: Sora-bluesky <sora.bluesky.dev@gmail.com>
Co-authored-by: iamwongeeeee <wykim777@naver.com>
Co-authored-by: web3blind <264741654+web3blind@users.noreply.github.com>
Co-authored-by: Bartok9 <danielrpike9@gmail.com>
Co-authored-by: anoopmehendale-cue <anoop.mehendale@gmail.com>
Co-authored-by: tinetwork <martin@tinetwork.com>
Co-authored-by: cucurigoo <241698038+cucurigoo@users.noreply.github.com>
Co-authored-by: McHermes <mchermes@edu.dreamcatcher.ai>
Co-authored-by: Nyaruko <nyaruko@hermes>
Co-authored-by: Hao Zhe <haozhe4547@gmail.com>
Co-authored-by: Naveen Fernando <90832919+NPFernando@users.noreply.github.com>
Co-authored-by: Chris Korhonen <ckorhonen@gmail.com>
Co-authored-by: Flownium <157689911+itsflownium@users.noreply.github.com>
Co-authored-by: kshitij <kshitij@kshitij.dev>
Co-authored-by: JackJin <1037461232@qq.com>
Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
Co-authored-by: menhguin <17287724+menhguin@users.noreply.github.com>
Co-authored-by: Minh Nguyen <menhguin@users.noreply.github.com>
Co-authored-by: SeoYeonKim <28585885+westkite1201@users.noreply.github.com>
Co-authored-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
Co-authored-by: izumi0uu <izumi0uu@gmail.com>
Co-authored-by: Soju06 <qlskssk@gmail.com>
Co-authored-by: tianma-if <5895871+tianma-if@users.noreply.github.com>
Co-authored-by: LeonSGP43 <154585401+LeonSGP43@users.noreply.github.com>
2026-07-22 04:42:50 -07:00

9341 lines
422 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Configuration management for Hermes Agent.
Config files are stored in ~/.hermes/ for easy access:
- ~/.hermes/config.yaml - All settings (model, toolsets, terminal, etc.)
- ~/.hermes/.env - API keys and secrets
This module provides:
- hermes config - Show current configuration
- hermes config edit - Open config in editor
- hermes config get - Print a resolved configuration value
- hermes config set - Set a specific value
- hermes config unset - Remove a user configuration value
- hermes config wizard - Re-run setup wizard
"""
import copy
import json
import logging
import os
import platform
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple, Set
from hermes_cli.route_identity import normalize_route_base_url
from hermes_cli.secret_prompt import masked_secret_prompt
logger = logging.getLogger(__name__)
# Track which (config_path, mtime_ns, size) tuples we've already warned about
# so concurrent CLI/gateway loads of a broken config.yaml don't spam stderr
# every time. Cleared automatically when the file changes (different mtime).
_CONFIG_PARSE_WARNED: set = set()
def _backup_corrupt_config(config_path: Path) -> Optional[Path]:
"""Preserve a corrupted ``config.yaml`` by copying it to a timestamped ``.bak``.
When the YAML can't be parsed, ``load_config()`` silently falls back to
``DEFAULT_CONFIG`` and the user's broken file stays on disk untouched.
That file is still the user's only copy of their intended overrides — if
they re-run the setup wizard or ``hermes config set`` (which rewrites
``config.yaml``), the broken-but-recoverable content is gone for good.
This snapshots the corrupted file to ``config.yaml.corrupt.<ts>.bak`` so
the user can diff/repair it. Unlike Gemini CLI's policy-file recovery
(which resets the live file to a clean state), we deliberately leave
``config.yaml`` in place: hermes never silently mutates the user's config,
and leaving it means a hand-fixed file is re-read on the next load. The
backup is best-effort — any failure (permissions, symlink, disk full) is
swallowed so config loading is never blocked by backup problems.
Returns the backup path on success, else ``None``. Symlinks are not
followed/copied (mirrors the Gemini #21541 lstat guard) to avoid
clobbering whatever a malicious/misconfigured symlink points at.
"""
try:
if config_path.is_symlink():
return None
st = config_path.stat()
if st.st_size == 0:
# Empty file isn't worth preserving and yaml.safe_load returns {}
# for it anyway (so it wouldn't reach here), but guard regardless.
return None
ts = time.strftime("%Y%m%d-%H%M%S")
backup_path = config_path.with_name(f"{config_path.name}.corrupt.{ts}.bak")
# Don't clobber an existing backup from the same second; if there's
# already a corrupt backup for this exact mtime, assume we've snapshotted
# this corruption already and skip (the dedup cache normally prevents a
# second call, but a process restart can clear it).
sibling_baks = list(
config_path.parent.glob(f"{config_path.name}.corrupt.*.bak")
)
for existing in sibling_baks:
try:
if existing.stat().st_size == st.st_size:
# Same size as the current broken file — likely the same
# corruption already preserved. Avoid backup churn.
return None
except OSError:
continue
if backup_path.exists():
return None
shutil.copy2(config_path, backup_path)
return backup_path
except Exception:
return None
def _warn_config_parse_failure(
config_path: Path, exc: Exception, *, fallback: str = "defaults"
) -> None:
"""Surface a config.yaml parse failure to user, log, and stderr.
A YAML parse error in ``~/.hermes/config.yaml`` causes ``load_config()``
to silently fall back to ``DEFAULT_CONFIG``, which means every user
override (auxiliary providers, fallback chain, model overrides, etc.)
is dropped. Before this helper that was a one-line ``print(...)`` that
scrolled off-screen on the first invocation and was never seen again.
Now: warn once per (path, mtime_ns, size) on stderr **and** in
``agent.log`` / ``errors.log`` at WARNING level so ``hermes logs``
surfaces it. Re-warns automatically if the file changes (different
mtime/size), so users editing the config see the next failure. On the
first warning for a given broken file we also snapshot it to a
timestamped ``.bak`` (best-effort) so the user's recoverable content
survives any later rewrite of ``config.yaml`` by the setup wizard or
``hermes config set``.
``fallback`` selects the message wording: ``"defaults"`` (fresh process,
nothing else to serve) or ``"last-known-good"`` (in-process retention of
the previously loaded config — see the codex#31188 port in
``_load_config_impl``).
"""
try:
st = config_path.stat()
key = (str(config_path), st.st_mtime_ns, st.st_size)
except OSError:
key = (str(config_path), 0, 0)
if key in _CONFIG_PARSE_WARNED:
return
_CONFIG_PARSE_WARNED.add(key)
backup_path = _backup_corrupt_config(config_path)
if fallback == "last-known-good":
msg = (
f"Failed to parse {config_path}: {exc}. "
f"Keeping the previously loaded config for this process — "
f"edits to config.yaml are being IGNORED until the YAML is fixed."
)
else:
msg = (
f"Failed to parse {config_path}: {exc}. "
f"Falling back to default config — every user override "
f"(auxiliary providers, fallback chain, model settings) is being IGNORED. "
f"Fix the YAML and restart."
)
if backup_path is not None:
msg += f" A copy of the corrupted file was saved to {backup_path}."
logger.warning(msg)
try:
sys.stderr.write(f"⚠️ hermes config: {msg}\n")
sys.stderr.flush()
except Exception:
pass
_IS_WINDOWS = platform.system() == "Windows"
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Env var names that influence how the next subprocess executes —
# never writable through ``save_env_value``. Anything that controls
# the loader, interpreter, shell, or replacement editor counts:
#
# * ``LD_PRELOAD`` / ``LD_LIBRARY_PATH`` / ``LD_AUDIT`` — Linux dynamic
# loader. ``DYLD_*`` — macOS equivalent. Planting a path here means
# the next ``subprocess.run([...])`` Hermes makes loads attacker code
# before main().
# * ``PYTHONPATH`` / ``PYTHONHOME`` / ``PYTHONSTARTUP`` /
# ``PYTHONUSERBASE`` — Python interpreter init. Hermes itself starts
# from one of these on every restart.
# * ``NODE_OPTIONS`` / ``NODE_PATH`` — Node interpreter; affects npm,
# ``hermes update``, the TUI build.
# * ``PATH`` — too broad to allow. The dashboard never needs to rewrite
# the operator's PATH; if a tool can't be found, the fix is to add an
# absolute path in the integration config, not to mutate PATH globally.
# * ``GIT_SSH_COMMAND`` / ``GIT_EXEC_PATH`` — git rewrites that fire
# on every plugin install / ``hermes update``.
# * ``BROWSER`` / ``EDITOR`` / ``VISUAL`` / ``PAGER`` — commands the
# shell or CLI invokes implicitly. Wrong values here = RCE on next
# ``$EDITOR``.
# * ``SHELL`` — what subprocess uses with ``shell=True`` (we try to
# avoid that, but defense in depth).
# * ``HERMES_HOME`` / ``HERMES_PROFILE`` / ``HERMES_CONFIG`` /
# ``HERMES_ENV`` — Hermes runtime location flags. Writing these into
# ``.env`` would relocate state in ways the user did not request from
# the dashboard. ``config.yaml`` is the supported surface for these.
#
# IMPORTANT: ``HERMES_*`` overall is NOT blocked. Many legitimate
# integration credentials follow that prefix (HERMES_LANGFUSE_PUBLIC_KEY,
# HERMES_SPOTIFY_CLIENT_ID, ...). The
# denylist is name-by-name on purpose so the gate stays narrow and
# doesn't accidentally break provider setup wizards.
#
# This is enforced on *write* only — values already in ``.env`` (set
# by the operator out-of-band, or pre-existing) keep working. The
# point is that the dashboard's writable surface cannot escalate by
# planting them.
_ENV_VAR_NAME_DENYLIST: frozenset[str] = frozenset({
# Loader / linker
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "LD_DEBUG",
"DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH", "DYLD_FALLBACK_FRAMEWORK_PATH",
# Python
"PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP", "PYTHONUSERBASE",
"PYTHONEXECUTABLE", "PYTHONNOUSERSITE",
# Node
"NODE_OPTIONS", "NODE_PATH",
# General
"PATH", "SHELL", "BROWSER", "EDITOR", "VISUAL", "PAGER",
# Git
"GIT_SSH_COMMAND", "GIT_EXEC_PATH", "GIT_SHELL",
# Hermes runtime location — never via dashboard env writer.
# NOT a HERMES_* blanket: integration credentials (HERMES_GEMINI_*,
# HERMES_LANGFUSE_*, HERMES_SPOTIFY_*, ...) ARE allowed.
"HERMES_HOME", "HERMES_PROFILE", "HERMES_CONFIG", "HERMES_ENV",
})
def _reject_denylisted_env_var(key: str) -> None:
"""Raise if ``key`` is in :data:`_ENV_VAR_NAME_DENYLIST`.
Centralised so both the regular and "secure" env writers share the
same gate, and so the message is consistent for callers.
"""
if key in _ENV_VAR_NAME_DENYLIST:
raise ValueError(
f"Environment variable {key!r} is on the writer denylist. "
"Names that influence subprocess execution (LD_PRELOAD, "
"PYTHONPATH, PATH, EDITOR, ...) or Hermes runtime location "
"(HERMES_HOME, HERMES_PROFILE, ...) cannot be persisted via "
"the env writer. If you really need this, edit "
"~/.hermes/.env directly."
)
_LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}
# (path, mtime_ns, size) -> cached expanded config dict.
# load_config() returns a deepcopy of the cached value when the file
# hasn't changed since the last load, skipping yaml.safe_load +
# _deep_merge + _normalize_* + _expand_env_vars (~13 ms/call).
# save_config() + migrate_config() write via atomic_yaml_write which
# produces a fresh inode, so stat() sees a new mtime_ns and the next
# load repopulates automatically — no explicit invalidation hook.
# Cached tuple is (user_mtime_ns, user_size, managed_mtime_ns, managed_size,
# merged_value, env_ref_snapshot) — the managed-file signature is folded in so
# editing the managed-scope config.yaml invalidates the cache (see
# managed_scope), and the env snapshot invalidates it when a referenced ${VAR}
# changes value (late .env load, in-process rotation — #58514).
_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, int, int, Dict[str, Any], Dict[str, Optional[str]]]] = {}
# (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as
# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want
# the user's on-disk values without defaults merged in.
_RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
# Serializes all config read/write paths. libyaml's C extension is not
# thread-safe for concurrent safe_load() on the same file, and multiple
# tool threads (approval.py, browser_tool.py, setup flows) hit
# load_config / read_raw_config / save_config from different threads
# during long agent runs. RLock (not Lock) because save_config internally
# calls read_raw_config. Also covers mutation of the module-level cache
# dicts above.
_CONFIG_LOCK = threading.RLock()
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
# (managed by setup/provider flows directly).
_EXTRA_ENV_KEYS = frozenset({
"OPENAI_API_KEY", "OPENAI_BASE_URL",
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
"DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME",
"TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME",
"SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME",
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
"SIGNAL_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL_NAME",
"SMS_HOME_CHANNEL", "SMS_HOME_CHANNEL_NAME",
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
"DINGTALK_HOME_CHANNEL", "DINGTALK_HOME_CHANNEL_NAME",
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
"FEISHU_HOME_CHANNEL", "FEISHU_HOME_CHANNEL_NAME",
"YUANBAO_HOME_CHANNEL", "YUANBAO_HOME_CHANNEL_NAME",
"WECOM_BOT_ID", "WECOM_SECRET",
"WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID",
"WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY",
"WECOM_CALLBACK_HOST", "WECOM_CALLBACK_PORT",
"WECOM_HOME_CHANNEL", "WECOM_HOME_CHANNEL_NAME",
"WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL",
"WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY",
"WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS",
"BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD",
"BLUEBUBBLES_HOME_CHANNEL", "BLUEBUBBLES_HOME_CHANNEL_NAME",
"QQ_APP_ID", "QQ_CLIENT_SECRET", "QQBOT_HOME_CHANNEL", "QQBOT_HOME_CHANNEL_NAME",
"QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat)
"QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
"QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
"IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL",
"IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD",
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
# Deprecated tool-progress env vars — replaced by display.tool_progress in
# config.yaml. Kept known here so .env sanitization/reload still handle
# them for existing users (gateway reads them as a back-compat fallback),
# without surfacing them in user-facing OPTIONAL_ENV_VARS listings.
"HERMES_TOOL_PROGRESS", "HERMES_TOOL_PROGRESS_MODE",
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD",
"MATRIX_RECOVERY_KEY",
# Langfuse observability plugin — optional tuning keys + standard SDK vars.
# Activation is via plugins.enabled (opt-in through `hermes plugins enable
# observability/langfuse` or `hermes tools → Langfuse`); credentials gate
# the plugin at runtime.
"HERMES_LANGFUSE_ENV",
"HERMES_LANGFUSE_RELEASE",
"HERMES_LANGFUSE_SAMPLE_RATE",
"HERMES_LANGFUSE_MAX_CHARS",
"HERMES_LANGFUSE_DEBUG",
"LANGFUSE_PUBLIC_KEY",
"LANGFUSE_SECRET_KEY",
"LANGFUSE_BASE_URL",
})
import yaml
from hermes_cli.colors import Colors, color
from hermes_cli.default_soul import DEFAULT_SOUL_MD, is_legacy_template_soul
# =============================================================================
# Managed mode (NixOS declarative config)
# =============================================================================
_MANAGED_TRUE_VALUES = ("true", "1", "yes")
_MANAGED_SYSTEM_NAMES = {
"brew": "Homebrew",
"homebrew": "Homebrew",
"nix": "NixOS",
"nixos": "NixOS",
}
def get_managed_system() -> Optional[str]:
"""Return the package manager owning this install, if any."""
raw = os.getenv("HERMES_MANAGED", "").strip()
if raw:
normalized = raw.lower()
if normalized in _MANAGED_TRUE_VALUES:
return "NixOS"
return _MANAGED_SYSTEM_NAMES.get(normalized, raw)
managed_marker = get_hermes_home() / ".managed"
if managed_marker.exists():
return "NixOS"
return None
def is_managed() -> bool:
"""Check if Hermes is running in package-manager-managed mode.
Two signals: the HERMES_MANAGED env var (set by the systemd service),
or a .managed marker file in HERMES_HOME (set by the NixOS activation
script, so interactive shells also see it).
"""
return get_managed_system() is not None
_NIX_UPDATE_MSG = "Update your Nix flake input and rebuild (e.g. nix flake update, nixos-rebuild, or home-manager switch)"
def get_managed_update_command() -> Optional[str]:
"""Return the preferred upgrade command for a managed install."""
managed_system = get_managed_system()
if managed_system == "Homebrew":
return "brew upgrade hermes-agent"
if managed_system == "NixOS":
return _NIX_UPDATE_MSG
return None
def _install_method_project_root(project_root: Optional[Path] = None) -> Path:
"""Resolve the directory that holds the *running code* (the install tree).
This is the parent of ``hermes_cli/`` — i.e. the git checkout for source
installs, ``/opt/hermes`` inside the published image, the venv's
site-packages root for pip installs. It is a property of the running
interpreter, NOT of ``$HERMES_HOME``, which is why a code-scoped stamp
here is immune to two installs sharing one data directory.
"""
if project_root is not None:
return project_root
return Path(__file__).parent.parent.resolve()
def detect_install_method(project_root: Optional[Path] = None) -> str:
"""Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'.
Resolution order:
1. Code-scoped stamp ``<install tree>/.install_method`` (next to the
running code) — the authoritative marker.
2. Legacy home-scoped stamp ``$HERMES_HOME/.install_method`` — read for
backward compatibility, but a ``docker`` value is IGNORED when we are
not actually running inside a container (see below).
3. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
4. .git directory presence -> 'git'
5. Fallback -> 'pip'
Why the stamp is code-scoped, not home-scoped (issue: shared ``~/.hermes``)
--------------------------------------------------------------------------
The install method describes *the binary that is running*, but
``$HERMES_HOME`` is a shared DATA directory — the Docker docs deliberately
bind-mount it (``~/.hermes:/opt/data``) so config/sessions/memory persist
and can be shared with a host-side Desktop/CLI install. When a
containerised gateway and a host install share one ``$HERMES_HOME``, a
home-scoped stamp is a single slot describing two different installs:
the container stamps ``docker`` on every boot, the host install then reads
``docker`` and ``hermes update`` refuses to run ("doesn't apply inside the
Docker container") even though the host binary is a perfectly updatable
git/pip install. Scoping the stamp to the install tree gives each install
its own truthful marker.
Self-healing for already-poisoned homes: a legacy ``docker`` value in the
home-scoped stamp is only honoured when we are genuinely in a container.
On a host install that read a contaminating ``docker`` stamp, we fall
through to managed/.git/pip detection instead — so existing shared-home
setups recover without the user touching anything.
Note: running inside a container is NOT treated as "docker" on its own.
The supported installs self-identify via the code-scoped stamp:
- the curl installer (scripts/install.sh, the README/website install
command) git-clones the repo and stamps ``git`` next to the code;
- the published ``nousresearch/hermes-agent`` image bakes a ``docker``
stamp into ``/opt/hermes`` at build time.
An unsupported manual install dropped into a container (no stamp) falls
through to the ``.git``/pip checks and behaves like any off-path install.
See issue #34397.
"""
root = _install_method_project_root(project_root)
# 1. Code-scoped stamp — authoritative, immune to shared $HERMES_HOME.
try:
method = (root / ".install_method").read_text(encoding="utf-8").strip().lower()
if method:
return method
except OSError:
pass
# 2. Legacy home-scoped stamp — back-compat. Ignore a ``docker`` value
# when we are not actually containerised: that is the signature of a
# host install whose shared $HERMES_HOME was stamped by a co-located
# container, and honouring it wrongly blocks ``hermes update``.
try:
method = (
(get_hermes_home() / ".install_method")
.read_text(encoding="utf-8")
.strip()
.lower()
)
if method and not (method == "docker" and not _running_in_container()):
return method
except OSError:
pass
managed = get_managed_system()
if managed:
return managed.lower().replace(" ", "-")
# detect git repo installs (normal installer, development env)
git_path = root / ".git"
if git_path.is_dir():
return "git"
# detect git repo installs from worktrees
if git_path.is_file():
try:
content = git_path.read_text(encoding="utf-8").strip()
if content.startswith("gitdir:"):
return "git"
except OSError:
pass
return "pip"
def _running_in_container() -> bool:
"""Thin wrapper around ``hermes_constants.is_container`` (import-safe)."""
try:
from hermes_constants import is_container
return is_container()
except Exception:
return False
def stamp_install_method(method: str, project_root: Optional[Path] = None) -> None:
"""Write the install method next to the running code (code-scoped stamp).
The stamp lives in the install tree (``<install tree>/.install_method``),
not in ``$HERMES_HOME``, so that two installs sharing one data directory
do not overwrite each other's marker. See ``detect_install_method`` for
the full rationale.
Best-effort: if the install tree is read-only (e.g. the immutable
``/opt/hermes`` in the published image, which instead bakes the stamp at
build time) the write silently no-ops and detection falls back to its
other signals.
"""
root = _install_method_project_root(project_root)
try:
root.mkdir(parents=True, exist_ok=True)
(root / ".install_method").write_text(method + "\n", encoding="utf-8")
except OSError:
pass
def is_uv_tool_install() -> bool:
"""Return True when the *running* Hermes lives in a ``uv tool`` layout.
``uv tool install hermes-agent`` places the install at
``.../uv/tools/hermes-agent/...`` (default ``~/.local/share/uv/tools``,
or ``$UV_TOOL_DIR/...``). Such installs live outside any virtualenv, so
``uv pip install`` fails with ``No virtual environment found`` and the
update path must use ``uv tool upgrade`` instead.
Detection is intentionally restricted to properties of the running
interpreter (``sys.prefix`` / ``sys.executable``). We deliberately do
NOT consult ``uv tool list``: it would also return True when
``hermes-agent`` happens to be uv-tool-installed on the machine while
the *active* Hermes is a regular pip/venv install, causing
``hermes update`` to upgrade the wrong copy. It would also block on a
subprocess call (~seconds) just to compute a recommendation string.
"""
def _has_uv_tool_marker(path: str) -> bool:
norm = os.path.normpath(path).replace(os.sep, "/").lower()
return "/uv/tools/hermes-agent/" in norm + "/"
if _has_uv_tool_marker(sys.prefix):
return True
if _has_uv_tool_marker(sys.executable or ""):
return True
return False
def recommended_update_command_for_method(method: str) -> str:
"""Return the update command or guidance for a given install method."""
if method == "nixos":
return _NIX_UPDATE_MSG
if method == "homebrew":
return "brew upgrade hermes-agent"
if method == "docker":
return "docker pull nousresearch/hermes-agent:latest"
if method == "pip":
if is_uv_tool_install():
return "uv tool upgrade hermes-agent"
import shutil
if shutil.which("uv"):
return "uv pip install --upgrade hermes-agent"
return "pip install --upgrade hermes-agent"
return "hermes update"
def recommended_update_command() -> str:
"""Return the best update command for the current installation."""
managed_cmd = get_managed_update_command()
if managed_cmd:
return managed_cmd
method = detect_install_method(get_project_root())
return recommended_update_command_for_method(method)
# =============================================================================
# Unsupported install methods (pip, Homebrew) — deprecation notice
# =============================================================================
#
# pip/PyPI and Homebrew are NOT an officially supported distribution method
# (see website/docs/getting-started/platform-support.md, "Unsupported"
# section). pip exists on PyPI for internal/CI reasons, not end-user installs;
# Homebrew is a legacy packaging path. Unlike NixOS/Homebrew "managed mode"
# (which hard-blocks config writes), this is a warn-don't-block deprecation
# notice surfaced everywhere the user might see install-method state: the CLI
# banner, the TUI/desktop session info panel, and ``hermes update``. NixOS
# stays fully supported (Tier 2) and must never hit this path.
PLATFORM_SUPPORT_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/getting-started/platform-support"
_UNSUPPORTED_INSTALL_METHODS = frozenset({"pip", "homebrew"})
def is_unsupported_install_method(method: str) -> bool:
"""Whether ``method`` (from ``detect_install_method()``) is deprecated."""
return method in _UNSUPPORTED_INSTALL_METHODS
def unsupported_install_method_label(method: str) -> str:
"""Human-readable name for an unsupported install method."""
return "pip" if method == "pip" else "Homebrew"
def format_unsupported_install_warning(method: str) -> str:
"""Plain-text (no markup) deprecation notice for pip/Homebrew installs.
Shared verbatim across the CLI banner, TUI/desktop ``session.info``, and
``hermes update`` / ``hermes update --check`` so the wording — and the
docs link — stays consistent across every surface instead of drifting
into three slightly different warnings.
"""
label = unsupported_install_method_label(method)
return (
f"{label} installs are no longer an officially supported platform and "
f"will not receive further updates. See {PLATFORM_SUPPORT_DOCS_URL} "
"for supported install methods."
)
# Long-form text for ``hermes update`` / ``--check`` when running inside the
# Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in
# hermes_cli/main.py; lives here so the wording stays consistent and we
# don't grow two slightly-different copies.
#
# Why this matters:
# - The published image excludes ``.git`` (see .dockerignore), so the
# git-based update path can never succeed inside the container.
# - The pre-existing fallback message ("✗ Not a git repository. Please
# reinstall: curl ... install.sh") is actively misleading inside Docker
# — that script installs a *new* host-side Hermes, it doesn't update
# the running container.
# - The right action is ``docker pull`` + restart the container; this
# helper spells that out, with notes on tag pinning and config
# persistence so users don't get blindsided.
_DOCKER_UPDATE_MESSAGE = """\
✗ ``hermes update`` doesn't apply inside the Docker container.
Hermes Agent runs as a published image (nousresearch/hermes-agent), not a
git checkout — the container has no working tree to pull into. Update by
pulling a fresh image and restarting your container instead:
docker pull nousresearch/hermes-agent:latest
# then restart whatever started the container, e.g.:
docker compose up -d --force-recreate hermes-agent
# or, for ad-hoc runs, exit the current container and `docker run` again
Verify the new version after restart:
docker run --rm nousresearch/hermes-agent:latest --version
Notes:
• If you pinned a specific tag (e.g. ``:v0.14.0``) the ``:latest`` tag
won't move your container — pull the newer tag you actually want, or
switch to ``:latest`` / ``:main`` for rolling updates. See available
tags at https://hub.docker.com/r/nousresearch/hermes-agent/tags
• Your config and session history live under ``$HERMES_HOME`` (``/opt/data``
in the container, typically bind-mounted from the host) and persist
across image upgrades — re-pulling doesn't lose any state.
• Running a fork? Build your own image with this repo's ``Dockerfile``
and replace the ``docker pull`` step with your build/push pipeline."""
def format_docker_update_message() -> str:
"""Return the user-facing message for ``hermes update`` inside Docker.
Centralised so ``cmd_update`` (the apply path) and ``_cmd_update_check``
(the dry-run path) share the same wording. See ``_DOCKER_UPDATE_MESSAGE``
above for the full rationale.
"""
return _DOCKER_UPDATE_MESSAGE
def format_managed_message(action: str = "modify this Hermes installation") -> str:
"""Build a user-facing error for managed installs."""
managed_system = get_managed_system() or "a package manager"
raw = os.getenv("HERMES_MANAGED", "").strip().lower()
if managed_system == "NixOS":
env_hint = "true" if raw in _MANAGED_TRUE_VALUES else raw or "true"
return (
f"Cannot {action}: this Hermes installation is managed by NixOS "
f"(HERMES_MANAGED={env_hint}).\n"
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
" sudo nixos-rebuild switch"
)
if managed_system == "Homebrew":
env_hint = raw or "homebrew"
return (
f"Cannot {action}: this Hermes installation is managed by Homebrew "
f"(HERMES_MANAGED={env_hint}).\n"
"Use:\n"
" brew upgrade hermes-agent"
)
return (
f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n"
"Use your package manager to upgrade or reinstall Hermes."
)
def managed_error(action: str = "modify configuration"):
"""Print user-friendly error for managed mode."""
print(format_managed_message(action), file=sys.stderr)
# =============================================================================
# Container-aware CLI (NixOS container mode)
# =============================================================================
def get_container_exec_info() -> Optional[dict]:
"""Read container mode metadata from HERMES_HOME/.container-mode.
Returns a dict with keys: backend, container_name, exec_user, hermes_bin
or None if container mode is not active, we're already inside the
container, or HERMES_DEV=1 is set.
The .container-mode file is written by the NixOS activation script when
container.enable = true. It tells the host CLI to exec into the container
instead of running locally.
"""
if os.environ.get("HERMES_DEV") == "1":
return None
from hermes_constants import is_container
if is_container():
return None
container_mode_file = get_hermes_home() / ".container-mode"
try:
info = {}
with open(container_mode_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if "=" in line and not line.startswith("#"):
key, _, value = line.partition("=")
info[key.strip()] = value.strip()
except FileNotFoundError:
return None
# All other exceptions (PermissionError, malformed data, etc.) propagate
backend = info.get("backend", "docker")
container_name = info.get("container_name", "hermes-agent")
exec_user = info.get("exec_user", "hermes")
hermes_bin = info.get("hermes_bin", "/data/current-package/bin/hermes")
return {
"backend": backend,
"container_name": container_name,
"exec_user": exec_user,
"hermes_bin": hermes_bin,
}
# =============================================================================
# Config paths
# =============================================================================
# Re-export from hermes_constants — canonical definition lives there.
from hermes_constants import get_hermes_home, get_process_hermes_home # noqa: F811,E402
from utils import atomic_replace, fast_safe_load
def get_config_path() -> Path:
"""Get the main config file path."""
return get_hermes_home() / "config.yaml"
def get_env_path() -> Path:
"""Get the .env file path (for API keys)."""
return get_hermes_home() / ".env"
def get_project_root() -> Path:
"""Get the project installation directory."""
return Path(__file__).parent.parent.resolve()
def _resolve_hermes_uid_gid() -> tuple[Optional[int], Optional[int]]:
"""Read the HERMES_UID / HERMES_GID env vars set by Docker deployments.
Docker containers running Hermes commonly set these to map the in-container
user to a host user so volume-mounted state files end up with the right
ownership. The entrypoint chowns the top-level HERMES_HOME once, but
subdirectories created at runtime by ``ensure_hermes_home()`` (especially
for profile namespaces under ``profiles/<name>/``) need the same chown
or they land as ``root:root`` and block subsequent uid-mapped workers
with ``PermissionError [Errno 13]``. See #34107.
Returns ``(uid, gid)`` parsed from the env vars, or ``(None, None)``
when either is missing/invalid. Returns ``(None, None)`` on Windows
too (where chown is a no-op anyway).
"""
if sys.platform == "win32":
return None, None
uid_str = os.environ.get("HERMES_UID", "").strip()
gid_str = os.environ.get("HERMES_GID", "").strip()
try:
uid = int(uid_str) if uid_str else None
except ValueError:
uid = None
try:
gid = int(gid_str) if gid_str else None
except ValueError:
gid = None
return uid, gid
def _chown_to_hermes_uid(path) -> None:
"""Chown ``path`` to ``HERMES_UID:HERMES_GID`` if those env vars are set.
No-op when:
- Either env var is unset/invalid
- The current process isn't root (chown will EPERM — silently ignored)
- On Windows (chown semantics don't apply)
Used by :func:`_secure_dir` to keep ownership consistent across all
directories created by :func:`ensure_hermes_home` on Docker deployments.
See #34107.
"""
uid, gid = _resolve_hermes_uid_gid()
if uid is None and gid is None:
return
try:
# os.chown with -1 means "don't change" for that field.
os.chown(
path,
uid if uid is not None else -1,
gid if gid is not None else -1,
)
except (OSError, AttributeError, NotImplementedError):
# OSError covers EPERM (not running as root) and ENOENT (race),
# both of which are non-fatal — the dir is still created and
# the entrypoint's startup chown -R will fix it on next restart.
pass
def _secure_dir(path):
"""Set directory to owner-only access (0700 by default). No-op on Windows.
Skipped in managed mode — the NixOS module sets group-readable
permissions (0750) so interactive users in the hermes group can
share state with the gateway service.
The mode can be overridden via the HERMES_HOME_MODE environment variable
(e.g. HERMES_HOME_MODE=0701) for deployments where a web server (nginx,
caddy, etc.) needs to traverse HERMES_HOME to reach a served subdirectory.
The execute-only bit on a directory permits cd-through without exposing
directory listings.
Also applies ``HERMES_UID``/``HERMES_GID``-based ownership when those env
vars are set (#34107 — Docker deployments need this so profile subdirs
created at runtime by kanban workers don't land as root:root and block
subsequent uid-mapped workers).
"""
if is_managed():
return
try:
mode_str = os.environ.get("HERMES_HOME_MODE", "").strip()
mode = int(mode_str, 8) if mode_str else 0o700
except ValueError:
mode = 0o700
try:
os.chmod(path, mode)
except (OSError, NotImplementedError):
pass
_chown_to_hermes_uid(path)
def _is_container() -> bool:
"""Detect if we're running inside a Docker/Podman/LXC container.
When Hermes runs in a container with volume-mounted config files, forcing
0o600 permissions breaks multi-process setups where the gateway and
dashboard run as different UIDs or the volume mount requires broader
permissions.
"""
# Explicit opt-out
if os.environ.get("HERMES_CONTAINER") or os.environ.get("HERMES_SKIP_CHMOD"):
return True
# Docker / Podman marker file
if os.path.exists("/.dockerenv"):
return True
# LXC / cgroup-based detection
try:
with open("/proc/1/cgroup", "r", encoding="utf-8") as f:
cgroup_content = f.read()
if "docker" in cgroup_content or "lxc" in cgroup_content or "kubepods" in cgroup_content:
return True
except (OSError, IOError):
pass
return False
def _secure_file(path):
"""Set file to owner-only read/write (0600). No-op on Windows.
Skipped in managed mode — the NixOS activation script sets
group-readable permissions (0640) on config files.
Skipped in containers — Docker/Podman volume mounts often need broader
permissions. Set HERMES_SKIP_CHMOD=1 to force-skip on other systems.
"""
if is_managed() or _is_container():
return
try:
if os.path.exists(str(path)):
os.chmod(path, 0o600)
except (OSError, NotImplementedError):
pass
def _ensure_default_soul_md(home: Path) -> None:
"""Seed a default SOUL.md into HERMES_HOME, upgrading legacy empty templates.
First run: write DEFAULT_SOUL_MD. Existing installs whose SOUL.md is still
the old comment-only scaffold (seeded by older install.sh / install.ps1 /
docker images, which shadowed the runtime default) get upgraded in place to
DEFAULT_SOUL_MD. A SOUL.md the user actually customized is never touched.
"""
soul_path = home / "SOUL.md"
if soul_path.exists():
try:
existing = soul_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return
if not is_legacy_template_soul(existing):
return
# Legacy empty template -> upgrade to the real default in place.
soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8")
_secure_file(soul_path)
# Home paths whose directory skeleton has been created this process — see
# ensure_hermes_home(). Only successful passes are recorded, so a raised
# managed-mode/missing-profile error keeps re-checking on later loads.
_HERMES_HOME_ENSURED: set = set()
def ensure_hermes_home():
"""Ensure ~/.hermes directory structure exists with secure permissions.
In managed mode (NixOS), dirs are created by the activation script with
setgid + group-writable (2770). We skip mkdir and set umask(0o007) so
any files created (e.g. SOUL.md) are group-writable (0660).
Memoized per home path: this runs on EVERY ``load_config()`` (inside the
config lock), and the ~14 mkdir/chmod syscalls per call made repeated
config loads the dominant cost of hot read paths like ``model.options``.
After the first successful pass for a given ``HERMES_HOME`` we only re-run
the full walk if the home directory itself has vanished (a deleted home is
recreated on the next load, as before). Profile switches change
``get_hermes_home()`` and therefore re-run for the new path.
"""
home = get_hermes_home()
key = str(home)
if key in _HERMES_HOME_ENSURED and home.is_dir():
return
# Named profiles must be created explicitly (e.g. ``hermes profile create``).
# If a stale process keeps running after the profile was renamed/deleted,
# silently mkdir-ing the old HERMES_HOME would resurrect an empty skeleton
# and make the deleted profile reappear in Desktop/profile lists.
if home.parent.name == "profiles" and not home.exists():
raise FileNotFoundError(
f"Named profile home does not exist: {home}. "
"Create the profile explicitly before using it."
)
if is_managed():
old_umask = os.umask(0o007)
try:
_ensure_hermes_home_managed(home)
finally:
os.umask(old_umask)
else:
home.mkdir(parents=True, exist_ok=True)
_secure_dir(home)
for subdir in (
"cron", "sessions", "logs", "logs/curator", "memories",
"pairing", "hooks", "image_cache", "audio_cache", "skills",
):
d = home / subdir
d.mkdir(parents=True, exist_ok=True)
_secure_dir(d)
_ensure_default_soul_md(home)
_HERMES_HOME_ENSURED.add(key)
def _ensure_hermes_home_managed(home: Path):
"""Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md."""
if not home.is_dir():
raise RuntimeError(
f"HERMES_HOME {home} does not exist. "
"Run 'sudo nixos-rebuild switch' first."
)
for subdir in ("cron", "sessions", "logs", "memories"):
d = home / subdir
if not d.is_dir():
raise RuntimeError(
f"{d} does not exist. "
"Run 'sudo nixos-rebuild switch' first."
)
# Curator reports dir is a sub-path of logs/; create it if missing.
# In managed mode the activation script may not know about this subdir,
# so we mkdir it ourselves (it's inside an already-secured logs/ dir).
(home / "logs" / "curator").mkdir(parents=True, exist_ok=True)
# Inside umask(0o007) scope — SOUL.md will be created as 0660
_ensure_default_soul_md(home)
# =============================================================================
# Config loading/saving
# =============================================================================
DEFAULT_CONFIG = {
"model": "",
"providers": {},
"fallback_providers": [],
"credential_pool_strategies": {},
"toolsets": ["hermes-cli"],
# Global active chat session cap across CLI, TUI/dashboard, and messaging.
# None/0 = unbounded.
"max_concurrent_sessions": None,
# Soft LRU cap on in-memory TUI/desktop/dashboard sessions. When more than
# this many are live, the gateway evicts the least-recently-active DETACHED
# sessions (no live client) so accumulated agents don't pile up under memory
# pressure. Reopening one re-resumes it from disk. 0/null disables.
"max_live_sessions": 16,
"agent": {
"max_turns": 90,
# Inactivity timeout for gateway agent execution (seconds).
# The agent can run indefinitely as long as it's actively calling
# tools or receiving API responses. Only fires when the agent has
# been completely idle for this duration. 0 = unlimited.
"gateway_timeout": 1800,
# Graceful drain timeout for gateway stop/restart (seconds).
# The gateway stops accepting new work, waits for running agents
# to finish, then interrupts any remaining runs after the timeout.
# 0 = no drain, interrupt immediately (the default).
#
# Contract: if you restart the gateway, in-flight work stops. We do
# not hold the restart open for a grace window — a drain timeout
# large enough to "save" a long agent turn would have to outlast an
# unbounded task (some runs take days), which is impossible, and a
# drain timeout shorter than systemd's TimeoutStopSec invites a
# SIGKILL-mid-cleanup race that leaves a stale lock and crash-loops
# the service. 0 sidesteps both: interrupt now, clean up, exit fast.
# Set a positive value in config.yaml only if you explicitly want a
# grace window on /restart (and keep it well under TimeoutStopSec).
"restart_drain_timeout": 0,
# Max app-level retry attempts for API errors (connection drops,
# provider timeouts, 5xx, etc.) before the agent surfaces the
# failure. The OpenAI SDK already does its own low-level retries
# (max_retries=2 default) for transient network errors; this is
# the Hermes-level retry loop that wraps the whole call. Lower
# this to 1 if you use fallback providers and want fast failover
# on flaky primaries; raise it if you prefer to tolerate longer
# provider hiccups on a single provider.
"api_max_retries": 3,
"service_tier": "",
# Tool-use enforcement: injects system prompt guidance that tells the
# model to actually call tools instead of describing intended actions.
# Values: "auto" (default — applies to gpt/codex models), true/false
# (force on/off for all models), or a list of model-name substrings
# to match (e.g. ["gpt", "codex", "gemini", "qwen"]).
"tool_use_enforcement": "auto",
# Intent-ack continuation: when the model opens a turn by narrating an
# action it will take ("I'll go check the logs...") but emits no tool
# call, intercept the turn-end, inject a "continue now, execute the
# tools" nudge, and loop instead of ending the turn (capped at 2 nudges
# per turn). This is the corrective sibling of tool_use_enforcement (the
# preventive prompt-side guard). Values: "auto" (default — fires only on
# the codex_responses api_mode, the historical behavior), true (all
# api_modes — fixes the Gemini/Claude "stops after stating intent" case),
# false (never), or a list of model-name substrings to match.
"intent_ack_continuation": "auto",
# Universal "finish the job" guidance — short prompt block applied to
# all models that targets two cross-family failure modes: (1) stopping
# after a stub instead of finishing the artifact, (2) fabricating
# plausible-looking output when a real path is blocked. Costs ~80
# tokens in the cached system prompt. Set False to disable globally.
"task_completion_guidance": True,
# Universal parallel-tool-call guidance — short prompt block applied to
# all models that tells the model to batch independent tool calls
# (reads, searches, web fetches, read-only commands) into one turn
# instead of one call per turn. The runtime already runs independent
# calls concurrently, so this just steers the model to produce the
# batch — cutting round-trips and the resent-context cost that
# compounds over a long conversation. Costs ~70 tokens in the cached
# system prompt. Set False to disable globally.
"parallel_tool_call_guidance": True,
# Local-environment toolchain probe — surfaces Python/pip/uv/PEP-668
# state in the system prompt when something non-default is detected
# (e.g. python3 has no pip module, pip→python version mismatch, PEP
# 668 enforcement without uv). Costs zero tokens when the env is
# clean (probe emits nothing). Skipped for remote terminal backends
# (docker/modal/ssh — they have their own probe). Set False to
# disable entirely.
"environment_probe": True,
# Embedder-supplied environment description appended to the system
# prompt's environment-hints block. Lets a host that wraps Hermes
# (sandbox runner, managed platform) explain the runtime environment
# — proxy, credential handling, mount layout — without editing the
# identity slot (SOUL.md). Empty by default. The HERMES_ENVIRONMENT_HINT
# env var overrides this (build-time/container mechanism).
"environment_hint": "",
# Coding posture — on interactive coding surfaces (CLI, TUI, desktop
# app, ACP) in a code workspace, Hermes adds a coding operating brief
# + a live git/workspace snapshot to the system prompt. See
# agent/coding_context.py.
# "auto" (default) — prompt-only posture when the surface is
# interactive AND cwd is a code workspace.
# Toolsets are never touched; messaging platforms
# unaffected.
# "focus" — auto + collapse the toolset to the lean coding
# set (+ enabled MCP servers) + demote non-coding
# skill categories to names-only in the prompt's
# skill index. Explicit opt-in.
# "on" — force the prompt posture everywhere.
# "off" — disable entirely.
"coding_context": "auto",
# Standing operator instructions for the coding posture. A string (or
# list of strings) appended to the coding brief as an extra stable
# system block — pin project-wide workflow rules here instead of editing
# the shipped brief, e.g. "For UI work, don't run tsc/lint until I
# approve. Clean the diff before you commit and push." Cache-safe:
# takes effect next session. Empty by default.
"coding_instructions": "",
# When verify-on-stop finds edited code without fresh verification
# evidence, append guidance for creative UI work (avoid broad
# tsc/lint/test before visual approval) and clean-diff expectations.
# Set false to keep the evidence nudge terse.
"verify_guidance": True,
# Upper bound on consecutive `pre_verify` "continue" nudges in a single
# turn, so a user/plugin hook can never trap the loop.
"max_verify_nudges": 3,
# Verification closure: after the agent edits files in a code workspace,
# do not accept a final answer until fresh verification evidence exists
# or the agent explains why it cannot run checks. The loop is bounded
# and uses the passive verification ledger. Default is "auto" —
# surface-aware: on for interactive coding surfaces (CLI, TUI, desktop)
# and programmatic callers, off for conversational messaging surfaces
# (Telegram, Discord, etc.) where the verification narrative would reach
# a human as chat noise. Doc/markdown/skill-only edits never fire it.
# Set true to force on everywhere, or false to disable.
"verify_on_stop": "auto",
# Staged inactivity warning: send a warning to the user at this
# threshold before escalating to a full timeout. The warning fires
# once per run and does not interrupt the agent. 0 = disable warning.
"gateway_timeout_warning": 900,
# Maximum time (seconds) the gateway will block an agent waiting for
# a clarify-tool response from the user. Hit this and the agent
# unblocks with "[user did not respond within Xm]" so it can adapt
# rather than pinning the running-agent guard forever. CLI clarify
# blocks indefinitely (input() is synchronous) and ignores this.
# Default 3600 (1h): real users step away (meetings, AFK) and the
# old 600s default evicted the entry mid-think, so a later button
# tap landed on a dead entry (#32762). Tradeoff: a higher value
# holds the gateway's running-agent guard longer for a genuinely
# abandoned prompt — lower it if a single session must free up the
# guard sooner.
"clarify_timeout": 3600,
# Periodic "still working" notification interval (seconds).
# Sends a status message every N seconds so the user knows the
# agent hasn't died during long tasks. 0 = disable notifications.
# Lower values mean faster feedback on slow tasks but more chat
# noise; 180s is a compromise that catches spinning weak-model runs
# (60+ tool iterations with tiny output) before users assume the
# bot is dead and /restart.
"gateway_notify_interval": 180,
# Freshness window for the gateway auto-continue note (seconds).
# After a gateway crash/restart/SIGTERM mid-run, the next user
# message gets a "[System note: your previous turn was
# interrupted — process the unfinished tool result(s) first]"
# prepended so the model picks up where it left off. That's the
# right behaviour while the interruption is fresh, but stale
# markers (transcript last touched hours or days ago) can revive
# an unrelated old task when the user's next message starts new
# work. This window is the max age of the last persisted
# transcript row for which we still inject the continue note.
# Default 3600s comfortably covers a long turn (gateway_timeout
# default is 1800s) plus runtime slack. Set to 0 to disable the
# gate and restore pre-fix behaviour (always inject).
"gateway_auto_continue_freshness": 3600,
# Stale-stream ceiling for local providers (Ollama, oMLX, llama-cpp) in
# seconds. When the base stale timeout is at its default (180s) and a
# local endpoint is detected, this finite ceiling replaces the former
# infinite disable so a wedged local server eventually trips the
# detector instead of hanging forever. The env var
# ``HERMES_LOCAL_STREAM_STALE_TIMEOUT`` overrides for escape-hatch use.
"local_stream_stale_timeout": 900,
# How user-attached images are presented to the main model on each turn.
# "auto" — attach natively when the active model reports
# supports_vision=True AND the user hasn't explicitly
# configured auxiliary.vision.provider. Otherwise fall
# back to text (vision_analyze pre-analysis).
# "native" — always attach natively; non-vision models will either
# error at the provider or get a last-chance text fallback
# (see run_agent._prepare_messages_for_api).
# "text" — always pre-analyze with vision_analyze and prepend the
# description as text; the main model never sees pixels.
# Affects gateway platforms, the TUI, and CLI /attach. vision_analyze
# remains available as a tool regardless of this setting — the routing
# only controls how inbound user images are presented.
"image_input_mode": "auto",
"disabled_toolsets": [],
# Per-model reasoning effort overrides (spelling-tolerant).
# Dict mapping model names (any reasonable spelling) to effort levels.
# Takes precedence over agent.reasoning_effort when the current model
# matches a key in this dict.
# Edit directly in config.yaml (no CLI support due to dots in keys).
"reasoning_overrides": {},
},
"terminal": {
"backend": "local",
"modal_mode": "auto",
"cwd": ".", # Use current directory
"timeout": 180,
# Bounded grace period (seconds) between SIGTERM and an escalated
# SIGKILL when terminating a host process tree (browser daemons, etc.).
# A daemon that stalls in its SIGTERM handler is force-killed after this
# window so it can't leak indefinitely. 0 disables escalation (SIGTERM
# only — the historical behavior). Floored internally at 0.
"daemon_term_grace_seconds": 2.0,
# Environment variables to pass through to sandboxed execution
# (terminal and execute_code). Skill-declared required_environment_variables
# are passed through automatically; this list is for non-skill use cases.
"env_passthrough": [],
# HOME handling for host tool subprocesses:
# auto — host keeps the real OS-user HOME; containers use
# HERMES_HOME/home for persistent state (default)
# real — force the real OS-user HOME
# profile — force HERMES_HOME/home when it exists (old strict
# per-profile CLI config isolation)
"home_mode": "auto",
# Extra files to source in the login shell when building the
# per-session environment snapshot. Use this when tools like nvm,
# pyenv, asdf, or custom PATH entries are registered by files that
# a bash login shell would skip — most commonly ``~/.bashrc``
# (bash doesn't source bashrc in non-interactive login mode) or
# zsh-specific files like ``~/.zshrc`` / ``~/.zprofile``.
# Paths support ``~`` / ``${VAR}``. Missing files are silently
# skipped. When empty, Hermes auto-sources ``~/.profile``,
# ``~/.bash_profile``, and ``~/.bashrc`` (in that order) if the
# snapshot shell is bash (this is the ``auto_source_bashrc``
# behaviour — disable with that key if you want strict login-only
# semantics).
"shell_init_files": [],
# When true (default), Hermes sources the user's shell rc files
# (``~/.profile``, ``~/.bash_profile``, ``~/.bashrc``) in the
# login shell used to build the environment snapshot. This
# captures PATH additions, shell functions, and aliases — which a
# plain ``bash -l -c`` would otherwise miss because bash skips
# bashrc in non-interactive login mode, and because a default
# Debian/Ubuntu ``~/.bashrc`` short-circuits on non-interactive
# sources. ``~/.profile`` and ``~/.bash_profile`` are tried first
# because ``n`` / ``nvm`` / ``asdf`` installers typically write
# their PATH exports there without an interactivity guard. Turn
# this off if your rc files misbehave when sourced
# non-interactively (e.g. one that hard-exits on TTY checks).
"auto_source_bashrc": True,
"docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"docker_forward_env": [],
# Explicit environment variables to set inside Docker containers.
# Unlike docker_forward_env (which reads values from the host process),
# docker_env lets you specify exact key-value pairs — useful when Hermes
# runs as a systemd service without access to the user's shell environment.
# Example: {"SSH_AUTH_SOCK": "/run/user/1000/ssh-agent.sock"}
"docker_env": {},
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
"modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
# Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)
"container_cpu": 1,
"container_memory": 5120, # MB (default 5GB)
"container_disk": 51200, # MB (default 50GB)
"container_persistent": True, # Persist filesystem across sessions
# Docker volume mounts — share host directories with the container.
# Each entry is "host_path:container_path" (standard Docker -v syntax).
# Example:
# ["/home/user/projects:/workspace/projects",
# "/home/user/.hermes/cache/documents:/output"]
# For gateway MEDIA delivery, write inside Docker to /output/... and emit
# the host-visible path in MEDIA:, not the container path.
"docker_volumes": [],
# Explicit opt-in: mount the host cwd into /workspace for Docker sessions.
# Default off because passing host directories into a sandbox weakens isolation.
"docker_mount_cwd_to_workspace": False,
# Opt-in egress lockdown for Docker terminal sessions. When false,
# Docker runs with --network=none so commands cannot reach the network.
"docker_network": True,
"docker_extra_args": [], # Extra flags passed verbatim to docker run
# Explicit opt-in: run the Docker container as the host user's uid:gid
# (via `--user`). When enabled, files written into bind-mounted dirs
# (docker_volumes, the persistent workspace, or the auto-mounted cwd)
# are owned by your host user instead of root, which avoids needing
# `sudo chown` after container runs. Default off to preserve behavior
# for images whose entrypoints expect to start as root (e.g. the
# bundled Hermes image, which drops to the `hermes` user via
# s6-setuidgid inside each supervised service).
# When on, SETUID/SETGID caps are omitted from the container since
# no privilege drop is needed.
"docker_run_as_host_user": False,
# Persistent shell — keep a long-lived bash shell across execute() calls
# so cwd/env vars/shell variables survive between commands.
# Enabled by default for non-local backends (SSH); local is always opt-in
# via TERMINAL_LOCAL_PERSISTENT env var.
"persistent_shell": True,
},
"web": {
"backend": "", # shared fallback — applies to both search and extract
"search_backend": "", # per-capability override for web_search (e.g. "searxng")
"extract_backend": "", # per-capability override for web_extract (e.g. "native")
"extract_char_limit": 15000, # per-page char budget for web_extract; larger pages truncate + store full text in cache/web
},
"browser": {
"inactivity_timeout": 120,
"command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)
"record_sessions": False, # Auto-record browser sessions as WebM videos
"headed": False, # Local mode: launch Chromium with a visible window (also skips per-turn cleanup so the window persists between turns; idle reaper still applies)
"allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)
# Browser engine for local mode. Passed as ``--engine <value>`` to
# agent-browser v0.25.3+.
# "auto" — use Chrome (default, don't pass --engine at all)
# "lightpanda" — use Lightpanda (1.3-5.8x faster navigation, no screenshots)
# "chrome" — explicitly request Chrome
# Also settable via AGENT_BROWSER_ENGINE env var.
"engine": "auto",
"auto_local_for_private_urls": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud
"cdp_url": "", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome
"allow_unsafe_evaluate": False, # Legacy override: when true, browser_console(expression=...) bypasses the restrict_evaluate denylist entirely
"restrict_evaluate": False, # Opt-in denylist blocking sensitive JS primitives (cookies/storage/clipboard/network/form values) in browser_console(expression=...)
# CDP supervisor — dialog + frame detection via a persistent WebSocket.
# Active only when a CDP-capable backend is attached (Browserbase or
# local Chrome via /browser connect). See
# website/docs/developer-guide/browser-supervisor.md.
"dialog_policy": "must_respond", # must_respond | auto_dismiss | auto_accept
"dialog_timeout_s": 300, # Safety auto-dismiss after N seconds under must_respond
"camofox": {
# When true, Hermes sends a stable profile-scoped userId to Camofox
# so the server maps it to a persistent Firefox profile automatically.
# When false (default), each session gets a random userId (ephemeral).
"managed_persistence": False,
# Optional externally managed Camofox identity. Useful when another
# app owns the visible browser and Hermes should operate in it.
"user_id": "",
"session_key": "",
# Rehydrate tab_id from Camofox before creating a new tab.
"adopt_existing_tab": False,
# Docker Camofox opens page URLs from inside the container. Enable
# this to rewrite loopback page URLs (localhost/127.0.0.1/::1) to a
# host alias while leaving CAMOFOX_URL itself unchanged.
"rewrite_loopback_urls": False,
"loopback_host_alias": "host.docker.internal",
},
},
# Filesystem checkpoints — automatic snapshots before destructive file ops.
# When enabled, the agent takes a snapshot of the working directory once
# per conversation turn (on first write_file/patch call). Use /rollback
# to restore.
#
# Defaults changed in v2 (single shared shadow store, real pruning):
# - enabled: True -> False (opt-in; most users never use /rollback)
# - max_snapshots: 50 -> 20 (now actually enforced via ref rewrite)
# - auto_prune: False -> True (orphans/stale pruned automatically)
# Opt in via ``hermes chat --checkpoints`` or set enabled=True here.
"checkpoints": {
"enabled": False,
# Max checkpoints to keep per working directory. Pre-v2 this only
# limited the `/rollback` listing; v2 actually rewrites the ref and
# garbage-collects older commits.
"max_snapshots": 20,
# Hard ceiling on total ``~/.hermes/checkpoints/`` size (MB). When
# exceeded, the oldest checkpoint per project is dropped in a
# round-robin pass until total size falls under the cap.
# 0 disables the size cap.
"max_total_size_mb": 500,
# Skip any single file larger than this when staging a checkpoint.
# Prevents accidental snapshotting of datasets, model weights, and
# other large generated assets. 0 disables the filter.
"max_file_size_mb": 10,
# Auto-maintenance: hermes sweeps the checkpoint base at startup
# (at most once per ``min_interval_hours``) and:
# * deletes project entries whose workdir no longer exists (orphan)
# * deletes project entries whose last_touch is older than
# ``retention_days``
# * GCs the single shared store to reclaim unreachable objects
# * enforces ``max_total_size_mb`` across remaining projects
# * deletes ``legacy-*`` archives older than ``retention_days``
"auto_prune": True,
"retention_days": 7,
"delete_orphans": True,
"min_interval_hours": 24,
},
# Hard cap (chars) for a single automatic context file such as SOUL.md,
# AGENTS.md, CLAUDE.md, .hermes.md, or .cursorrules before Hermes applies
# head/tail truncation. ``null`` (the default) lets the cap scale with the
# model's context window (floor 20K, ceiling 500K) so large-context models
# rarely truncate a project doc. Set a positive integer to pin a fixed cap
# and override the dynamic behavior. Separate from read_file tool limits.
"context_file_max_chars": None,
# Maximum characters returned by a single read_file call. Reads that
# exceed this are rejected with guidance to use offset+limit.
# 100K chars ≈ 2535K tokens across typical tokenisers.
"file_read_max_chars": 100_000,
# Seconds to wait at agent-build time for in-flight MCP server discovery
# to finish before the agent snapshots its tool list. MCP discovery runs
# in a background thread so a slow/dead server can't freeze startup; this
# bounds how long the first agent build blocks on it. The wait returns
# the INSTANT discovery completes, so users with no MCP servers (the common
# case) or fast servers pay ~0s regardless of this value — the bound is
# only reached when a server is genuinely still connecting. The old 0.75s
# default was a touch short for HTTP/OAuth servers on a cold connect; a
# modest bump lets more of them land in the FIRST turn's snapshot. This is
# only a turn-1 latency/UX knob: a server that misses this window is still
# picked up automatically on the next turn by the between-turns refresh
# (see agent/turn_context.py), so correctness never depends on it. Keep it
# small so a slow/dead server adds little to first-response latency.
"mcp_discovery_timeout": 1.5,
# MCP runtime behavior (distinct from the per-server definitions in
# mcp_servers: and from the auxiliary.mcp side-LLM task settings).
"mcp": {
# Auto-reload MCP connections when config.yaml's mcp_servers section
# changes at runtime (CLI file watcher, default on).
# Set to false to stop the automatic reload: every automatic reload
# rebuilds the agent tool surface and INVALIDATES the provider
# prompt cache (the next message re-sends the full input prefix),
# which is expensive on long-context / high-reasoning models.
# When disabled, the watcher still detects the change and prints
# guidance to apply it deliberately via /reload-mcp.
"auto_reload_on_config_change": True,
},
# Tool-output truncation thresholds. When terminal output or a
# single read_file page exceeds these limits, Hermes truncates the
# payload sent to the model (keeping head + tail for terminal,
# enforcing pagination for read_file). Tuning these trades context
# footprint against how much raw output the model can see in one
# shot. Ported from anomalyco/opencode PR #23770.
#
# - max_bytes: terminal_tool output cap, in chars
# (default 50_000 ≈ 12-15K tokens).
# - max_lines: read_file pagination cap — the maximum `limit`
# a single read_file call can request before
# being clamped (default 2000).
# - max_line_length: per-line cap applied when read_file emits a
# line-numbered view (default 2000 chars).
"tool_output": {
"max_bytes": 50_000,
"max_lines": 2000,
"max_line_length": 2000,
},
# Tool loop guardrails nudge models when they repeat failed or
# non-progressing tool calls. Soft warnings are always-on by default;
# hard stops are opt-in so interactive CLI/TUI sessions keep flowing.
"tool_loop_guardrails": {
"warnings_enabled": True,
"hard_stop_enabled": False,
"warn_after": {
"exact_failure": 2,
"same_tool_failure": 3,
"idempotent_no_progress": 2,
},
"hard_stop_after": {
"exact_failure": 5,
"same_tool_failure": 8,
"idempotent_no_progress": 5,
},
},
"compression": {
"enabled": True,
"threshold": 0.50, # compress when context usage exceeds this ratio.
# Models with context windows below 512K are
# floored at 0.75 (raise-only) so compaction
# doesn't fire with half the window still free;
# set this above 0.75 to override the floor.
"target_ratio": 0.20, # fraction of threshold to preserve as recent tail
"protect_last_n": 20, # minimum recent messages to keep uncompressed
"hygiene_hard_message_limit": 5000, # gateway session-hygiene force-compress threshold by message count
"protect_first_n": 3, # non-system head messages always preserved
# verbatim, in ADDITION to the system prompt
# (which is always implicitly protected). Set to
# 0 for long-running rolling-compaction sessions
# where you want nothing pinned except the
# system prompt + rolling summary + recent tail.
"abort_on_summary_failure": False, # When True, auto-compression that fails
# to generate a summary (aux LLM errored / returned
# non-JSON / timed out) aborts entirely instead of
# dropping the middle window with a static
# "summary unavailable" placeholder. Messages are
# preserved unchanged and the session "freezes" at
# its current size until the user runs /compress
# (which bypasses the failure cooldown) or /new.
# Default False matches historical behavior; set to
# True if you'd rather pause than silently lose
# context turns when your aux model is flaky.
"codex_gpt55_autoraise": True, # Historical key name kept for compatibility.
# When True, gpt-5.4 / gpt-5.5 / gpt-5.6 on the
# ChatGPT Codex OAuth route raise their compaction
# trigger to 85% (vs the global `threshold` above).
# Codex hard-caps these families at a 272K window, so
# the default 50% would compact at ~136K and waste half
# the usable context. Set to False to opt back down to
# the global threshold (e.g. 0.50) for those Codex
# sessions. Only this exact route is affected —
# gpt-5.4 / 5.5 / 5.6 on OpenAI's direct API,
# OpenRouter, and Copilot keep the global threshold
# regardless.
"codex_gpt55_autoraise_notice": True, # Display the one-time Codex gpt-5.4/5.5/5.6
# autoraise banner. Set False to keep the
# 85% threshold autoraise but suppress the
# user-facing notice in CLI/gateway output.
"codex_app_server_auto": "native", # Codex app-server (codex CLI runtime) thread
# compaction mode. The codex agent owns the real
# thread context, so Hermes' summarizer cannot
# shrink it (#36801). native = codex decides when
# to compact its own thread (default); hermes =
# Hermes' compression threshold triggers
# thread/compact/start; off = never auto-trigger
# (codex may still compact natively).
"in_place": True, # When True, compaction rewrites the message
# list and rebuilds the system prompt WITHOUT
# rotating the session id — the conversation
# keeps one durable id for its whole life
# (no parent_session_id chain, no `name #N`
# renumbering). Eliminates the session-rotation
# bug cluster (#33618 /goal loss, #14238 lost
# response, #33907 orphans, #45117 search gaps,
# #42228 null cwd) — see #38763. Non-destructive:
# the live context is compacted (lossy for what
# the model reloads), but the pre-compaction
# turns are soft-archived under the same id
# (active=0, compacted=1) — still searchable via
# session_search and recoverable, not deleted.
# Default False during rollout; will flip on
# after live validation.
},
# Kanban subsystem (orchestrator workers + dispatcher-driven child tasks).
# See tools/kanban_tools.py and hermes_cli/kanban_db.py for the actual
# implementations. Per-platform notification opt-out is handled by the
# kanban dashboard (see ``hermes dashboard`` -> Notifications).
"kanban": {
# Auto-subscribe the originating gateway/TUI session to task
# completion + block events when ``kanban_create`` is called from
# inside a session that has a persistent delivery channel. The
# agent that dispatched the task will get notified automatically
# instead of having to poll. Disable to mirror pre-feature
# behaviour — e.g. for a profile that prefers explicit
# ``kanban_notify-subscribe`` calls per task.
"auto_subscribe_on_create": True,
},
# Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).
# cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored.
"prompt_caching": {
"cache_ttl": "5m",
},
# OpenRouter-specific settings.
# response_cache: enable OpenRouter response caching (X-OpenRouter-Cache header).
# When enabled, identical requests return cached responses for free (zero billing).
# This is separate from Anthropic prompt caching and works alongside it.
# See: https://openrouter.ai/docs/guides/features/response-caching
# response_cache_ttl: how long cached responses remain valid, in seconds (1-86400).
# Default 300 (5 minutes). Only used when response_cache is enabled.
# min_coding_score: knob for the openrouter/pareto-code router (0.0-1.0).
# Only applied when model.model is "openrouter/pareto-code". Higher
# values route to stronger (more expensive) coders; lower values open
# up cheaper, faster options. Default 0.65 lands on the mid-tier
# coder on the current Pareto frontier. Empty string = let OpenRouter
# pick the strongest available coder (router's documented default
# when the plugins block is omitted).
# See: https://openrouter.ai/docs/guides/routing/routers/pareto-router
"openrouter": {
"response_cache": True,
"response_cache_ttl": 300,
"min_coding_score": 0.65,
},
# AWS Bedrock provider configuration.
# Only used when model.provider is "bedrock".
"bedrock": {
"region": "", # AWS region for Bedrock API calls (empty = AWS_REGION env var → us-east-1)
"discovery": {
"enabled": True, # Auto-discover models via ListFoundationModels
"provider_filter": [], # Only show models from these providers (e.g. ["anthropic", "amazon"])
"refresh_interval": 3600, # Cache discovery results for this many seconds
},
"guardrail": {
# Amazon Bedrock Guardrails — content filtering and safety policies.
# Create a guardrail in the Bedrock console, then set the ID and version here.
# See: https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html
"guardrail_identifier": "", # e.g. "abc123def456"
"guardrail_version": "", # e.g. "1" or "DRAFT"
"stream_processing_mode": "async", # "sync" or "async"
"trace": "disabled", # "enabled", "disabled", or "enabled_full"
},
},
# Auxiliary model config — provider:model for each side task.
# Format: provider is the provider name, model is the model slug.
# "auto" for provider = auto-detect best available provider.
# Empty model = use provider's default auxiliary model.
# All tasks fall back to openrouter:google/gemini-3-flash-preview if
# the configured provider is unavailable.
#
# extra_body: forwarded verbatim as request body fields on every aux call
# for that task. Use this to set provider-specific knobs (independent of
# main-agent settings). On OpenRouter you can set provider routing prefs
# and the Pareto Code coding-score floor here. Example:
#
# auxiliary:
# compression:
# provider: openrouter
# model: openrouter/pareto-code
# extra_body:
# provider: # OpenRouter provider routing
# order: [anthropic, google]
# sort: throughput # or price | latency
# plugins: # OpenRouter Pareto Code router
# - id: pareto-router
# min_coding_score: 0.5
#
# Each aux task is independent — main-agent provider_routing and
# openrouter.min_coding_score do NOT propagate to aux calls by design.
"auxiliary": {
# Same-provider retries for a transient transport blip (connection
# reset / timeout / 5xx / 408) on ANY auxiliary call before falling
# back. Default 2 (→ 3 total attempts), clamped [0,6]. Matters most for
# pinned calls like MoA reference advisors, where provider fallback is
# not a meaningful recovery, so an unretried blip silently loses the
# call.
"transient_retries": 2,
"vision": {
"provider": "auto", # auto | openrouter | nous | codex | custom
"model": "", # e.g. "google/gemini-2.5-flash", "gpt-4o"
"base_url": "", # direct OpenAI-compatible endpoint (takes precedence over provider)
"api_key": "", # API key for base_url (falls back to OPENAI_API_KEY)
"timeout": 120, # seconds — LLM API call timeout; vision payloads need generous timeout
"extra_body": {}, # OpenAI-compatible provider-specific request fields
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
"download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections
},
"web_extract": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
"compression": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 120, # seconds — compression summarises large contexts; increase for local models
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Note: session_search no longer uses an auxiliary LLM (PR #27590 —
# single-shape tool returns DB content directly). The old
# ``auxiliary.session_search.*`` block was removed here. Existing
# values in user config.yaml files are harmless leftovers and ignored.
"skills_hub": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
"approval": {
"provider": "auto",
"model": "", # fast/cheap model recommended (e.g. gemini-flash, haiku)
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
"mcp": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
"title_generation": {
"enabled": True,
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
"language": "",
},
"memory_query_rewrite": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 8,
"extra_body": {},
},
"tts_audio_tags": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Triage specifier — flesh out a rough one-liner in the Kanban
# Triage column into a concrete spec, then promote it to ``todo``.
# Invoked by ``hermes kanban specify`` (single id or --all). Set a
# cheap, capable model here (gemini-flash works well); the main
# model is overkill for short spec expansion.
"triage_specifier": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 120,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Kanban decomposer — decomposes a triage task into a graph of
# child tasks routed to specialist profiles by description.
# Invoked by ``hermes kanban decompose`` and the kanban
# auto-decompose dispatcher tick. Returns a JSON task graph;
# uses more tokens than the specifier so allow more headroom.
"kanban_decomposer": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 180,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Profile describer — auto-generates a 1-2 sentence description
# of what a profile is good at. Invoked by
# ``hermes profile describe <name> --auto`` and the dashboard's
# auto-generate button. Short, cheap call.
"profile_describer": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 60,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Goal judge — evaluates whether a /goal run's latest response
# satisfies the goal/contract, and drafts goal contracts. Short
# structured-JSON calls; a fast cheap model is fine.
"goal_judge": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 60,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Curator — skill-usage review fork. Timeout is generous because the
# review pass can take several minutes on reasoning models (umbrella
# building over hundreds of candidate skills). "auto" = use main chat
# model; override via `hermes model` → auxiliary → Curator to route
# to a cheaper aux model (e.g. openrouter google/gemini-3-flash-preview).
"curator": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 600,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Monitor — urgency/importance classifier used by the important-mail
# monitor catalog automation (cron/scripts/classify_items.py). Scores
# candidate items 0-10 against the user's criteria so only above-
# threshold items get delivered. "auto" = main chat model; override to
# a cheap fast model (e.g. openrouter google/gemini-3-flash-preview,
# haiku) since per-item scoring is high-volume and a small model is fine.
"monitor": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 60,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Background review — the post-turn self-improvement fork that decides
# whether to save a memory / patch a skill. "auto" (default) = run on
# the main chat model, replaying the full conversation, which is already
# warm in the prompt cache (cheap cache reads) — unchanged, optimal.
# Set provider/model to a cheaper model (e.g. openrouter
# google/gemini-3-flash-preview) to run the review there for ~3-5x lower
# cost. A different model can't reuse the main prompt cache anyway, so
# the fork automatically replays a compact digest instead of the full
# transcript when routed (minimises the cold-write). Same model = full
# replay; different model = digest. Quality holds (memory capture
# identical, skill near-identical in benchmarks).
"background_review": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 120,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
"moa_reference": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 900,
"extra_body": {},
# NOTE: no reasoning_effort here by design — MoA reasoning depth is
# configured PER SLOT in the MoA preset (moa.presets.<name>.
# reference_models[].reasoning_effort / aggregator.reasoning_effort),
# not at the auxiliary-task level.
},
"moa_aggregator": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 900,
"extra_body": {},
# NOTE: no reasoning_effort here by design — see moa_reference above.
},
},
"display": {
"compact": False,
"personality": "",
"resume_display": "full",
# Recap tuning for /resume and startup resume. The defaults match the
# historical hardcoded values; expose them as config so power users can
# widen or tighten the snapshot to taste.
"resume_exchanges": 10, # max user+assistant pairs to show
"resume_max_user_chars": 300, # truncate user message text
"resume_max_assistant_chars": 200, # truncate non-last assistant text
"resume_max_assistant_lines": 3, # truncate non-last assistant lines
# When True (default), assistant entries that are *only* tool calls
# (no visible text) are skipped in the recap. This prevents the recap
# from being dominated by `[2 tool calls: terminal, read_file]` lines
# when an exchange was tool-heavy. Set False to restore the legacy
# behavior of showing tool-call summaries inline.
"resume_skip_tool_only": True,
"busy_input_mode": "interrupt", # interrupt | queue | steer
# When busy_input_mode="steer", suppress only the visible
# "Steered into current run" confirmation bubble by setting this false.
# The mid-turn steering itself still happens.
"busy_steer_ack_enabled": True,
# Which interface bare `hermes` (and `hermes chat`) launches by default:
# "cli" — the classic prompt_toolkit REPL (default, preserves prior behavior)
# "tui" — the modern Ink TUI (same as passing `--tui`)
# Explicit flags always win over this setting: `--cli` forces the classic
# REPL and `--tui` (or HERMES_TUI=1) forces the TUI regardless of config.
"interface": "cli",
# When true, `hermes --tui` auto-resumes the most recent human-
# facing session on launch instead of forging a fresh one.
# Mirrors `hermes -c` muscle memory. Default off so existing
# users aren't surprised. HERMES_TUI_RESUME=<id> always wins.
"tui_auto_resume_recent": False,
# When true (default), `hermes --tui` drops a one-time hint
# ("subagents working · /agents to watch live") the first time a turn
# starts delegating, nudging the user toward the live spawn-tree
# dashboard. Set false to suppress the hint.
"tui_agents_nudge": True,
"bell_on_complete": False,
# Stream the model's reasoning/thinking live before the response.
# Default ON: on thinking models the reasoning phase can run tens of
# seconds, and with this off the user stares at a spinner the whole
# time even though tokens are streaming. Set false for quiet output.
"show_reasoning": True,
# When reasoning display is on, the post-response "Reasoning" recap box
# collapses long thinking to the first 10 lines. Set true to print the
# complete thinking text uncollapsed (live streaming is always full).
"reasoning_full": False,
# Background self-improvement review notifications surfaced in chat.
# "off" — no chat notification (the review still runs and writes)
# "on" — generic "💾 Memory updated" line (default)
# "verbose" — include a compact content preview of what changed
# Per-platform overrides via display.platforms.<platform>.memory_notifications.
"memory_notifications": "on",
"streaming": False,
"timestamps": False, # Show timestamp on user and assistant labels
"timestamp_format": "%H:%M", # strftime format for timestamps (e.g. "%b-%d %H:%M")
"final_response_markdown": "strip", # render | strip | raw
# Preserve recent classic CLI output across Ctrl+L, /redraw, and
# terminal resize full-screen clears. Disable if a terminal emulator
# behaves badly with replayed scrollback.
"persistent_output": True,
"persistent_output_max_lines": 200,
# Print a one-line summary of resolved modal prompts (approval /
# clarify) into scrollback so the question and decision survive the
# panel repaint. Set false to keep scrollback untouched.
"persist_prompts": True,
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
# File-mutation verifier footer. When true (default), the agent
# appends a one-line advisory to its final response whenever a
# write_file / patch call failed during the turn and was never
# superseded by a successful write to the same path. This catches
# the "batch of parallel patches, half fail, model claims success"
# class of over-claim that otherwise forces users to run
# `git status` to verify edits landed. Set false to suppress.
"file_mutation_verifier": True,
# Nous credits status-bar notices (usage bands, grant-spent, depleted /
# restored). When false, no credits notices are emitted — balance data
# is still captured and /usage keeps working. Off switch for sub +
# top-up users who find the gauge noisy.
"credits_notices": True,
# Turn-completion explainer. When true (default), the agent appends a
# one-line explanation to its final response whenever a turn ends
# abnormally with no usable reply — empty content after retries, a
# partial/truncated stream, a still-pending tool result, or an
# iteration/budget limit. Replaces the bare "(empty)" sentinel so the
# failure isn't silent from the UI's perspective. Set false to suppress.
"turn_completion_explainer": True,
"show_cost": False, # Show $ cost in the status bar (off by default)
# Show a color-coded battery read-out as the first status-bar element in
# the CLI/TUI (off by default). No-op on machines without a battery.
"battery": False,
"skin": "default",
# UI language for static user-facing messages (approval prompts, a
# handful of gateway slash-command replies). Does NOT affect agent
# responses, log lines, tool outputs, or slash-command descriptions.
# Supported: en, zh, ja, de, es, fr, tr, uk. Unknown values fall back to en.
"language": "en",
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
# spinner), or ascii. Live-swappable via `/indicator <style>`.
"tui_status_indicator": "kaomoji",
# Seconds between prompt_toolkit redraws in the classic CLI when idle.
# Default 1.0 keeps the wall-clock status-bar read-outs (idle-since-
# last-turn) ticking and keeps the bottom chrome alive during idle —
# without it prompt_toolkit stops repainting the status bar after a
# turn and it can go stale/disappear (#45592).
# Set 0 to disable the background refresh if it fights terminal
# auto-scroll in non-fullscreen mode on some emulators (#48309).
"cli_refresh_interval": 1.0,
"user_message_preview": { # CLI: how many submitted user-message lines to echo back in scrollback
"first_lines": 2,
"last_lines": 2,
},
"interim_assistant_messages": True, # Gateway: send natural mid-turn assistant status messages. Desktop: keep mid-turn narration between tool calls instead of collapsing to the final message.
# Codex Responses models narrate progress in a dedicated commentary
# channel. When true (default), completed commentary messages are
# delivered as visible mid-turn updates via the interim message path.
# When false, commentary falls back to the reasoning channel and is
# only visible when show_reasoning is enabled.
"show_commentary": True,
"tool_progress_command": False, # Enable /verbose command in messaging gateway
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
# Human-phrased tool status labels for built-in tools: "Searching the
# web for ...", "Reading <file>", "Browsing <url>" instead of the raw
# tool name. Applies to CLI spinner + gateway/desktop tool-progress.
# Custom/plugin/MCP tools always fall back to the raw preview.
"friendly_tool_labels": True,
# How gateway tool-progress is grouped on platforms that support message
# editing: "accumulate" (default) edits one bubble in place; "separate"
# sends one message per tool (the pre-v0.9 behavior, noisier). Only
# applies where tool_progress is already enabled. Per-platform override
# via display.platforms.<platform>.tool_progress_grouping.
"tool_progress_grouping": "accumulate",
# Optional custom phrases for generic long-running status messages.
# Built-in defaults live in gateway/assets/status_phrases.yaml. Users
# can set `path`/`paths` to HERMES_HOME-relative YAML files/directories
# (or rely on conventional status_phrases.yaml / status_phrases/*.yaml).
# Keys: status, generic. Use
# mode: "append" (default) to add phrases, or "replace" to fully
# replace configured surfaces. Per-platform overrides live under
# display.platforms.<platform>.status_phrases.
"status_phrases": {},
# How a reasoning/thinking summary renders when show_reasoning is on.
# "code" (default) = 💭 fenced code block; "blockquote" = "> " lines;
# "subtext" = "-# " lines (Discord small grey metadata text). Discord
# defaults to "subtext"; override per-platform via
# display.platforms.<platform>.reasoning_style.
"reasoning_style": "code",
# Auto-delete system-notice replies (e.g. "✨ New session started!",
# "♻ Restarting gateway…", "⚡ Stopped…") after N seconds on platforms
# that support message deletion (currently Telegram; other platforms
# ignore and leave the message in place). Only affects slash-command
# replies wrapped with gateway.platforms.base.EphemeralReply — agent
# responses and content messages are never touched. Default 0
# (disabled) preserves prior behavior.
"ephemeral_system_ttl": 0,
# Per-platform display/streaming overrides. Each key is a gateway
# platform ("telegram", "discord", "slack", …) mapping to a dict of
# display settings that override the global value for that platform
# only. A setting left unset here falls through to the global default.
#
# Shipped defaults encode the streaming experience that works best
# per platform:
# - Telegram has native animated draft streaming (sendMessageDraft),
# which is smooth, so streaming is on by default there.
# - Discord/Slack/etc. only have edit-based streaming (repeated
# editMessage), which flickers and is noticeably jankier, so
# streaming is off by default there.
# These are gap-fillers: a user who explicitly sets, e.g.,
# display.platforms.discord.streaming: true keeps their value
# (config deep-merge has user values win over defaults). The global
# streaming.enabled master switch still gates everything — these
# per-platform flags only take effect once streaming is enabled.
"platforms": {
"telegram": {"streaming": True},
"discord": {"streaming": False},
},
# Gateway runtime-metadata footer appended to the FINAL message of a turn
# (disabled by default to keep replies minimal). When enabled, renders
# e.g. `model · 68% · ~/projects/hermes`. Per-platform overrides go under
# display.platforms.<platform>.runtime_footer.
"runtime_footer": {
"enabled": False,
"fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide
},
"copy_shortcut": "auto", # "auto" (platform default) | "ctrl_c" | "ctrl_shift_c" | "disabled"
# Petdex animated mascot (https://github.com/crafter-station/petdex).
# A purely cosmetic sprite that reacts to agent activity across the
# CLI, TUI, and desktop app. Manage with `hermes pets`. Disabled until
# a pet is installed + selected (no effect on prompt caching — this is
# a display concern only).
"pet": {
"enabled": False,
# Active pet slug; resolved against installed pets in
# get_hermes_home()/pets/. Empty → first installed pet.
"slug": "",
# Terminal render protocol for CLI/TUI:
# auto — detect kitty/iTerm2/sixel, else unicode half-blocks
# kitty | iterm | sixel | unicode | off
"render_mode": "auto",
# Master size scalar (relative to native 192×208 frames). One knob
# shrinks every surface: the desktop canvas scales its pixels by it
# and the CLI/TUI derive their terminal column width from it. The
# half-block fallback clamps to a legibility floor (it can't shrink
# as far as true-pixel kitty/GUI without turning to mush).
"scale": 0.33,
# Hard override for terminal column width. 0 = auto (derive from
# scale); set a positive int only to pin the half-block/kitty width
# independently of scale.
"unicode_cols": 0,
},
},
# Web dashboard settings
"dashboard": {
"theme": "default", # Dashboard visual theme: "default", "midnight", "ember", "mono", "cyberpunk", "rose"
# Process-isolation rollout controls. Runtime reads these through the
# raw config loader, so tui_gateway.server also owns explicit defaults.
"turn_isolation": False,
"compute_host_heartbeat_secs": 15,
"compute_host_respawn_max": 3,
# Hide the token/cost analytics surfaces (Analytics page, token bars and
# cost figures on the Models page) by default. The numbers shown there
# are a local debug estimate: they only count successful main-agent
# responses with a usable ``response.usage``, and silently exclude every
# auxiliary call (context compression, title generation, vision,
# session search, web extract, smart approval, MCP routing, plugin LLM
# access) plus provider-side retries, fallback attempts, and any call
# whose usage block didn't come back. Cache writes are also missing
# from the API response. On models with heavy auxiliary traffic
# (Kimi K2.6, MiniMax M2.7) the local total can be 10x-100x lower than
# the provider bill, which is worse than hiding the numbers entirely
# because they look precise enough to compare against the provider.
# Set this to True to re-enable the surfaces with the understanding
# that the numbers are a local lower-bound estimate, not billing.
"show_token_analytics": False,
# OAuth gate configuration (engaged when ``--host`` is set and
# ``--insecure`` is not). The bundled Nous Portal plugin reads
# both keys at startup; they are the canonical surface for these
# settings. Each can be overridden by an environment variable —
# ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` and
# ``HERMES_DASHBOARD_PORTAL_URL`` respectively — and the env var
# wins when set to a non-empty value. The override path is what
# Fly.io's platform-secret injection uses to push the per-deploy
# client_id at provisioning time without operators needing to
# touch config.yaml. Local dev / non-Fly deploys can set either
# surface; missing values fall through to the plugin's defaults
# (no provider registered when ``client_id`` is empty;
# ``portal_url`` defaults to https://portal.nousresearch.com).
"oauth": {
"client_id": "", # agent:{instance_id} — Portal provisions this
"portal_url": "", # blank → use plugin default (production Portal)
},
# Username/password gate configuration — read by the bundled
# ``dashboard_auth/basic`` plugin (a self-hosted "just put a
# password on my dashboard" provider that needs no OAuth IDP).
# The plugin registers a password provider when ``username`` plus
# either ``password_hash`` (preferred — no plaintext at rest) or
# ``password`` (plaintext, hashed in-memory at load) are set. Each
# key is overridable by an env var
# (``HERMES_DASHBOARD_BASIC_AUTH_USERNAME`` /
# ``_PASSWORD_HASH`` / ``_PASSWORD`` / ``_SECRET`` /
# ``_TTL_SECONDS``), env winning when non-empty. Leave ``username``
# empty (the default) to keep the plugin a no-op — loopback /
# ``--insecure`` operators and OAuth users are unaffected.
#
# ``secret`` is the HMAC key used to sign the stateless session
# tokens this provider mints. When empty, a random per-process key
# is generated — fine for a single process, but sessions then
# don't survive a restart or span multiple workers. Set an
# explicit ``secret`` (32+ random bytes, base64/hex/raw) for
# stable multi-worker / restart-surviving sessions. Compute a
# ``password_hash`` with
# ``python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"``.
"basic_auth": {
"username": "", # blank → plugin no-op (no password provider)
"password_hash": "", # scrypt$... (preferred — no plaintext at rest)
"password": "", # plaintext fallback (hashed in-memory at load)
"secret": "", # token-signing key; blank → random per-process
"session_ttl_seconds": 0, # 0 → plugin default (12h)
},
# Drain-control service-credential configuration — read by the
# bundled ``dashboard_auth/drain`` plugin (the first consumer of the
# generic non-interactive token-auth capability). The SECRET itself
# is a credential and is NOT configured here: it is provisioned by
# nous-account-service at deploy time via the
# ``HERMES_DASHBOARD_DRAIN_SECRET`` env var (the .env-is-for-secrets
# rule). These are the behavioural knobs only. The plugin is a no-op
# unless that env var is set to a >=256-bit secret; a weak secret is
# rejected at registration (fail-closed) and the drain endpoint stays
# disabled. ``scope`` is the capability label attached to the verified
# principal; ``min_secret_chars`` is the entropy bar (url-safe-b64
# chars; 43 ~= 256 bits).
"drain_auth": {
"scope": "drain",
"min_secret_chars": 43,
},
# Public URL override (env: ``HERMES_DASHBOARD_PUBLIC_URL``).
# When set, this is the complete authority — scheme + host +
# optional path prefix (e.g. ``https://example.com/hermes``) —
# the OAuth ``redirect_uri`` is built from. Set this for deploys
# behind reverse proxies that don't reliably forward
# ``X-Forwarded-Host`` / ``X-Forwarded-Proto`` / ``X-Forwarded-Prefix``
# (manual nginx setups, on-prem ingresses, custom-domain Fly
# deploys without proper proxy headers). When set,
# ``X-Forwarded-Prefix`` is IGNORED on the OAuth path because
# the operator has declared the public URL — we no longer need
# to guess from proxy headers, and stacking the prefix on top
# would double-prefix the common case where the prefix is
# already baked into ``public_url``. Leave empty to use the
# existing proxy-header reconstruction (the default).
#
# Validation: rejects values without ``http(s)://`` scheme or
# without a host, and any string containing quote / angle /
# whitespace / control characters. A malformed value silently
# falls through to request reconstruction rather than breaking
# the login flow.
"public_url": "",
},
# Privacy settings
"privacy": {
"redact_pii": False, # When True, hash user IDs and strip phone numbers from LLM context
},
# Text-to-speech configuration
# Each provider supports an optional `max_text_length:` override for the
# per-request input-character cap. Omit it to use the provider's documented
# limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,
# Gemini 32000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
"tts": {
# Set explicitly to pin a backend:
# "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "deepinfra" | "neutts" (local) | "kittentts" (local) | "piper" (local)
"provider": "edge",
"edge": {
"voice": "en-US-AriaNeural",
# Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural
},
"elevenlabs": {
"voice_id": "pNInz6obpgDQGcFmaJgB", # Adam
"model_id": "eleven_multilingual_v2",
},
"openai": {
"model": "gpt-4o-mini-tts",
"voice": "alloy",
# Voices: alloy, ash, ballad, cedar, coral, echo, fable, marin,
# nova, onyx, sage, shimmer, verse (gpt-4o-mini-tts; the tts-1
# era stopped at alloy/echo/fable/onyx/nova/shimmer)
},
"gemini": {
"model": "gemini-2.5-flash-preview-tts",
"voice": "Kore",
# When true, Gemini 3.1 TTS uses a hidden auxiliary-model rewrite
# pass to insert freeform square-bracket audio tags into the TTS
# script. Visible chat replies are unchanged.
"audio_tags": False,
# Optional local Markdown/text file with Gemini TTS performance
# direction. It may include AUDIO PROFILE, SCENE, DIRECTOR'S NOTES,
# SAMPLE CONTEXT, and either a `{transcript}` placeholder or no
# transcript section; Hermes appends the live transcript when absent.
"persona_prompt_file": "",
},
"xai": {
"voice_id": "eve", # or custom voice ID — see https://docs.x.ai/developers/model-capabilities/audio/custom-voices
"language": "en", # BCP-47 code ("en", "pt-BR") or "auto"
"speed": 1.0, # 0.71.5, playback speed
"auto_speech_tags": False, # insert expressive audio tags via LLM rewrite
"optimize_streaming_latency": 0, # 02, trades quality for lower latency
"sample_rate": 24000, # 22050 / 24000 / 44100 / 48000
"bit_rate": 128000, # MP3 bitrate; only applies when codec=mp3
},
"mistral": {
"model": "voxtral-mini-tts-2603",
"voice_id": "c69964a6-ab8b-4f8a-9465-ec0925096ec8", # Paul - Neutral
},
"minimax": {
"model": "speech-02-hd",
"voice_id": "English_expressive_narrator",
},
"kittentts": {
"model": "KittenML/kitten-tts-nano-0.8-int8", # nano 25MB; micro 41MB; mini 80MB
"voice": "Jasper",
},
"neutts": {
"ref_audio": "", # Path to reference voice audio (empty = bundled default)
"ref_text": "", # Path to reference voice transcript (empty = bundled default)
"model": "neuphonic/neutts-air-q4-gguf", # HuggingFace model repo
"device": "cpu", # cpu, cuda, or mps
},
"piper": {
# Voice name (e.g. "en_US-lessac-medium") downloaded on first
# use, OR an absolute path to a pre-downloaded .onnx file.
# Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md
"voice": "en_US-lessac-medium",
# "voices_dir": "", # Override voice cache dir; default = ~/.hermes/cache/piper-voices/
# "use_cuda": False, # Requires onnxruntime-gpu
# "length_scale": 1.0, # 2.0 = twice as slow
# "noise_scale": 0.667,
# "noise_w_scale": 0.8,
# "volume": 1.0,
# "normalize_audio": True,
},
"deepinfra": {
"model": "", # empty = first tts-tagged model from the live catalog
"voice": "default",
# "base_url": "", # override DEEPINFRA_BASE_URL for TTS only
},
},
"stt": {
"enabled": True,
# When true, gateway voice messages are transcribed for the agent and
# the raw transcript is also echoed back to the user as a 🎙️ message.
# Set false to keep STT for the agent while suppressing that user-facing echo.
"echo_transcripts": True,
"provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | "elevenlabs" (Scribe) | "deepinfra"
"local": {
"model": "base", # tiny, base, small, medium, large-v3
"language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force
},
"openai": {
"model": "whisper-1", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe
},
"mistral": {
"model": "voxtral-mini-latest", # voxtral-mini-latest, voxtral-mini-2602
},
"elevenlabs": {
"model_id": "scribe_v2", # scribe_v2, scribe_v1
"language_code": "", # auto-detect by default; set to "eng", "spa", "fra", etc. to force
"tag_audio_events": False,
"diarize": False,
},
"deepinfra": {
"model": "", # empty = first stt-tagged model from the live catalog
# "base_url": "", # override DEEPINFRA_BASE_URL for STT only
},
},
"voice": {
"record_key": "ctrl+b",
"max_recording_seconds": 120,
"auto_tts": False,
"beep_enabled": True, # Play record start/stop beeps in CLI voice mode
"silence_threshold": 200, # RMS below this = silence (0-32767)
"silence_duration": 3.0, # Seconds of silence before auto-stop
},
"human_delay": {
"mode": "off",
"min_ms": 800,
"max_ms": 2500,
},
# Context engine -- controls how the context window is managed when
# approaching the model's token limit.
# "compressor" = built-in lossy summarization (default).
# Set to a plugin name to activate an alternative engine (e.g. "lcm"
# for Lossless Context Management). The engine must be installed as
# a plugin in plugins/context_engine/<name>/ or ~/.hermes/plugins/.
"context": {
"engine": "compressor",
},
# Persistent memory -- bounded curated memory injected into system prompt
"memory": {
"memory_enabled": True,
"user_profile_enabled": True,
# Approval gate for memory writes (add/replace/remove), applied to BOTH
# foreground agent turns and the background self-improvement review fork
# (the source of unprompted "wrong assumption" saves users reported).
# false (default) — write freely; the gate is off (pre-gate behaviour)
# true — require approval: foreground writes prompt inline
# (entries are small enough to review in a chat
# bubble); background-review writes are staged
# instead of committed (a daemon thread cannot block
# on a prompt). Review staged entries with
# /memory pending, /memory approve <id>,
# /memory reject <id>.
# To disable memory entirely, use memory_enabled: false instead.
"write_approval": False,
"memory_char_limit": 2200, # ~800 tokens at 2.75 chars/token
"user_char_limit": 1375, # ~500 tokens at 2.75 chars/token
# External memory provider plugin (empty = built-in only).
# Set to a provider name to activate: "openviking", "mem0",
# "hindsight", "holographic", "retaindb", "byterover".
# Only ONE external provider is allowed at a time.
"provider": "",
},
# Subagent delegation — override the provider:model used by delegate_task
# so child agents can run on a different (cheaper/faster) provider and model.
# Uses the same runtime provider resolution as CLI/gateway startup, so all
# configured providers (OpenRouter, Nous, Z.ai, Kimi, etc.) are supported.
"delegation": {
"model": "", # e.g. "google/gemini-3-flash-preview" (empty = inherit parent model)
"provider": "", # e.g. "openrouter" (empty = inherit parent provider + credentials)
"base_url": "", # direct OpenAI-compatible endpoint for subagents
"api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
"api_mode": "", # wire protocol for delegation.base_url: "chat_completions",
# "codex_responses", or "anthropic_messages". Empty = auto-detect
# from URL (e.g. /anthropic suffix → anthropic_messages). Set this
# explicitly for non-standard endpoints the heuristic can't detect.
# When delegate_task narrows child toolsets explicitly, preserve any
# MCP toolsets the parent already has enabled. On by default so
# narrowing (e.g. toolsets=["web","browser"]) expresses "I want these
# extras" without silently stripping MCP tools the parent already has.
# Set to false for strict intersection.
"inherit_mcp_toolsets": True,
"max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget,
# independent of the parent's max_iterations)
# Subagent summaries return to the parent's context verbatim. A batch
# fan-out (N children) returns N summaries at once, which can exceed
# the parent's context window and trigger a compression/429 death
# spiral. delegate_task sizes each summary against the parent's
# remaining context headroom (split across the batch); when it must
# trim, the full text is spilled to ~/.hermes/cache/delegation/
# (mounted into remote backends) and the in-context summary becomes a
# head+tail window plus a footer with the exact read_file offset to
# page the omitted middle — the same convention web_extract uses for
# large pages. Nothing is lost. max_summary_chars is a hard per-summary
# character ceiling layered on top of that dynamic budget
# (belt-and-suspenders for models that ignore the "be concise"
# instruction). 0 disables the hard ceiling; the dynamic headroom
# budget still applies.
"max_summary_chars": 24000,
"child_timeout_seconds": 0, # optional wall-clock cap per child agent. 0 (default)
# = no timeout: children fail only from real errors
# (API, tools, iteration budget), never a delegation
# stopwatch. Set a positive number of seconds
# (floor 30s) to enforce a hard cap.
"reasoning_effort": "", # subagent effort: "ultra", "max", "xhigh", "high",
# "medium", "low", "minimal", "none" (empty = inherit)
"max_concurrent_children": 3, # unified concurrency cap: max parallel children per batch
# AND max concurrent background (background=true)
# delegation units. New async dispatches beyond the cap
# fall back to synchronous execution. Floor of 1, no ceiling.
# (Replaces the deprecated max_async_children.)
# Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth
# and _get_orchestrator_enabled). Floored at 1, no upper ceiling —
# raise deliberately, each level multiplies API cost.
"max_spawn_depth": 1, # depth (1 = flat [default], 2 = orchestrator→leaf, 3+ = deeper)
"orchestrator_enabled": True, # kill switch for role="orchestrator"
# When a subagent hits a dangerous-command approval prompt, the parent's
# prompt_toolkit TUI owns stdin — a thread-local input() call from the
# subagent worker would deadlock the parent UI. To avoid the deadlock,
# subagent threads ALWAYS resolve approvals non-interactively:
# false (default) → auto-deny with a logger.warning audit line (safe)
# true → auto-approve "once" with a logger.warning audit line
# Flip to true only if you trust delegated work to run dangerous cmds
# without human review (cron pipelines, batch automation, etc.).
"subagent_auto_approve": False,
},
# Ephemeral prefill messages file — JSON list of {role, content} dicts
# injected at the start of every API call for few-shot priming.
# Never saved to sessions, logs, or trajectories.
"prefill_messages_file": "",
# Goals — persistent cross-turn goals (Ralph-style loop).
# After every turn, a lightweight judge call asks the auxiliary model
# whether the active /goal is satisfied by the assistant's last
# response. If not, Hermes feeds a continuation prompt back into the
# same session and keeps working until the goal is done, the turn
# budget is exhausted, or the user pauses/clears it. Judge failures
# fail OPEN (continue) so a flaky judge never wedges progress — the
# turn budget is the real backstop.
"goals": {
# Max continuation turns before Hermes auto-pauses the goal and
# asks the user to /goal resume. Protects against judge false
# negatives (goal actually done but judge says continue) and
# unbounded model spend on fuzzy / unachievable goals.
"max_turns": 20,
},
# Mixture of Agents — named presets used by /moa. A preset is an execution
# mode around the main model, not a provider/model itself: references +
# aggregator synthesize private guidance before each main-model iteration.
"moa": {
"default_preset": "default",
"active_preset": "",
# When true, every MoA turn that runs the reference fan-out writes the
# FULL turn (each reference's exact input messages + output + usage/cost,
# and the aggregator's exact input + output) to a JSONL file at
# <hermes_home>/moa-traces/<session_id>.jsonl. Off by default — turn it
# on to audit / improve MoA behavior from real runs. Set trace_dir to
# override the output directory.
"save_traces": False,
"trace_dir": "",
"presets": {
"default": {
"reference_models": [
{"provider": "openai-codex", "model": "gpt-5.5"},
{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"},
],
"aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
"max_tokens": 4096,
"enabled": True,
}
},
},
# Skills — external skill directories for sharing skills across tools/agents.
# Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation
# always goes to ~/.hermes/skills/.
"skills": {
"external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-skills"]
# Substitute ${HERMES_SKILL_DIR} and ${HERMES_SESSION_ID} in SKILL.md
# content with the absolute skill directory and the active session id
# before the agent sees it. Lets skill authors reference bundled
# scripts without the agent having to join paths.
"template_vars": True,
# Pre-execute inline shell snippets written as !`cmd` in SKILL.md
# body. Their stdout is inlined into the skill message before the
# agent reads it, so skills can inject dynamic context (dates, git
# state, detected tool versions, …). Off by default because any
# content from the skill author runs on the host without approval;
# only enable for skill sources you trust.
"inline_shell": False,
# Timeout (seconds) for each !`cmd` snippet when inline_shell is on.
"inline_shell_timeout": 10,
# Run the keyword/pattern security scanner on skills the agent
# writes via skill_manage (create/edit/patch). Off by default
# because the agent can already execute the same code paths via
# terminal() with no gate, so the scan adds friction (blocks
# skills that mention risky keywords in prose) without meaningful
# security. Turn on if you want the belt-and-suspenders — a
# dangerous verdict will then surface as a tool error to the
# agent, which can retry with the flagged content removed.
# External hub installs (trusted/community sources) are always
# scanned regardless of this setting.
"guard_agent_created": False,
# Approval gate for skill_manage (create/edit/patch/write_file/delete/
# remove_file), applied to BOTH foreground agent turns and the
# background self-improvement review fork.
# false (default) — write freely; the gate is off (pre-gate behaviour)
# true — require approval: stage the write for review
# instead of committing (a SKILL.md is too large to
# review inline, so skills always stage rather than
# prompt). List with /skills pending, inspect with
# /skills diff <id> (full diff — CLI/dashboard/file,
# never crammed into a chat bubble), apply with
# /skills approve <id> or drop with /skills reject <id>.
"write_approval": False,
},
# Curator — background skill maintenance.
#
# Periodically reviews AGENT-CREATED skills (never bundled or
# hub-installed) and keeps the collection tidy: marks long-unused skills
# as stale, archives genuinely obsolete ones (archive only, never
# deletes), and spawns a forked aux-model agent to consolidate overlaps
# and patch drift. Runs inactivity-triggered from session start — no
# cron daemon.
#
# See `hermes curator status` for the last run summary.
"curator": {
"enabled": True,
# How long to wait between curator runs (hours). Default: 7 days.
"interval_hours": 24 * 7,
# Only run when the agent has been idle at least this long (hours).
"min_idle_hours": 2,
# Mark a skill as "stale" after this many days without use.
"stale_after_days": 30,
# Archive a skill (move to skills/.archive/) after this many days
# without use. Archived skills are recoverable — no auto-deletion.
"archive_after_days": 90,
# Run the LLM consolidation (umbrella-building) pass. OFF by default.
# When off, a curator run does ONLY the deterministic inactivity prune
# (mark stale / archive long-unused skills) and skips the forked
# aux-model review entirely — no umbrella-building, no aux-model cost.
# Set to true to opt back into merging overlapping skills into
# class-level umbrellas. `hermes curator run --consolidate` overrides
# this for a single invocation.
"consolidate": False,
# Also prune (archive) bundled built-in skills after the inactivity
# period, not just agent-created ones. ON by default. Built-ins are
# normally restored on every `hermes update`, so pruning them only
# sticks because a suppression list tells the re-seeder to leave them
# archived. Hub-installed skills are NEVER pruned here — they have an
# external upstream owner. Built-ins accrue usage telemetry and their
# inactivity clock starts the first time the curator sees them, so a
# long-unused built-in is archived only after archive_after_days of
# genuine non-use (never a mass-prune on the first run). Set to false
# to keep all bundled built-ins permanently.
"prune_builtins": True,
# Pre-run backup: before every real curator pass (dry-run is
# skipped), snapshot ~/.hermes/skills/ into
# ~/.hermes/skills/.curator_backups/<utc-iso>/skills.tar.gz so the
# user can roll back with `hermes curator rollback`.
"backup": {
"enabled": True,
"keep": 5, # retain last N regular snapshots
},
},
# Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
# This section is only needed for hermes-specific overrides; everything else
# (apiKey, workspace, peerName, sessions, enabled) comes from the global config.
"honcho": {},
# IANA timezone (e.g. "Asia/Kolkata", "America/New_York").
# Empty string means use server-local time.
"timezone": "",
# Slack platform settings (gateway mode)
"slack": {
"require_mention": True, # Require @mention to respond in channels
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
"channel_prompts": {}, # Per-channel ephemeral system prompts
},
# Discord platform settings (gateway mode)
"discord": {
"require_mention": True, # Require @mention to respond in server channels
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
"thread_require_mention": False, # If True, require @mention in threads too (multi-bot threads)
"bots_require_inline_mention": False, # Multi-bot rooms: if True, another bot must type @thisbot in its message to trigger a reply; a Discord reply/quote alone won't. Prevents two bots auto-replying to each other forever. Does not affect humans.
"history_backfill": True, # If True, prepend recent channel scrollback when bot is triggered (recovers messages missed while require_mention gated them out)
"history_backfill_limit": 50, # Max number of recent messages to scan when assembling the backfill block
"missed_message_backfill": {
"enabled": False, # Replay missed Discord messages after reconnect/startup
"channels": "", # Comma-separated channel IDs; empty uses free_response_channels
"window_seconds": 21600, # Only inspect messages from the last 6 hours
"limit": 100, # Global cap on messages scanned per reconnect
"max_dispatches": 10, # Cap on recovered messages dispatched per reconnect
},
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
# Discord Gateway transport health. These settings inspect the active
# WebSocket's ready/open/heartbeat state; they never use Discord REST as
# proof that Gateway events are still arriving. Set any value to 0 to
# disable this compatibility-safe probe during a rollback.
"websocket_liveness_interval_seconds": 15,
"websocket_liveness_failure_threshold": 2,
"websocket_heartbeat_ack_max_age_seconds": 60,
"websocket_max_latency_seconds": 30,
"channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads)
# Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES
# authorizes only guild messages in the role's own guild — DMs require
# DISCORD_ALLOWED_USERS. Set dm_role_auth_guild to a guild ID to also
# authorize DMs from members of that one trusted guild holding the
# allowed role. Unset / empty / 0 = secure default (DM role-auth off).
"dm_role_auth_guild": "",
# discord / discord_admin tools: restrict which actions the agent may call.
# Default (empty) = all actions allowed (subject to bot privileged intents).
# Accepts comma-separated string ("list_guilds,list_channels,fetch_messages")
# or YAML list. Unknown names are dropped with a warning at load time.
# Actions: list_guilds, server_info, list_channels, channel_info,
# list_roles, member_info, search_members, fetch_messages, list_pins,
# pin_message, unpin_message, create_thread, add_role, remove_role.
"server_actions": "",
# DEPRECATED / no-op. Any uploaded file is now always cached and
# surfaced to the agent regardless of file type — authorization to
# message the agent is the gate, not the extension. Kept so existing
# configs that set it do not error. Env override:
# DISCORD_ALLOW_ANY_ATTACHMENT.
"allow_any_attachment": False,
# Maximum bytes per attachment the gateway will cache. The whole file
# is held in memory while being written, so unlimited uploads carry a
# real memory cost. Default 32 MiB matches the historical hardcoded
# cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES.
"max_attachment_bytes": 33554432,
# When True, Discord approval prompts mention numeric allowed users so
# owners notice approval requests in shared channels/threads. Env
# override: DISCORD_APPROVAL_MENTIONS. Default false avoids surprise
# pings.
"approval_mentions": False,
# Voice-channel audio effects (the continuous mixer). OFF by default.
# When enabled, the bot installs a software mixer on the outgoing voice
# stream so a low ambient "thinking" bed, verbal acknowledgements, and
# TTS replies can OVERLAP (ducking the ambient under speech) instead of
# stop-and-swap — the Grok-voice-mode feel. discord.py ships no mixer;
# this is implemented in plugins/platforms/discord/voice_mixer.py.
"voice_fx": {
"enabled": False, # master switch for the mixer subsystem
"ambient_enabled": True, # play the idle "thinking" bed while tools run
"ambient_path": "", # custom loop audio file; "" = synthesised pad
"ambient_gain": 0.18, # idle bed loudness, 0.01.0
"duck_gain": 0.06, # ambient loudness while speech plays
"speech_gain": 1.0, # TTS / ack loudness, 0.01.0
"ack_enabled": True, # speak a short phrase before the first tool call
"ack_phrases": [ # picked at random; set [] to disable phrases
"Let me look into that.",
"One moment.",
"Checking on that now.",
"Give me a sec.",
"On it.",
],
},
},
# WhatsApp platform settings (gateway mode)
"whatsapp": {
# Reply prefix prepended to every outgoing WhatsApp message.
# Default (None) uses the built-in "⚕ *Hermes Agent*" header.
# Set to "" (empty string) to disable the header entirely.
# Supports \n for newlines, e.g. "🤖 *My Bot*\n──────\n"
},
# Telegram platform settings (gateway mode)
"telegram": {
"reactions": False, # Add 👀/✅/❌ reactions to messages during processing
"channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
"allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)
"extra": {
"rich_messages": False, # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set True to opt in. Default stays legacy MarkdownV2 because rich messages can be hard to copy as plain text in Telegram clients.
"rich_drafts": False, # Experimental Bot API 10.1 rich draft previews during Telegram DM streaming. Default off because Telegram Desktop/macOS can visually overlay rich draft frames until the chat redraws.
},
},
# Mattermost platform settings (gateway mode)
"mattermost": {
"require_mention": True, # Require @mention to respond in channels
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
"channel_prompts": {}, # Per-channel ephemeral system prompts
},
# Matrix platform settings (gateway mode)
"matrix": {
"require_mention": True, # Require @mention to respond in rooms
"free_response_rooms": "", # Comma-separated room IDs where bot responds without mention
"allowed_rooms": "", # If set, bot ONLY responds in these room IDs (whitelist)
},
# Approval mode for dangerous commands:
# manual — always prompt the user
# smart — use auxiliary LLM to auto-approve low-risk commands (default)
# off — skip all approval prompts (equivalent to --yolo)
#
# cron_mode — what to do when a cron job hits a dangerous command:
# deny — block the command and let the agent find another way (default, safe)
# approve — auto-approve all dangerous commands in cron jobs
#
# timeout — seconds to wait for the user's approve/deny before failing
# closed (deny). Shared by the CLI prompt and gateway/messaging waits.
# Messaging approvals arrive as a push notification the user may not see
# immediately — 60s proved too tight on Telegram/Discord (the prompt
# expired before the user reached their phone), so the default is 300.
"approvals": {
"mode": "smart",
"timeout": 300,
"cron_mode": "deny",
# User-defined deny rules: fnmatch globs matched against terminal
# commands. A match blocks the command unconditionally — BEFORE the
# --yolo / /yolo / mode=off bypass — making this the user-editable
# counterpart to the code-shipped hardline blocklist. Patterns are
# case-insensitive and must be quoted in YAML when they start with
# * or contain {}/!/: sequences. Example:
# deny:
# - "git push --force*"
# - "*curl*|*sh*"
"deny": [],
# When true, /reload-mcp asks the user to confirm before rebuilding
# the MCP tool set for the active session. Reloading invalidates
# the provider prompt cache (tool schemas are baked into the system
# prompt), so the next message re-sends full input tokens — this can
# be expensive on long-context or high-reasoning models. Users click
# "Always Approve" to silence the prompt permanently; that flips
# this key to false.
"mcp_reload_confirm": True,
# When true, destructive session slash commands (/clear, /new, /reset,
# /undo) ask the user to confirm before discarding conversation state.
# Three-option prompt (Approve Once / Always Approve / Cancel) routed
# through tools.slash_confirm — native yes/no buttons on Telegram,
# Discord, and Slack; text fallback elsewhere. Users click "Always
# Approve" to silence the prompt permanently; that flips this key to
# false. TUI has its own modal overlay (HERMES_TUI_NO_CONFIRM=1 to
# opt out there).
"destructive_slash_confirm": True,
},
# Permanently allowed dangerous command patterns (added via "always" approval)
"command_allowlist": [],
# User-defined quick commands that bypass the agent loop (type: exec only)
"quick_commands": {},
# Per-platform system-prompt hint overrides. Lets an admin append to or
# replace Hermes' built-in platform hint for a single messaging platform
# (WhatsApp, Slack, Telegram, ...) without affecting other platforms.
# Useful for enterprise/managed profiles that ship platform-aware skills.
# Each key is a platform name; the value is either:
# { "append": "extra text" } — keep the default hint, append text
# { "replace": "full text" } — substitute the default hint entirely
# "extra text" — shorthand for { "append": ... }
# `replace` wins over `append` if both are given. Example:
# platform_hints:
# whatsapp:
# append: >
# When tabular output would be useful, invoke the
# table_formatting skill instead of emitting a Markdown table.
"platform_hints": {},
# Shell-script hooks — declarative bridge that invokes shell scripts
# on plugin-hook events (pre_tool_call, post_tool_call, pre_llm_call,
# subagent_stop, etc.). Each entry maps an event name to a list of
# {matcher, command, timeout} dicts. First registration of a new
# command prompts the user for consent; subsequent runs reuse the
# stored approval from ~/.hermes/shell-hooks-allowlist.json.
# See `website/docs/user-guide/features/hooks.md` for schema + examples.
"hooks": {},
# Auto-accept shell-hook registrations without a TTY prompt. Also
# toggleable per-invocation via --accept-hooks or HERMES_ACCEPT_HOOKS=1.
# Gateway / cron / non-interactive runs need this (or one of the other
# channels) to pick up newly-added hooks.
"hooks_auto_accept": False,
# Custom personalities — add your own entries here
# Supports string format: {"name": "system prompt"}
# Or dict format: {"name": {"description": "...", "system_prompt": "...", "tone": "...", "style": "..."}}
"personalities": {},
# Pre-exec security scanning via tirith
"security": {
"allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)
"redact_secrets": True,
"tirith_enabled": True,
"tirith_path": "tirith",
"tirith_timeout": 5,
"tirith_fail_open": True,
"website_blocklist": {
"enabled": False,
"domains": [],
"shared_files": [],
},
# Acknowledged supply-chain security advisories. Each entry is the
# ID of an advisory the user has read and acted on (uninstalled the
# compromised package, rotated credentials). Acked advisories no
# longer trigger the startup banner. Add via `hermes doctor --ack
# <id>`; remove by editing the list directly. See
# ``hermes_cli/security_advisories.py`` for the catalog.
"acked_advisories": [],
# Allow Hermes to lazy-install opt-in backend packages from PyPI
# the first time the user enables a backend that needs them
# (e.g. installing ``elevenlabs`` when the user picks ElevenLabs as
# their TTS provider). Set to false to require explicit
# ``pip install`` for everything beyond the base set — appropriate
# for restricted networks, audited environments, or air-gapped
# systems where any runtime install is unacceptable.
"allow_lazy_installs": True,
},
"cron": {
# Active cron SCHEDULER provider (Axis B — the trigger that decides
# WHEN a due job fires). Empty string = the built-in in-process 60s
# ticker (default). Name an installed provider (plugins/cron_providers/<name>/ or
# $HERMES_HOME/plugins/<name>/) to relocate the trigger — e.g. "chronos",
# the NAS-mediated managed-cron provider for scale-to-zero deployments.
# An unknown or unavailable provider falls back to the built-in, so cron
# never loses its trigger.
"provider": "",
# Chronos (NAS-mediated managed cron) settings. Only consulted when
# provider == "chronos". All non-secret (URLs + the JWT audience): the
# agent holds NO external-scheduler credentials. For hosted agents, NAS
# sets these at provision time. The outbound provision call reuses the
# agent's existing Nous Portal token — there is no token key here.
"chronos": {
# NAS / portal base URL the agent calls to arm/cancel one-shots
# and that mints the inbound fire JWT (used as the expected issuer).
"portal_url": "https://portal.nousresearch.com",
# The agent's OWN publicly-reachable base URL for NAS→agent fires
# (NAS POSTs {callback_url}/api/cron/fire). Empty → Chronos is
# unavailable and the resolver falls back to the built-in ticker.
"callback_url": "",
# This agent's expected JWT audience (e.g. "agent:{instance_id}").
"expected_audience": "",
# NAS JWKS URL for verifying the inbound fire JWT's signature.
# Empty → the fire endpoint refuses all tokens (no unsigned decode).
"nas_jwks_url": "",
},
# Wrap delivered cron responses with a header (task name) and footer
# ("The agent cannot see this message"). Set to false for clean output.
"wrap_response": True,
# Make cron deliveries CONTINUABLE: a user can reply to a cron brief
# and the agent has it in context (no "what is Task #2?" amnesia).
# Default False preserves the historical isolation guarantee (cron
# deliveries live only in the cron job's own session). Per-job
# `attach_to_session` overrides this for a single job.
#
# Behaviour is THREAD-PREFERRED, scoped to the job's origin chat:
# - Thread-capable platforms (Telegram forum/DM topics, Discord
# threads, Slack threads): a dedicated thread is opened for the job
# via the adapter's create_handoff_thread, the brief is delivered
# into it, and that thread's session is seeded so the user's reply
# in-thread continues with full context. Each continuable job gets
# its own scrollback, isolated from the parent channel.
# - DM-only platforms (WhatsApp / Signal / SMS): no threads exist, so
# the brief is mirrored into the origin DM session instead — the
# DM itself is the continuation surface.
# Both paths ride the shipped gateway.mirror.mirror_to_session and are
# alternation- and cache-safe (appended at a turn boundary, never
# mid-loop, never mutating the cached system prompt). Only the origin
# chat is ever touched — fan-out / broadcast targets are never mirrored.
"mirror_delivery": False,
# Maximum number of due jobs to run in parallel per tick.
# null/0 = unbounded (limited only by thread count).
# 1 = serial (pre-v0.9 behaviour).
# Also overridable via HERMES_CRON_MAX_PARALLEL env var.
"max_parallel_jobs": None,
# Per-job output-file retention: save_job_output keeps the N most
# recent .md files and prunes older ones. 0 or negative disables
# pruning (for operators who manage cleanup externally). Default 50.
"output_retention": 50,
# Timeout (seconds) for SessionDB() init inside cron jobs.
# SessionDB opens/migrates state.db synchronously and has no timeout
# of its own against a wedged sqlite3.connect. An unbounded hang here
# wedges the job's dispatch guard forever. Also overridable via
# HERMES_CRON_SESSION_DB_TIMEOUT env var. 0 = unlimited (skip the bound).
"session_db_timeout_seconds": 10,
},
# Kanban multi-agent coordination — controls the dispatcher loop that
# spawns workers for ready tasks. The dispatcher ticks every N seconds
# (default 60), reclaims stale claims, promotes dependency-satisfied
# todos to ready, and fires `hermes -p <assignee> chat -q ...` for
# each claimable ready task. One dispatcher per profile is sufficient;
# running more than one on the same kanban.db will race for claims.
"kanban": {
# Run the dispatcher inside the gateway process. On by default —
# the cost is ~300µs every `dispatch_interval_seconds` when idle,
# and gateway is the supervisor users already have. Set to false
# only if you run the dispatcher as a separate systemd unit or
# don't want the gateway to spawn workers.
"dispatch_in_gateway": True,
# Seconds between dispatcher ticks (idle or not). Lower = snappier
# pickup of newly-ready tasks; higher = less SQL pressure.
"dispatch_interval_seconds": 60,
# Auto-block after this many consecutive non-success attempts for the
# same task/profile (spawn_failed, timed_out, or crashed). Reassignment
# resets the streak for the new profile.
"failure_limit": 2,
# Worker stdout/stderr logs rotate at spawn time. Defaults preserve
# the historical 2 MiB + one-backup behavior; long-running workers can
# raise these to keep more early failure evidence.
"worker_log_rotate_bytes": 2 * 1024 * 1024,
"worker_log_backup_count": 1,
# Profile assigned to the root/orchestration task after Triage
# decomposition. When unset, falls back to the default profile (the
# one `hermes` launches with no -p flag). This does not control the
# decomposer prompt, model, or skills; configure that LLM path under
# auxiliary.kanban_decomposer.
"orchestrator_profile": "",
# Where a child task lands if the orchestrator can't match an
# assignee to any installed profile. When unset, falls back to the
# default profile. A task never ends up with assignee=None.
"default_assignee": "",
# Per-profile concurrency cap (#21582). When set to a positive int,
# no single profile can have more than N workers running at once,
# even if the global max_in_progress / max_spawn caps would allow
# it. Tasks blocked this way defer to the next dispatcher tick.
# Unset (None) means "no per-profile cap" — backward-compatible
# with existing installs. Useful for fan-out workflows that would
# otherwise saturate one profile's local model / API quota /
# browser pool while leaving other profiles idle.
"max_in_progress_per_profile": None,
# When true, the kanban dispatcher auto-runs the decomposer on
# tasks that land in Triage (every dispatcher tick). When false,
# decomposition is manual via `hermes kanban decompose <id>` or
# the dashboard's Decompose button.
"auto_decompose": True,
# Max triage tasks to decompose per dispatcher tick. Prevents a
# large bulk-load of triage tasks from spending a burst of aux
# LLM calls in one tick. Excess tasks defer to the next tick.
"auto_decompose_per_tick": 3,
# Stale detection: running tasks that have exceeded this many
# seconds without a heartbeat (since ``last_heartbeat_at``) are
# auto-reclaimed to ``ready`` on the next dispatcher tick. The
# worker process (if still running host-locally) is terminated
# before the reclaim. 0 disables stale detection entirely.
"dispatch_stale_timeout_seconds": 14400,
},
# execute_code settings — controls the tool used for programmatic tool calls.
"code_execution": {
# Execution mode:
# project (default) — scripts run in the session's working directory
# with the active virtualenv/conda env's python, so project deps
# (pandas, torch, project packages) and relative paths resolve.
# strict — scripts run in an isolated temp directory with
# hermes-agent's own python (sys.executable). Maximum isolation
# and reproducibility; project deps and relative paths won't work.
# Env scrubbing (strips *_API_KEY, *_TOKEN, *_SECRET, ...) and the
# tool whitelist apply identically in both modes.
"mode": "project",
},
# Tool Search (progressive disclosure for large tool surfaces).
# When the model is connected to many MCP servers or non-core plugin
# tools, their JSON schemas can consume a substantial fraction of the
# context window on every turn. When enabled, those tools are replaced
# in the model-facing tools array with three bridge tools —
# tool_search / tool_describe / tool_call — and surfaced on demand.
#
# Core Hermes tools (terminal, read_file, write_file, patch,
# search_files, todo, memory, browser_*, etc.) are NEVER deferred.
# See tools/tool_search.py for full design notes and the
# openclaw-tool-search-report PDF in this PR for the rationale.
"tools": {
"tool_search": {
# "auto" (default) — activate only when deferrable tool schemas
# exceed ``threshold_pct`` of the active model's context length,
# so small toolsets pay no overhead.
# "on" — always activate when there is at least one deferrable
# tool. Use when you have many MCP servers and want maximum
# token reduction unconditionally.
# "off" — disable entirely. Tools-array assembly is a pass-through.
"enabled": "auto",
# Percentage of context length at which "auto" mode kicks in.
# 10 matches the Claude Code default. Range 0..100.
"threshold_pct": 10,
# When the model calls tool_search without a ``limit`` argument,
# how many hits to return. Range 1..max_search_limit.
"search_default_limit": 5,
# Hard upper bound the model can request via ``limit``. Range 1..50.
"max_search_limit": 20,
},
},
# Logging — controls file logging to ~/.hermes/logs/.
# agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.
"logging": {
"level": "INFO", # Minimum level for agent.log: DEBUG, INFO, WARNING
"max_size_mb": 5, # Max size per log file before rotation
"backup_count": 3, # Number of rotated backup files to keep
},
# Remotely-hosted model catalog manifest. When enabled, the CLI fetches
# curated model lists for OpenRouter and Nous Portal from this URL,
# falling back to the in-repo snapshot on network failure. Lets us
# update model picker lists without shipping a hermes-agent release.
# The default URL is served by the docs site GitHub Pages deploy.
"model_catalog": {
"enabled": True,
"url": "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json",
# Disk cache TTL in hours. Beyond this, the CLI refetches on the
# next /model or `hermes model` invocation; network failures
# silently fall back to the stale cache.
"ttl_hours": 1,
# Optional per-provider override URLs for third parties that want
# to self-host their own curation list using the same schema.
# Example:
# providers:
# openrouter:
# url: https://example.com/my-curation.json
"providers": {},
},
# Network settings — workarounds for connectivity issues.
"network": {
# Force IPv4 connections. On servers with broken or unreachable IPv6,
# Python tries AAAA records first and hangs for the full TCP timeout
# before falling back to IPv4. Set to true to skip IPv6 entirely.
"force_ipv4": False,
},
# Gateway settings — control how messaging platforms (Telegram, Discord,
# Slack, etc.) deliver agent-produced files as native attachments.
"gateway": {
# Durable delivery-obligation ledger: final agent responses are
# recorded in state.db around the platform send, and a gateway that
# died between finalize and platform ACK redelivers the stored
# response on the next boot (ambiguous cases carry a visible
# "recovered reply — may be a duplicate" marker; honest
# at-least-once). Disable to lose in-flight final responses on
# crash/restart, as before.
"delivery_ledger": True,
# Seconds the gateway waits for a single messaging platform to finish
# connecting during startup (and on reconnect). Discord in particular
# can blow past the old fixed 30s when an account has many slash
# commands to sync (#19776: 90-173 skills → ~28-31s sync). Raise this
# if your gateway hits "discord connect timed out" / "Timeout waiting
# for connection to Discord" restart loops. ``0`` or negative disables
# the timeout entirely (wait indefinitely). Bridged at startup to the
# internal HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT env var, which still
# works as a manual override and wins if set explicitly.
"platform_connect_timeout": 30,
# Whether the gateway keeps writing the legacy sessions.json mirror of
# its routing index. The primary copy lives in state.db (the
# gateway_routing table). Default True for backward compatibility with
# external tooling and downgrade safety; set to false to stop
# producing ~/.hermes/sessions/sessions.json entirely.
"write_sessions_json": True,
# Scale-to-zero idle detection (Phase 0). The gateway watches for idle
# and, when an instance is opted in via the NAS "Labs" toggle (carried as
# the HERMES_SCALE_TO_ZERO env stamp) AND messaging is relay-only/absent
# AND a wakeUrl is registered, drives the relay transport dormant so the
# platform (e.g. Fly autostop:"suspend") can suspend the now-idle machine;
# it wakes on the connector's wakeUrl poke. This is the idle TIMEOUT only
# — whether the feature is enabled at all is the Labs toggle, never a
# config key (decisions.md D2/D11). 0/negative falls back to the default.
"scale_to_zero": {
"idle_timeout_minutes": 5,
},
# Auto-resume restart-loop breaker (#30719, defense-3). When the
# gateway is killed mid-turn (SIGTERM) and revived by a supervisor
# (launchd KeepAlive / systemd Restart=), it auto-resumes the
# restart-interrupted session on the next boot. If the resumed turn
# keeps triggering another kill (e.g. the agent runs a raw
# `launchctl kickstart ai.hermes.gateway` that defenses 1-2 don't
# cover), the result is a tight SIGTERM-respawn loop. This breaker
# counts restart-interrupted boots in a rolling window and, once
# `max_restarts` boots happen within `window_seconds`, SKIPS
# auto-resume for that boot — the gateway still starts and serves
# real inbound messages, it just stops replaying the session that
# keeps killing it. Set `max_restarts` to 0 to disable the breaker.
"restart_loop_guard": {
"max_restarts": 3,
"window_seconds": 60,
},
# Portable respawn-storm circuit breaker (complements
# ``restart_loop_guard`` above). Counts gateway (re)starts in a sliding
# window and, when too many land, sleeps an exponential backoff before
# booting so a crash-looping supervisor (launchd KeepAlive, systemd
# Restart=always) can't hammer the process into a respawn storm.
# ``max_starts <= 0`` disables the breaker. The env vars
# ``HERMES_GATEWAY_MAX_STARTS`` / ``HERMES_GATEWAY_START_WINDOW_S``
# override these defaults for escape-hatch use.
"respawn_storm": {
"max_starts": 5,
"window_seconds": 120,
},
# Inject a human-readable timestamp prefix (e.g.
# "[Tue 2026-04-28 13:40:53 CEST]") onto user messages IN THE MODEL'S
# CONTEXT so the agent has temporal awareness of when each message was
# sent. Off by default — when off, the model sees clean message text.
# Persisted transcripts always stay clean (the timestamp is stored as
# message metadata regardless of this toggle), so turning it on later
# surfaces send-times for past messages too.
"message_timestamps": {
"enabled": False,
},
# Maximum bytes for an inbound image / audio / video payload the
# gateway will buffer into memory and cache to disk. Inbound media is
# read fully into RAM before being written, so an unbounded upload
# (Discord Nitro allows 500 MB) or a remote media URL pointing at a
# huge file can spike memory and OOM-kill the gateway on constrained
# deployments. Enforced in the shared cache helpers
# (gateway/platforms/base.py), so the cap holds across every platform
# adapter. ``0`` disables the cap. Default 128 MiB.
"max_inbound_media_bytes": 134217728,
# When false (default), any file path the agent emits is delivered
# as a native attachment as long as it isn't under the credential /
# system-path denylist (/etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env,
# auth.json, etc.). This matches the symmetry of inbound delivery
# — we accept any document type the user uploads, and the agent
# can hand back any file that isn't a credential.
#
# When true, fall back to the older allowlist+recency-window
# behavior: files must live under the Hermes cache, under
# ``media_delivery_allow_dirs``, or be freshly produced inside the
# ``trust_recent_files_seconds`` window. Recommended for
# public-facing gateways where prompt injection from one user
# shouldn't be able to exfiltrate the host's secrets to that same
# user. Bridged to HERMES_MEDIA_DELIVERY_STRICT.
"strict": False,
# Extra directories from which model-emitted bare file paths may be
# uploaded as native gateway attachments. Files inside the Hermes
# cache (~/.hermes/cache/{documents,images,audio,video,screenshots})
# are always trusted; this list adds operator-controlled roots
# (project dirs, scratch dirs, mounted shares). Accepts a list of
# absolute paths or a single os.pathsep-separated string. Bridged
# to HERMES_MEDIA_ALLOW_DIRS at gateway startup. Tilde paths are
# expanded. Honored in both default and strict mode.
"media_delivery_allow_dirs": [],
# When true, files whose mtime is within ``trust_recent_files_seconds``
# of "now" are trusted for native delivery even outside the cache /
# operator allowlist — useful for ``pandoc -o /tmp/report.pdf`` or
# PDFs the agent writes into a working directory. System paths
# (/etc, /proc, ~/.ssh, ~/.aws, etc.) remain blocked regardless.
# Disable to fall back to pure-allowlist mode. Bridged to
# HERMES_MEDIA_TRUST_RECENT_FILES. Only consulted when ``strict``
# is true; in default mode the denylist alone gates delivery.
"trust_recent_files": True,
# Recency window in seconds. 600 (10 min) comfortably covers a
# multi-tool agent turn. Bridged to HERMES_MEDIA_TRUST_RECENT_SECONDS.
# Only consulted when ``strict`` is true.
"trust_recent_files_seconds": 600,
# OpenAI-compatible API server platform
# (gateway/platforms/api_server.py).
"api_server": {
# Maximum number of agent runs the API server will service
# concurrently. Requests to /v1/chat/completions, /v1/responses,
# and /v1/runs that arrive while this many runs are already
# in flight are rejected with HTTP 429 + a Retry-After header,
# bounding CPU / memory / upstream-LLM-quota exhaustion from a
# request flood. Set to 0 to disable the cap entirely.
"max_concurrent_runs": 10,
},
},
# Real-time token streaming to messaging platforms (Telegram, Discord,
# Slack, etc.). Read at the top level by the gateway; absent this block the
# gateway falls back to these same defaults, so adding it here only makes
# the feature discoverable in config.yaml — it does not change behavior.
#
# Disabled by default: streaming costs extra edit/draft API calls per
# response. Set ``enabled: true`` and restart the gateway to turn it on.
"streaming": {
# Master switch. When false, each response is delivered as a single
# final message (no progressive updates).
"enabled": False,
# Transport selection:
# "auto" — prefer native draft streaming where the platform
# supports it (Telegram DMs via sendMessageDraft,
# Bot API 9.5+) and fall back to edit-based elsewhere.
# Safe global default: platforms without draft support
# (Discord, Slack, Matrix, Telegram groups) transparently
# use the edit path, so "auto" only upgrades chats that
# can render the smoother native preview.
# "draft" — explicitly request native drafts; falls back to edit
# when the platform/chat doesn't support them.
# "edit" — progressive editMessageText only (legacy behavior).
# "off" — disable streaming entirely (same as enabled: false).
"transport": "auto",
# Minimum seconds between progressive edits — tuned for Telegram's
# ~1 edit/s flood envelope.
"edit_interval": 0.8,
# Flush the buffer to the platform once this many characters have
# accumulated, so short replies feel near-instant.
"buffer_threshold": 24,
# Cursor glyph appended to the in-progress message while streaming.
"cursor": " \u2589",
# When >0, the final edit for a long-running streamed response is
# delivered as a fresh message if the preview has been visible at
# least this many seconds, so the platform timestamp reflects
# completion time. Telegram only; other platforms ignore it.
"fresh_final_after_seconds": 0.0,
},
# Session storage — controls automatic cleanup of ~/.hermes/state.db.
# state.db accumulates every session, message, tool call, and FTS5 index
# entry forever. Without auto-pruning, a heavy user (gateway + cron)
# reports 384MB+ databases with 68K+ messages, which slows down FTS5
# inserts, /resume listing, and insights queries.
"sessions": {
# When true, prune ended sessions older than retention_days once
# per (roughly) min_interval_hours at CLI/gateway/cron startup.
# Only touches ended sessions — active sessions are always preserved.
# Default false: session history is valuable for search recall, and
# silently deleting it could surprise users. Opt in explicitly.
"auto_prune": False,
# How many days of ended-session history to keep. Matches the
# default of ``hermes sessions prune``.
"retention_days": 90,
# VACUUM after a prune that actually deleted rows. SQLite does not
# reclaim disk space on DELETE — freed pages are just reused on
# subsequent INSERTs — so without VACUUM the file stays bloated
# even after pruning. VACUUM blocks writes for a few seconds per
# 100MB, so it only runs at startup, and only when prune deleted
# ≥1 session.
"vacuum_after_prune": True,
# Minimum hours between auto-maintenance runs (avoids repeating
# the sweep on every CLI invocation). Tracked via state_meta in
# state.db itself, so it's shared across all processes.
"min_interval_hours": 24,
# Legacy per-session JSON snapshot writer. When true, the agent
# rewrites ``~/.hermes/sessions/session_{sid}.json`` on every turn
# boundary with the full message list. state.db is canonical and
# has every field the snapshot stored (plus per-message timestamps
# and token counts), so this is off by default — the snapshots had
# no consumer outside their own overwrite guard and accumulated
# GBs of disk on heavy users. Opt in only if you have an external
# tool that consumes the JSON files directly.
"write_json_snapshots": False,
# Search-index (FTS) storage optimization — the compact v23 layout
# that drops duplicate content copies and stops trigram-indexing tool
# output (typically reclaims ~60%+ of state.db on heavy users). It is
# OPT-IN: existing databases keep their working legacy index until the
# user runs `hermes sessions optimize-storage`, because the rebuild is
# disk-heavy and long on large DBs (see that command's disk preflight).
#
# "advise" (default): `hermes update` prints a one-line notice with
# the reclaimable size and the command, when a legacy index is
# detected. Nothing is changed automatically.
# "require": the notice is shown as a REQUIRED upgrade (firmer copy),
# and future tooling may gate on it. Flip this default in a future
# release when we're ready to make the v23 layout mandatory — the
# command, progress bar, and resumability are already in place, so
# enforcement is a copy/gating change, not new migration code.
# "off": suppress the notice entirely.
"fts_optimize_notice": "advise",
},
# Contextual first-touch onboarding hints (see agent/onboarding.py).
# Each hint is shown once per install and then latched here so it
# never fires again. Users can wipe the section to re-see all hints.
"onboarding": {
"seen": {},
# Structured profile-build path offered on the very first gateway
# message ever. "ask" (default) -> offer to build a user profile
# (opt-in, consent-gated; the agent asks before any lookup and never
# reads connected accounts silently). "off" -> plain intro only.
# The offer fires at most once (latched under onboarding.seen).
"profile_build": "ask",
},
# ``hermes update`` behaviour.
"updates": {
# Pre-update safety backup — ONE consolidated mechanism, three modes:
#
# quick (default) — snapshot critical small state files (pairing
# JSONs, cron jobs, config.yaml, .env, auth.json, per-profile
# DBs) into <HERMES_HOME>/state-snapshots/ before the update.
# Files over 1 GiB (e.g. a bloated state.db) are skipped with a
# warning so the snapshot stays fast. Restore via ``/snapshot``.
# This is the #15733 (lost pairing data) / #34600 (emptied cron
# jobs) safety net.
# full — the quick snapshot PLUS a full ``hermes backup``-style zip
# of HERMES_HOME into <HERMES_HOME>/backups/, restorable with
# ``hermes import``. Can add minutes on large homes. This is the
# #48200 (wrong-path wipe) safety net. ``--backup`` forces this
# for a single run.
# off — no pre-update backup of any kind. ``--no-backup`` forces
# this for a single run.
#
# Legacy boolean values are honored: true -> full, false -> off.
"pre_update_backup": "quick",
# How many full pre-update backup zips to retain (mode ``full``).
# Older ones are pruned automatically after each successful backup.
# Values below 1 are floored to 1 — the backup just created is
# always preserved. The quick snapshot always keeps exactly 1.
"backup_keep": 5,
# What `hermes update` does with uncommitted local changes to the
# source tree when it runs NON-interactively — i.e. triggered from
# the desktop/chat app or the gateway, where there's no TTY to answer
# a restore prompt. Interactive (terminal) updates are unaffected:
# they always stash the changes and ask whether to restore, exactly
# as they always have.
# "stash" — auto-stash the changes, pull, then auto-restore them
# on top of the updated code (the safe default; nothing
# is ever lost — conflicts are preserved in a git stash).
# "discard" — auto-stash the changes and throw the stash away after
# the pull. Use this only if you never intend to keep
# local edits to the source tree on this machine.
# Stash-and-drop (not `reset --hard` + `clean -fd`) so
# ignored paths — node_modules, venv, build outputs —
# are never touched.
"non_interactive_local_changes": "stash",
# Refresh an already-installed cua-driver during `hermes update`.
# The refresh is best-effort and macOS-only. Turn this off if the
# upstream installer is not appropriate for the machine, for example
# on non-admin accounts where `/Applications` is not writable.
"refresh_cua_driver": True,
},
# Language Server Protocol — semantic diagnostics from real
# language servers (pyright, gopls, rust-analyzer, etc.) wired
# into the post-write lint check used by ``write_file`` and
# ``patch``.
#
# LSP is gated on git-workspace detection: when the agent's
# cwd (or the file being edited) is inside a git worktree, LSP
# runs against that workspace. When neither is in a git repo,
# LSP stays dormant and the in-process syntax check is the only
# tier — handy for Telegram/Discord chats where the cwd is the
# user's home directory.
"lsp": {
# Master toggle. Setting this to false disables the entire
# subsystem — no servers spawn, no background event loop, no
# cost.
"enabled": True,
# Diagnostic-wait mode for the post-write check.
# ``"document"`` waits up to ``wait_timeout`` seconds for the
# current file's diagnostics; ``"full"`` additionally requests
# workspace-wide diagnostics (slower).
"wait_mode": "document",
"wait_timeout": 5.0,
# How to handle missing server binaries.
# ``"auto"`` — try to install via npm/go/pip into
# ``<HERMES_HOME>/lsp/bin/`` on first use.
# ``"manual"`` — only use binaries already on PATH.
# ``"off"`` — alias for ``manual``.
"install_strategy": "auto",
# Per-server overrides. Each key is a server_id from the
# registry (``pyright``, ``typescript``, ``gopls``,
# ``rust-analyzer``, etc.) and accepts:
# disabled: true
# — skip this server even when its extensions match
# command: ["full/path/to/server", "--stdio"]
# — pin a custom binary path; bypasses auto-install
# env: {"KEY": "value"}
# — extra env vars passed to the spawned process
# initialization_options: {...}
# — merged into the LSP ``initializationOptions``
# Empty by default; the registry defaults work for typical
# setups.
"servers": {},
},
# X (Twitter) Search via xAI's built-in x_search Responses tool.
# The tool registers when xAI credentials are available (SuperGrok
# OAuth or XAI_API_KEY) AND the x_search toolset is enabled in
# `hermes tools`. These settings tune the backing Responses API call.
"x_search": {
# xAI model used for the Responses call. grok-4.5 is the
# recommended default; any Grok model with x_search tool
# access works.
"model": "grok-4.5",
# Optional reasoning effort sent to xAI Responses API models that
# support it. Leave null to preserve the selected model's default.
"reasoning_effort": None,
# Request timeout in seconds (minimum 30). x_search can take
# 60-120s for complex queries — the default is generous.
"timeout_seconds": 180,
# Number of automatic retries on 5xx / ReadTimeout / ConnectionError.
# Each retry backs off (1.5x attempt seconds, capped at 5s).
"retries": 2,
},
# =========================================================================
# External secret sources
# =========================================================================
# Pull credentials from external secret managers at process startup
# rather than storing them in ~/.hermes/.env.
"secrets": {
# Optional explicit ordering of enabled secret sources. When
# omitted, sources run in registration order (bundled first,
# then plugin-registered). Regardless of this list, "mapped"
# sources (explicit VAR→ref bindings, e.g. a future 1Password
# env: map) always take precedence over "bulk" sources
# (project dumps like Bitwarden BSM), and the first source to
# claim a var wins — later claims are skipped with a warning.
# Example: sources: [onepassword, bitwarden]
# "sources": [],
"bitwarden": {
# Master switch. When false, BSM is never contacted and the
# bws binary is never auto-installed — same as not having
# this section at all.
"enabled": False,
# Name of the env var that holds the Bitwarden machine-account
# access token. This is the one bootstrap secret; it lives
# in ~/.hermes/.env (or your shell) and never in config.yaml.
"access_token_env": "BWS_ACCESS_TOKEN",
# UUID of the BSM project to sync from.
"project_id": "",
# Seconds to reuse a fresh disk/memory cache entry before contacting
# Bitwarden again. 0 disables normal fresh-cache reuse.
"cache_ttl_seconds": 300,
# Optional encrypted last-good fallback for network/timeout outages.
# When enabled, successful BWS fetches write AES-GCM encrypted cache
# material under ~/.hermes/cache/. If a later startup cannot reach
# Bitwarden due to NETWORK/TIMEOUT, Hermes may use this encrypted
# cache for up to max_stale_seconds. Auth failures do not fall back.
"encrypted_cache": {
"enabled": False,
"max_stale_seconds": 0,
},
# When True, BSM values overwrite existing env vars. Default
# True because the point of using BSM is centralized rotation —
# if .env had the final say, rotating in Bitwarden wouldn't
# take effect until you also cleared the matching .env line.
"override_existing": True,
# When True, the bws binary is auto-downloaded into
# ~/.hermes/bin/ on first use. When False you must install
# bws yourself and have it on PATH.
"auto_install": True,
# Bitwarden region / self-hosted endpoint. Empty string
# means use the bws CLI default (US Cloud,
# https://vault.bitwarden.com). Set to
# https://vault.bitwarden.eu for EU Cloud, or your own URL
# for self-hosted Bitwarden. Plumbed into the bws subprocess
# as BWS_SERVER_URL. Prompted for during
# `hermes secrets bitwarden setup`.
"server_url": "",
},
"onepassword": {
# Master switch. When false, the op CLI is never invoked —
# same as not having this section at all.
"enabled": False,
# Mapping of env-var name → 1Password secret reference
# (op://vault/item/field). Each entry is resolved with a
# single `op read` at startup.
"env": {},
# Optional account shorthand / sign-in address passed as
# `op read --account <account>`. Empty = op's default account.
"account": "",
# Name of the env var holding a 1Password service-account token
# for headless auth. Sourced from ~/.hermes/.env (or the shell)
# and exported to the op child as OP_SERVICE_ACCOUNT_TOKEN.
# Leave the var unset to use an interactive/desktop op session.
"service_account_token_env": "OP_SERVICE_ACCOUNT_TOKEN",
# Optional absolute path to the op binary. When set it is used
# verbatim (PATH is not consulted) — pin this to avoid trusting
# whatever `op` appears first on PATH. Empty = resolve via PATH.
"binary_path": "",
# Seconds to cache resolved values in-process and on disk. 0
# disables BOTH cache layers (no values are written to disk).
"cache_ttl_seconds": 300,
# When True (default), resolved values overwrite existing env
# vars so rotating a secret in 1Password takes effect on next
# start. Flip to false to let .env / shell exports win locally.
"override_existing": True,
},
},
# Paste collapse thresholds (TUI + CLI).
#
# paste_collapse_threshold (default 5)
# Bracketed-paste handler. Pastes with this many newlines or more
# collapse to a file reference. Set 0 to disable.
#
# paste_collapse_threshold_fallback (default 5)
# Fallback heuristic for terminals without bracketed paste support.
# Same line count test but heuristically gated by chars-added /
# newlines-added to avoid false positives from normal typing.
# Set 0 to disable.
#
# paste_collapse_char_threshold (default 2000)
# Long single-line paste guard. Pastes whose total char length
# reaches this value collapse to a file reference even if line
# count is below the line threshold. Catches the "8000 chars of
# minified JSON / log output on one line" case. Set 0 to disable.
"paste_collapse_threshold": 5,
"paste_collapse_threshold_fallback": 5,
"paste_collapse_char_threshold": 2000,
# Computer Use (cua-driver) toolset settings.
"computer_use": {
# cua-driver ships with anonymous usage telemetry (PostHog) ENABLED
# by default upstream. Hermes disables it for our users unless they
# explicitly opt in here. When false (default), Hermes sets
# CUA_DRIVER_RS_TELEMETRY_ENABLED=0 in the cua-driver child env for
# every invocation (MCP backend, status, doctor, install). Set true
# to let cua-driver use its own default (telemetry on).
"cua_telemetry": False,
},
# Hermes Desktop (Electron app) launch options. These only affect
# `hermes desktop`; they do not touch the CLI/gateway.
"desktop": {
# Git repository discovery for the Desktop Projects sidebar. Empty
# roots preserve the historical bounded scan of the user's home.
"repo_scan_enabled": True,
"repo_scan_roots": [],
"repo_scan_exclude_paths": [],
# Extra Electron command-line flags appended to every desktop launch,
# e.g. ["--ozone-platform=x11"] on headless/VM X11 hosts that need an
# explicit ozone backend, or GPU workaround flags. A list of strings;
# a single string is also accepted and shell-split.
"electron_flags": [],
# GPU hardware acceleration policy for the desktop app:
# "auto" - let the app detect remote displays (SSH/VNC/RDP) and
# disable GPU only then (default; current behavior).
# true - always disable GPU acceleration (software rendering).
# Use on no-GPU VMs / Proxmox hosts where the GPU path hangs.
# false - always keep GPU acceleration on, even over a remote display.
# Bridged to the HERMES_DESKTOP_DISABLE_GPU env var the Electron app reads.
"disable_gpu": "auto",
},
# Google Vertex AI provider (Gemini via the OpenAI-compatible endpoint).
# Auth is OAuth2 (short-lived access tokens minted from a service-account
# JSON or Application Default Credentials) — NOT a static API key. The
# credential *path* is a secret-adjacent pointer and lives in .env
# (VERTEX_CREDENTIALS_PATH / GOOGLE_APPLICATION_CREDENTIALS); these two
# settings are non-secret routing config and live here. Both are bridged to
# the VERTEX_PROJECT_ID / VERTEX_REGION env vars the adapter reads, so an
# explicit env var still wins over config.yaml.
"vertex": {
# GCP project ID. Empty → use the project_id embedded in the service
# account JSON (or ADC-resolved project).
"project_id": "",
# Vertex region. "global" is required for the Gemini 3.x preview models
# (regional endpoints silently 404 them). Override to a regional value
# (e.g. "us-central1") only if your models are pinned to a region.
"region": "global",
},
# Config schema version - bump this when adding new required fields
"_config_version": 33,
}
# =============================================================================
# Config Migration System
# =============================================================================
# Track which env vars were introduced in each config version.
# Migration only mentions vars new since the user's previous version.
ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
3: ["FIRECRAWL_API_KEY", "BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "FAL_KEY"],
4: ["VOICE_TOOLS_OPENAI_KEY", "ELEVENLABS_API_KEY"],
5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
10: ["TAVILY_API_KEY"],
11: ["TERMINAL_MODAL_MODE"],
}
# Required environment variables with metadata for migration prompts.
# LLM provider is required but handled in the setup wizard's provider
# selection step (Nous Portal / OpenRouter / Custom endpoint), so this
# dict is intentionally empty — no single env var is universally required.
REQUIRED_ENV_VARS = {}
# Optional environment variables that enhance functionality
OPTIONAL_ENV_VARS = {
# ── Provider (handled in provider selection, not shown in checklists) ──
"NOUS_BASE_URL": {
"description": "Nous Portal base URL override",
"prompt": "Nous Portal base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"OPENROUTER_API_KEY": {
"description": "OpenRouter API key (for vision, web scraping helpers, and MoA)",
"prompt": "OpenRouter API key",
"url": "https://openrouter.ai/keys",
"password": True,
"tools": ["vision_analyze"],
"category": "provider",
"advanced": True,
},
"GOOGLE_API_KEY": {
"description": "Google AI Studio API key (also recognized as GEMINI_API_KEY)",
"prompt": "Google AI Studio API key",
"url": "https://aistudio.google.com/app/apikey",
"password": True,
"category": "provider",
"advanced": True,
},
"GEMINI_API_KEY": {
"description": "Google AI Studio API key (alias for GOOGLE_API_KEY)",
"prompt": "Gemini API key",
"url": "https://aistudio.google.com/app/apikey",
"password": True,
"category": "provider",
"advanced": True,
},
"GEMINI_BASE_URL": {
"description": "Google AI Studio base URL override",
"prompt": "Gemini base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"VERTEX_CREDENTIALS_PATH": {
"description": "Path to a Google Cloud service account JSON for Vertex AI (Gemini). "
"Vertex uses OAuth2, not a static API key — this points at the "
"credentials Hermes mints short-lived tokens from. Falls back to "
"GOOGLE_APPLICATION_CREDENTIALS, then to ADC (gcloud auth "
"application-default login). Set project/region under vertex: in config.yaml.",
"prompt": "Vertex service account JSON path (leave empty to use ADC / GOOGLE_APPLICATION_CREDENTIALS)",
"url": "https://cloud.google.com/iam/docs/keys-create-delete",
"password": False,
"category": "provider",
"advanced": True,
},
"XAI_API_KEY": {
"description": "xAI API key",
"prompt": "xAI API key",
"url": "https://console.x.ai/",
"password": True,
"category": "provider",
"advanced": True,
},
"XAI_BASE_URL": {
"description": "xAI base URL override",
"prompt": "xAI base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"NVIDIA_API_KEY": {
"description": "NVIDIA NIM API key (build.nvidia.com or local NIM endpoint)",
"prompt": "NVIDIA NIM API key",
"url": "https://build.nvidia.com/",
"password": True,
"category": "provider",
"advanced": True,
},
"NVIDIA_BASE_URL": {
"description": "NVIDIA NIM base URL override (e.g. http://localhost:8000/v1 for local NIM)",
"prompt": "NVIDIA NIM base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"LM_API_KEY": {
"description": "LM Studio bearer token for auth-enabled local servers",
"prompt": "LM Studio API key / bearer token",
"url": None,
"password": True,
"category": "provider",
"advanced": True,
},
"LM_BASE_URL": {
"description": "LM Studio base URL override",
"prompt": "LM Studio base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"GLM_API_KEY": {
"description": "Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)",
"prompt": "Z.AI / GLM API key",
"url": "https://z.ai/",
"password": True,
"category": "provider",
"advanced": True,
},
"ZAI_API_KEY": {
"description": "Z.AI API key (alias for GLM_API_KEY)",
"prompt": "Z.AI API key",
"url": "https://z.ai/",
"password": True,
"category": "provider",
"advanced": True,
},
"Z_AI_API_KEY": {
"description": "Z.AI API key (alias for GLM_API_KEY)",
"prompt": "Z.AI API key",
"url": "https://z.ai/",
"password": True,
"category": "provider",
"advanced": True,
},
"GLM_BASE_URL": {
"description": "Z.AI / GLM base URL override",
"prompt": "Z.AI / GLM base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"KIMI_API_KEY": {
"description": "Kimi / Moonshot API key",
"prompt": "Kimi API key",
"url": "https://platform.moonshot.cn/",
"password": True,
"category": "provider",
"advanced": True,
},
"KIMI_BASE_URL": {
"description": "Kimi / Moonshot base URL override",
"prompt": "Kimi base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"KIMI_CN_API_KEY": {
"description": "Kimi / Moonshot China API key",
"prompt": "Kimi (China) API key",
"url": "https://platform.moonshot.cn/",
"password": True,
"category": "provider",
"advanced": True,
},
"STEPFUN_API_KEY": {
"description": "StepFun Step Plan API key",
"prompt": "StepFun Step Plan API key",
"url": "https://platform.stepfun.com/",
"password": True,
"category": "provider",
"advanced": True,
},
"STEPFUN_BASE_URL": {
"description": "StepFun Step Plan base URL override",
"prompt": "StepFun Step Plan base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"ARCEEAI_API_KEY": {
"description": "Arcee AI API key",
"prompt": "Arcee AI API key",
"url": "https://chat.arcee.ai/",
"password": True,
"category": "provider",
"advanced": True,
},
"ARCEE_BASE_URL": {
"description": "Arcee AI base URL override",
"prompt": "Arcee base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"GMI_API_KEY": {
"description": "GMI Cloud API key",
"prompt": "GMI Cloud API key",
"url": "https://www.gmicloud.ai/",
"password": True,
"category": "provider",
"advanced": True,
},
"GMI_BASE_URL": {
"description": "GMI Cloud base URL override",
"prompt": "GMI Cloud base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"FIREWORKS_API_KEY": {
"description": "Fireworks AI API key",
"prompt": "Fireworks AI API key",
"url": "https://app.fireworks.ai/settings/users/api-keys",
"password": True,
"category": "provider",
"advanced": True,
},
"MINIMAX_API_KEY": {
"description": "MiniMax API key (international)",
"prompt": "MiniMax API key",
"url": "https://www.minimax.io/",
"password": True,
"category": "provider",
"advanced": True,
},
"MINIMAX_BASE_URL": {
"description": "MiniMax base URL override",
"prompt": "MiniMax base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"MINIMAX_CN_API_KEY": {
"description": "MiniMax API key (China endpoint)",
"prompt": "MiniMax (China) API key",
"url": "https://www.minimaxi.com/",
"password": True,
"category": "provider",
"advanced": True,
},
"MINIMAX_CN_BASE_URL": {
"description": "MiniMax (China) base URL override",
"prompt": "MiniMax (China) base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"DEEPSEEK_API_KEY": {
"description": "DeepSeek API key for direct DeepSeek access",
"prompt": "DeepSeek API Key",
"url": "https://platform.deepseek.com/api_keys",
"password": True,
"category": "provider",
},
"DEEPSEEK_BASE_URL": {
"description": "Custom DeepSeek API base URL (advanced)",
"prompt": "DeepSeek Base URL",
"url": "",
"password": False,
"category": "provider",
},
"DASHSCOPE_API_KEY": {
"description": "Alibaba Cloud DashScope API key (Qwen + multi-provider models)",
"prompt": "DashScope API Key",
"url": "https://modelstudio.console.alibabacloud.com/",
"password": True,
"category": "provider",
},
"DASHSCOPE_BASE_URL": {
"description": "Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)",
"prompt": "DashScope Base URL",
"url": "",
"password": False,
"category": "provider",
"advanced": True,
},
"HERMES_QWEN_BASE_URL": {
"description": "Qwen Portal base URL override (default: https://portal.qwen.ai/v1)",
"prompt": "Qwen Portal base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"OPENCODE_ZEN_API_KEY": {
"description": "OpenCode Zen API key (pay-as-you-go access to curated models)",
"prompt": "OpenCode Zen API key",
"url": "https://opencode.ai/auth",
"password": True,
"category": "provider",
"advanced": True,
},
"OPENCODE_ZEN_BASE_URL": {
"description": "OpenCode Zen base URL override",
"prompt": "OpenCode Zen base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"OPENCODE_GO_API_KEY": {
"description": "OpenCode Go API key ($10/month subscription for open models)",
"prompt": "OpenCode Go API key",
"url": "https://opencode.ai/auth",
"password": True,
"category": "provider",
"advanced": True,
},
"OPENCODE_GO_BASE_URL": {
"description": "OpenCode Go base URL override",
"prompt": "OpenCode Go base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"HF_TOKEN": {
"description": "Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)",
"prompt": "Hugging Face Token",
"url": "https://huggingface.co/settings/tokens",
"password": True,
"category": "provider",
},
"HF_BASE_URL": {
"description": "Hugging Face Inference Providers base URL override",
"prompt": "HF base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"OLLAMA_API_KEY": {
"description": "Ollama Cloud API key (ollama.com — cloud-hosted open models)",
"prompt": "Ollama Cloud API key",
"url": "https://ollama.com/settings",
"password": True,
"category": "provider",
"advanced": True,
},
"OLLAMA_BASE_URL": {
"description": "Ollama Cloud base URL override (default: https://ollama.com/v1)",
"prompt": "Ollama base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"XIAOMI_API_KEY": {
"description": "Xiaomi MiMo API key for MiMo models (mimo-v2.5-pro, mimo-v2.5, mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)",
"prompt": "Xiaomi MiMo API Key",
"url": "https://platform.xiaomimimo.com",
"password": True,
"category": "provider",
},
"XIAOMI_BASE_URL": {
"description": "Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)",
"prompt": "Xiaomi base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"UPSTAGE_API_KEY": {
"description": "Upstage API key for Solar LLM models",
"prompt": "Upstage API Key",
"url": "https://console.upstage.ai/api-keys",
"password": True,
"category": "provider",
},
"UPSTAGE_BASE_URL": {
"description": "Upstage base URL override (default: https://api.upstage.ai/v1)",
"prompt": "Upstage base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"AWS_REGION": {
"description": "AWS region for Bedrock API calls (e.g. us-east-1, eu-central-1)",
"prompt": "AWS Region",
"url": "https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html",
"password": False,
"category": "provider",
"advanced": True,
},
"AWS_PROFILE": {
"description": "AWS named profile for Bedrock authentication (from ~/.aws/credentials)",
"prompt": "AWS Profile",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"AZURE_FOUNDRY_API_KEY": {
"description": "Azure Foundry API key for custom Azure endpoints",
"prompt": "Azure Foundry API Key",
"url": "https://ai.azure.com/",
"password": True,
"category": "provider",
},
"AZURE_FOUNDRY_BASE_URL": {
"description": "Azure Foundry base URL (set via 'hermes model' for endpoint-specific config)",
"prompt": "Azure Foundry base URL",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
# ── Tool API keys ──
"EXA_API_KEY": {
"description": "Exa API key for AI-native web search and contents",
"prompt": "Exa API key",
"url": "https://exa.ai/",
"tools": ["web_search", "web_extract"],
"password": True,
"category": "tool",
},
"PARALLEL_API_KEY": {
"description": "Parallel API key for AI-native web search and extract",
"prompt": "Parallel API key",
"url": "https://parallel.ai/",
"tools": ["web_search", "web_extract"],
"password": True,
"category": "tool",
},
"FIRECRAWL_API_KEY": {
"description": "Firecrawl API key for web search and scraping",
"prompt": "Firecrawl API key",
"url": "https://firecrawl.dev/",
"tools": ["web_search", "web_extract"],
"password": True,
"category": "tool",
},
"FIRECRAWL_API_URL": {
"description": "Firecrawl API URL for self-hosted instances (optional)",
"prompt": "Firecrawl API URL (leave empty for cloud)",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
"FIRECRAWL_GATEWAY_URL": {
"description": "Exact Firecrawl tool-gateway origin override for Nous Subscribers only (optional)",
"prompt": "Firecrawl gateway URL (leave empty to derive from domain)",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
"TOOL_GATEWAY_DOMAIN": {
"description": "Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, e.g. nousresearch.com -> firecrawl-gateway.nousresearch.com",
"prompt": "Tool-gateway domain suffix",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
"TOOL_GATEWAY_SCHEME": {
"description": "Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)",
"prompt": "Tool-gateway URL scheme",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
"TOOL_GATEWAY_USER_TOKEN": {
"description": "Explicit Nous Subscriber access token for tool-gateway requests (optional; otherwise read from the Hermes auth store)",
"prompt": "Tool-gateway user token",
"url": None,
"password": True,
"category": "tool",
"advanced": True,
},
"TAVILY_API_KEY": {
"description": "Tavily API key for AI-native web search and extract",
"prompt": "Tavily API key",
"url": "https://app.tavily.com/home",
"tools": ["web_search", "web_extract"],
"password": True,
"category": "tool",
},
"SEARXNG_URL": {
"description": "URL of your SearXNG instance for free self-hosted web search",
"prompt": "SearXNG URL (e.g. http://localhost:8080)",
"url": "https://searxng.github.io/searxng/",
"tools": ["web_search"],
"password": False,
"category": "tool",
},
"BRAVE_SEARCH_API_KEY": {
"description": "Brave Search API subscription token (free tier: 2,000 queries/mo)",
"prompt": "Brave Search subscription token",
"url": "https://brave.com/search/api/",
"tools": ["web_search"],
"password": True,
"category": "tool",
},
"BROWSERBASE_API_KEY": {
"description": "Browserbase API key for cloud browser (optional — local browser works without this)",
"prompt": "Browserbase API key",
"url": "https://browserbase.com/",
"tools": ["browser_navigate", "browser_click"],
"password": True,
"category": "tool",
},
"BROWSERBASE_PROJECT_ID": {
"description": "Browserbase project ID (optional — only needed for cloud browser)",
"prompt": "Browserbase project ID",
"url": "https://browserbase.com/",
"tools": ["browser_navigate", "browser_click"],
"password": False,
"category": "tool",
},
"BROWSER_USE_API_KEY": {
"description": "Browser Use API key for cloud browser (optional — local browser works without this)",
"prompt": "Browser Use API key",
"url": "https://browser-use.com/",
"tools": ["browser_navigate", "browser_click"],
"password": True,
"category": "tool",
},
"FIRECRAWL_BROWSER_TTL": {
"description": "Firecrawl browser session TTL in seconds (optional, default 300)",
"prompt": "Browser session TTL (seconds)",
"tools": ["browser_navigate", "browser_click"],
"password": False,
"category": "tool",
},
"AGENT_BROWSER_ENGINE": {
"description": "Browser engine for local mode: auto (default Chrome), lightpanda (faster, no screenshots), chrome",
"prompt": "Browser engine (auto/lightpanda/chrome)",
"url": "https://github.com/vercel-labs/agent-browser",
"tools": ["browser_navigate", "browser_snapshot", "browser_click", "browser_vision"],
"password": False,
"category": "tool",
"advanced": True,
},
"CAMOFOX_URL": {
"description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)",
"prompt": "Camofox server URL",
"url": "https://github.com/jo-inc/camofox-browser",
"tools": ["browser_navigate", "browser_click"],
"password": False,
"category": "tool",
},
"CAMOFOX_API_KEY": {
"description": "Optional bearer token sent as Authorization header to a remote/authenticated Camofox server",
"prompt": "Camofox API key",
"url": "https://github.com/jo-inc/camofox-browser",
"tools": ["browser_navigate", "browser_click"],
"password": True,
"category": "tool",
"advanced": True,
},
"FAL_KEY": {
"description": "FAL API key for image and video generation",
"prompt": "FAL API key",
"url": "https://fal.ai/",
"tools": ["image_generate", "video_generate"],
"password": True,
"category": "tool",
},
"KREA_API_KEY": {
"description": "Krea API key for Krea 2 image generation (Medium + Large)",
"prompt": "Krea API key",
"url": "https://www.krea.ai/settings/api-tokens",
"tools": ["image_generate"],
"password": True,
"category": "tool",
},
"VOICE_TOOLS_OPENAI_KEY": {
"description": "OpenAI API key for voice transcription (Whisper) and OpenAI TTS",
"prompt": "OpenAI API Key (for Whisper STT + TTS)",
"url": "https://platform.openai.com/api-keys",
"tools": ["voice_transcription", "openai_tts"],
"password": True,
"category": "tool",
},
"ELEVENLABS_API_KEY": {
"description": "ElevenLabs API key for premium text-to-speech voices and Scribe transcription",
"prompt": "ElevenLabs API key",
"url": "https://elevenlabs.io/",
"tools": ["elevenlabs_tts", "voice_transcription"],
"password": True,
"category": "tool",
},
"MISTRAL_API_KEY": {
"description": "Mistral API key for Voxtral TTS and transcription (STT)",
"prompt": "Mistral API key",
"url": "https://console.mistral.ai/",
"password": True,
"category": "tool",
},
"GITHUB_TOKEN": {
"description": "GitHub token for Skills Hub (higher API rate limits, skill publish)",
"prompt": "GitHub Token",
"url": "https://github.com/settings/tokens",
"password": True,
"category": "tool",
},
# ── Bundled skills (opt-in: only needed if the user uses that skill) ──
# These use category="skill" (distinct from "tool") so the sandbox
# env blocklist in tools/environments/local.py does NOT rewrite them —
# skills legitimately need these passed through to curl via
# tools/env_passthrough.py when the user's skill calls out.
"NOTION_API_KEY": {
"description": "Notion integration token (used by the `notion` skill)",
"prompt": "Notion API key",
"url": "https://www.notion.so/my-integrations",
"password": True,
"category": "skill",
"advanced": True,
},
"LINEAR_API_KEY": {
"description": "Linear personal API key (used by the `linear` skill)",
"prompt": "Linear API key",
"url": "https://linear.app/settings/account/security",
"password": True,
"category": "skill",
"advanced": True,
},
"AIRTABLE_API_KEY": {
"description": "Airtable personal access token (used by the `airtable` skill)",
"prompt": "Airtable API key",
"url": "https://airtable.com/create/tokens",
"password": True,
"category": "skill",
"advanced": True,
},
"TENOR_API_KEY": {
"description": "Tenor API key for GIF search (used by the `gif-search` skill)",
"prompt": "Tenor API key",
"url": "https://developers.google.com/tenor/guides/quickstart",
"password": True,
"category": "skill",
"advanced": True,
},
# ── Honcho ──
"HONCHO_API_KEY": {
"description": "Honcho API key for AI-native persistent memory",
"prompt": "Honcho API key",
"url": "https://app.honcho.dev",
"tools": ["honcho_context"],
"password": True,
"category": "tool",
},
"HONCHO_BASE_URL": {
"description": "Base URL for self-hosted Honcho instances (no API key needed)",
"prompt": "Honcho base URL (e.g. http://localhost:8000)",
"category": "tool",
},
# ── Hindsight ──
"HINDSIGHT_API_KEY": {
"description": "Hindsight API key for graph-aware persistent memory",
"prompt": "Hindsight API key",
"url": "https://hindsight.vectorize.io",
"tools": ["hindsight_recall"],
"password": True,
"category": "tool",
},
"HINDSIGHT_API_URL": {
"description": "Base URL for the Hindsight API (default: https://api.hindsight.vectorize.io)",
"prompt": "Hindsight API URL",
"category": "tool",
"advanced": True,
},
# ── Supermemory ──
"SUPERMEMORY_API_KEY": {
"description": "Supermemory API key for conversation-scoped persistent memory",
"prompt": "Supermemory API key",
"url": "https://supermemory.ai",
"tools": ["supermemory_search"],
"password": True,
"category": "tool",
},
# ── Mem0 ──
"MEM0_API_KEY": {
"description": "Mem0 Platform API key for semantic persistent memory",
"prompt": "Mem0 API key",
"url": "https://app.mem0.ai",
"tools": ["mem0_search"],
"password": True,
"category": "tool",
},
# ── RetainDB ──
"RETAINDB_API_KEY": {
"description": "RetainDB API key for persistent memory",
"prompt": "RetainDB API key",
"url": "https://retaindb.com",
"tools": ["retaindb_search"],
"password": True,
"category": "tool",
},
"RETAINDB_BASE_URL": {
"description": "Base URL for self-hosted RetainDB instances (default: https://api.retaindb.com)",
"prompt": "RetainDB base URL",
"category": "tool",
"advanced": True,
},
# ── ByteRover ──
"BRV_API_KEY": {
"description": "ByteRover API key (optional, for cloud sync — local-first by default)",
"prompt": "ByteRover API key",
"url": "https://app.byterover.dev",
"tools": ["brv_query"],
"password": True,
"category": "tool",
},
# ── OpenViking ──
"OPENVIKING_API_KEY": {
"description": "OpenViking API key (leave blank for local dev mode)",
"prompt": "OpenViking API key",
"tools": ["viking_search"],
"password": True,
"category": "tool",
},
"OPENVIKING_ENDPOINT": {
"description": "OpenViking server URL (default: http://127.0.0.1:1933)",
"prompt": "OpenViking endpoint",
"category": "tool",
"advanced": True,
},
# ── Langfuse observability ──
"HERMES_LANGFUSE_PUBLIC_KEY": {
"description": "Langfuse project public key (pk-lf-...)",
"prompt": "Langfuse public key",
"url": "https://cloud.langfuse.com",
"password": False,
"category": "tool",
},
"HERMES_LANGFUSE_SECRET_KEY": {
"description": "Langfuse project secret key (sk-lf-...)",
"prompt": "Langfuse secret key",
"url": "https://cloud.langfuse.com",
"password": True,
"category": "tool",
},
"HERMES_LANGFUSE_BASE_URL": {
"description": "Langfuse server URL (default: https://cloud.langfuse.com)",
"prompt": "Langfuse server URL (leave empty for cloud.langfuse.com)",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
# ── Messaging platforms ──
"TELEGRAM_BOT_TOKEN": {
"description": "Complete Telegram bot token created by @BotFather (numeric bot ID followed by a colon and secret)",
"prompt": "Telegram bot token",
"url": "https://t.me/BotFather",
"password": True,
"category": "messaging",
},
"TELEGRAM_ALLOWED_USERS": {
"description": "Optional comma-separated numeric Telegram user IDs allowed immediately; leave blank to approve new users through DM pairing",
"prompt": "Allowed Telegram user IDs (comma-separated)",
"url": "https://t.me/userinfobot",
"password": False,
"category": "messaging",
},
"TELEGRAM_PROXY": {
"description": "Proxy URL for Telegram connections (overrides HTTPS_PROXY). Supports http://, https://, socks5://",
"prompt": "Telegram proxy URL (optional)",
"password": False,
"category": "messaging",
},
"DISCORD_BOT_TOKEN": {
"description": "Discord bot token from Developer Portal",
"prompt": "Discord bot token",
"url": "https://discord.com/developers/applications",
"password": True,
"category": "messaging",
},
"DISCORD_ALLOWED_USERS": {
"description": "Comma-separated Discord user IDs allowed to use the bot",
"prompt": "Allowed Discord user IDs (comma-separated)",
"url": None,
"password": False,
"category": "messaging",
},
"DISCORD_REPLY_TO_MODE": {
"description": "Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)",
"prompt": "Discord reply mode (off/first/all)",
"url": None,
"password": False,
"category": "messaging",
},
"SLACK_BOT_TOKEN": {
"description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. "
"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
"im:history, im:read, im:write, mpim:history, mpim:read, users:read, files:read, files:write",
"prompt": "Slack Bot Token (xoxb-...)",
"help": "In your Slack app, add the required bot scopes, install the app to the workspace, then copy OAuth & Permissions > Bot User OAuth Token.",
"url": "https://api.slack.com/apps",
"password": True,
"category": "messaging",
},
"SLACK_APP_TOKEN": {
"description": "Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → "
"App-Level Tokens. Also ensure Event Subscriptions include: message.im, "
"message.channels, message.groups, message.mpim, app_mention",
"prompt": "Slack App Token (xapp-...)",
"help": "In your Slack app, enable Socket Mode, then create Basic Information > App-Level Tokens with the connections:write scope.",
"url": "https://api.slack.com/apps",
"password": True,
"category": "messaging",
},
"SLACK_ALLOWED_USERS": {
"description": "Comma-separated Slack member IDs allowed to use Hermes, e.g. U01ABC2DEF3. Without this, Slack may connect but deny messages by default.",
"prompt": "Allowed Slack member IDs",
"help": "In Slack, open your profile, choose More or the three-dot menu, then Copy member ID. Add multiple IDs comma-separated.",
"url": "https://api.slack.com/apps",
"password": False,
"category": "messaging",
},
"MATTERMOST_URL": {
"description": "Mattermost server URL (e.g. https://mm.example.com)",
"prompt": "Mattermost server URL",
"url": "https://mattermost.com/deploy/",
"password": False,
"category": "messaging",
},
"MATTERMOST_TOKEN": {
"description": "Mattermost bot token or personal access token",
"prompt": "Mattermost bot token",
"url": None,
"password": True,
"category": "messaging",
},
"MATTERMOST_ALLOWED_USERS": {
"description": "Comma-separated Mattermost user IDs allowed to use the bot",
"prompt": "Allowed Mattermost user IDs (comma-separated)",
"url": None,
"password": False,
"category": "messaging",
},
"MATTERMOST_REQUIRE_MENTION": {
"description": "Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.",
"prompt": "Require @mention in channels",
"url": None,
"password": False,
"category": "messaging",
},
"MATTERMOST_FREE_RESPONSE_CHANNELS": {
"description": "Comma-separated Mattermost channel IDs where bot responds without @mention",
"prompt": "Free-response channel IDs (comma-separated)",
"url": None,
"password": False,
"category": "messaging",
},
"MATRIX_HOMESERVER": {
"description": "Matrix homeserver URL (e.g. https://matrix.example.org)",
"prompt": "Matrix homeserver URL",
"url": "https://matrix.org/ecosystem/servers/",
"password": False,
"category": "messaging",
},
"MATRIX_ACCESS_TOKEN": {
"description": "Matrix access token (preferred over password login)",
"prompt": "Matrix access token",
"url": None,
"password": True,
"category": "messaging",
},
"MATRIX_USER_ID": {
"description": "Matrix user ID (e.g. @hermes:example.org)",
"prompt": "Matrix user ID (@user:server)",
"url": None,
"password": False,
"category": "messaging",
},
"MATRIX_ALLOWED_USERS": {
"description": "Comma-separated Matrix user IDs allowed to use the bot (@user:server format)",
"prompt": "Allowed Matrix user IDs (comma-separated)",
"url": None,
"password": False,
"category": "messaging",
},
"MATRIX_REQUIRE_MENTION": {
"description": "Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.",
"prompt": "Require @mention in rooms (true/false)",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"MATRIX_FREE_RESPONSE_ROOMS": {
"description": "Comma-separated Matrix room IDs where bot responds without @mention",
"prompt": "Free-response room IDs (comma-separated)",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"MATRIX_AUTO_THREAD": {
"description": "Auto-create threads for messages in Matrix rooms (default: true)",
"prompt": "Auto-create threads in rooms (true/false)",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"MATRIX_DM_AUTO_THREAD": {
"description": "Auto-create threads for DM messages in Matrix (default: false)",
"prompt": "Auto-create threads in DMs (true/false)",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"MATRIX_DEVICE_ID": {
"description": "Stable Matrix device ID for E2EE persistence across restarts (e.g. HERMES_BOT)",
"prompt": "Matrix device ID (stable across restarts)",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"MATRIX_RECOVERY_KEY": {
"description": "Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)",
"prompt": "Matrix recovery key",
"url": None,
"password": True,
"category": "messaging",
"advanced": True,
},
"BLUEBUBBLES_SERVER_URL": {
"description": "BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)",
"prompt": "BlueBubbles server URL",
"url": "https://bluebubbles.app/",
"password": False,
"category": "messaging",
},
"BLUEBUBBLES_PASSWORD": {
"description": "BlueBubbles server password (from BlueBubbles Server → Settings → API)",
"prompt": "BlueBubbles server password",
"url": None,
"password": True,
"category": "messaging",
},
"BLUEBUBBLES_ALLOWED_USERS": {
"description": "Comma-separated iMessage addresses (email or phone) allowed to use the bot",
"prompt": "Allowed iMessage addresses (comma-separated)",
"url": None,
"password": False,
"category": "messaging",
},
"BLUEBUBBLES_ALLOW_ALL_USERS": {
"description": "Allow all BlueBubbles users without allowlist",
"prompt": "Allow All BlueBubbles Users",
"category": "messaging",
},
"QQ_APP_ID": {
"description": "QQ Bot App ID from QQ Open Platform (q.qq.com)",
"prompt": "QQ App ID",
"url": "https://q.qq.com",
"category": "messaging",
},
"QQ_CLIENT_SECRET": {
"description": "QQ Bot Client Secret from QQ Open Platform",
"prompt": "QQ Client Secret",
"password": True,
"category": "messaging",
},
"QQ_ALLOWED_USERS": {
"description": "Comma-separated QQ user IDs allowed to use the bot",
"prompt": "QQ Allowed Users",
"category": "messaging",
},
"QQ_GROUP_ALLOWED_USERS": {
"description": "Comma-separated QQ group IDs allowed to interact with the bot",
"prompt": "QQ Group Allowed Users",
"category": "messaging",
},
"QQ_ALLOW_ALL_USERS": {
"description": "Allow all QQ users without an allowlist (true/false)",
"prompt": "Allow All QQ Users",
"category": "messaging",
},
"QQBOT_HOME_CHANNEL": {
"description": "Default QQ channel/group for cron delivery and notifications",
"prompt": "QQ Home Channel",
"category": "messaging",
},
"QQBOT_HOME_CHANNEL_NAME": {
"description": "Display name for the QQ home channel",
"prompt": "QQ Home Channel Name",
"category": "messaging",
},
"QQ_SANDBOX": {
"description": "Enable QQ sandbox mode for development testing (true/false)",
"prompt": "QQ Sandbox Mode",
"category": "messaging",
},
"IRC_SERVER": {
"description": "IRC server hostname (e.g. irc.libera.chat)",
"prompt": "IRC server",
"url": None,
"password": False,
"category": "messaging",
},
"IRC_CHANNEL": {
"description": "IRC channel to join (e.g. #hermes)",
"prompt": "IRC channel",
"url": None,
"password": False,
"category": "messaging",
},
"IRC_NICKNAME": {
"description": "Bot nickname on IRC (default: hermes-bot)",
"prompt": "IRC nickname",
"url": None,
"password": False,
"category": "messaging",
},
"IRC_SERVER_PASSWORD": {
"description": "IRC server password (if required)",
"prompt": "IRC server password",
"url": None,
"password": True,
"category": "messaging",
"advanced": True,
},
"IRC_NICKSERV_PASSWORD": {
"description": "NickServ password for nick identification",
"prompt": "NickServ password",
"url": None,
"password": True,
"category": "messaging",
"advanced": True,
},
"GATEWAY_ALLOW_ALL_USERS": {
"description": "Allow all users to interact with messaging bots (true/false). Default: false.",
"prompt": "Allow all users (true/false)",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"API_SERVER_ENABLED": {
"description": "Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.",
"prompt": "Enable API server (true/false)",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"API_SERVER_KEY": {
"description": "Bearer token for API server authentication. Required whenever the API server is enabled; server refuses to start without it.",
"prompt": "API server auth key",
"url": None,
"password": True,
"category": "messaging",
"advanced": True,
},
"API_SERVER_PORT": {
"description": "Port for the API server (default: 8642).",
"prompt": "API server port",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"API_SERVER_HOST": {
"description": "Host/bind address for the API server (default: 127.0.0.1). API_SERVER_KEY is still required even on loopback binds.",
"prompt": "API server host",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"API_SERVER_MODEL_NAME": {
"description": "Model name advertised on /v1/models. Defaults to the profile name (or 'hermes-agent' for the default profile). Useful for multi-user setups with OpenWebUI.",
"prompt": "API server model name",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"GATEWAY_PROXY_URL": {
"description": "URL of a remote Hermes API server to forward messages to (proxy mode). When set, the gateway handles platform I/O only — all agent work is delegated to the remote server. Use for Docker E2EE containers that relay to a host agent. Also configurable via gateway.proxy_url in config.yaml.",
"prompt": "Remote Hermes API server URL (e.g. http://192.168.1.100:8642)",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"GATEWAY_PROXY_KEY": {
"description": "Bearer token for authenticating with the remote Hermes API server (proxy mode). Must match the API_SERVER_KEY on the remote host.",
"prompt": "Remote API server auth key",
"url": None,
"password": True,
"category": "messaging",
"advanced": True,
},
"WEBHOOK_ENABLED": {
"description": "Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.",
"prompt": "Enable webhooks (true/false)",
"url": None,
"password": False,
"category": "messaging",
},
"WEBHOOK_PORT": {
"description": "Port for the webhook HTTP server (default: 8644).",
"prompt": "Webhook port",
"url": None,
"password": False,
"category": "messaging",
},
"WEBHOOK_SECRET": {
"description": "Global HMAC secret for webhook signature validation (overridable per route in config.yaml).",
"prompt": "Webhook secret",
"url": None,
"password": True,
"category": "messaging",
},
# ── Agent settings ──
# NOTE: MESSAGING_CWD was removed here — use terminal.cwd in config.yaml
# instead. The gateway reads TERMINAL_CWD (bridged from terminal.cwd).
"SUDO_PASSWORD": {
"description": "Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting",
"prompt": "Sudo password",
"url": None,
"password": True,
"category": "setting",
},
# HERMES_TOOL_PROGRESS and HERMES_TOOL_PROGRESS_MODE are deprecated —
# now configured via display.tool_progress in config.yaml (off|new|all|verbose|log).
# The gateway still falls back to these env vars for backward compatibility,
# so they live in _EXTRA_ENV_KEYS (known to .env sanitization/reload) but
# are intentionally NOT listed here: OPTIONAL_ENV_VARS feeds user-facing
# surfaces (dashboard keys page, setup checklists) and deprecated knobs
# shouldn't be offered there.
"HERMES_PREFILL_MESSAGES_FILE": {
"description": "Path to JSON file with ephemeral prefill messages for few-shot priming",
"prompt": "Prefill messages file path",
"url": None,
"password": False,
"category": "setting",
},
"HERMES_EPHEMERAL_SYSTEM_PROMPT": {
"description": "Ephemeral system prompt injected at API-call time (never persisted to sessions)",
"prompt": "Ephemeral system prompt",
"url": None,
"password": False,
"category": "setting",
},
}
# Tool Gateway env vars are always visible — they're useful for
# self-hosted / custom gateway setups regardless of subscription state.
def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
"""
Check which environment variables are missing.
Returns list of dicts with var info for missing variables.
"""
missing = []
# Check required vars
for var_name, info in REQUIRED_ENV_VARS.items():
if not get_env_value(var_name):
missing.append({"name": var_name, **info, "is_required": True})
# Check optional vars (if not required_only)
if not required_only:
for var_name, info in OPTIONAL_ENV_VARS.items():
if not get_env_value(var_name):
missing.append({"name": var_name, **info, "is_required": False})
return missing
def _set_nested(config, dotted_key: str, value):
"""Set a value at an arbitrarily nested dotted key path.
Supports both dict and list navigation:
_set_nested(c, "a.b.c", 1) → c["a"]["b"]["c"] = 1
_set_nested(c, "a.0.b", 1) → c["a"][0]["b"] = 1
_set_nested(c, "providers.1", "x") → c["providers"][1] = "x"
Intermediate dicts are created on demand. List indices are parsed
from numeric path segments; the referenced index must already exist
(we do not grow lists — the user is navigating into structure they
wrote themselves). If a segment targets a non-container leaf
(scalar), the leaf is replaced with a fresh dict so the write can
proceed — this preserves the pre-existing behavior for bare scalar
overrides (e.g. setting ``a.b.c`` where ``a.b`` was previously a
string).
Guards against #17876: before this fix the code unconditionally
replaced any non-dict value (including lists) with ``{}``, silently
destroying list-typed config like ``custom_providers`` whenever a
caller used an indexed path.
"""
parts = dotted_key.split(".")
current = config
for part in parts[:-1]:
if isinstance(current, list):
try:
idx = int(part)
except (TypeError, ValueError):
raise TypeError(
f"Cannot navigate into list at key {dotted_key!r}: "
f"segment {part!r} is not a numeric index"
)
current = current[idx]
elif isinstance(current, dict):
existing = current.get(part)
# Preserve dicts and lists; replace missing/scalar with a fresh dict.
if part not in current or not isinstance(existing, (dict, list)):
current[part] = {}
current = current[part]
else:
raise TypeError(
f"Cannot navigate into {type(current).__name__} at key {dotted_key!r}"
)
last = parts[-1]
if isinstance(current, list):
current[int(last)] = value
else:
current[last] = value
def clear_model_endpoint_credentials(
model_cfg: Dict[str, Any],
*,
clear_api_key: bool = True,
clear_api_mode: bool = True,
clear_base_url: bool = False,
) -> Dict[str, Any]:
"""Remove stale inline endpoint credentials from a model config.
``model.api_key`` is valid only for explicit custom endpoint assignments.
Built-in providers resolve credentials from env vars, auth.json, or the
credential pool. When switching away from a custom endpoint, leaving these
fields behind keeps secrets in config.yaml and can contaminate later custom
resolution paths.
"""
if not isinstance(model_cfg, dict):
return model_cfg
if clear_api_key:
model_cfg.pop("api_key", None)
model_cfg.pop("api", None)
if clear_api_mode:
model_cfg.pop("api_mode", None)
if clear_base_url:
model_cfg.pop("base_url", None)
return model_cfg
_MISSING = object()
def _get_nested(config, dotted_key: str):
"""Return a dotted-path value from nested dict/list config data."""
current = config
for part in dotted_key.split("."):
if isinstance(current, list):
try:
current = current[int(part)]
except (TypeError, ValueError, IndexError):
return _MISSING
elif isinstance(current, dict):
if part not in current:
return _MISSING
current = current[part]
else:
return _MISSING
return current
def _unset_nested(config, dotted_key: str) -> bool:
"""Remove a dotted-path value from nested dict/list config data."""
parts = dotted_key.split(".")
if not parts:
return False
parents = []
current = config
for part in parts[:-1]:
parents.append((current, part))
if isinstance(current, list):
try:
current = current[int(part)]
except (TypeError, ValueError, IndexError):
return False
elif isinstance(current, dict):
if part not in current:
return False
current = current[part]
else:
return False
last = parts[-1]
removed = False
if isinstance(current, list):
try:
current.pop(int(last))
removed = True
except (TypeError, ValueError, IndexError):
return False
elif isinstance(current, dict):
if last not in current:
return False
del current[last]
removed = True
else:
return False
# Drop empty dict containers left behind by the deletion while preserving
# user-authored empty lists and non-empty sibling branches.
for parent, part in reversed(parents):
if current != {}:
break
if isinstance(parent, list):
try:
idx = int(part)
except (TypeError, ValueError):
break
if 0 <= idx < len(parent) and parent[idx] == {}:
parent.pop(idx)
current = parent
continue
elif isinstance(parent, dict) and parent.get(part) == {}:
del parent[part]
current = parent
continue
break
return removed
def _is_env_config_key(key: str) -> bool:
"""Return whether `hermes config set` routes this key to .env."""
if "." in key:
return False
key_upper = key.upper()
api_keys = [
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL',
'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME',
'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY',
'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
'GITHUB_TOKEN', 'HONCHO_API_KEY',
]
return (
key_upper in api_keys
or key_upper.endswith(('_API_KEY', '_TOKEN'))
or key_upper.startswith('TERMINAL_SSH')
)
def _format_config_get_value(value, *, as_json: bool) -> str:
"""Format a config value for command-line output."""
if as_json:
import json
return json.dumps(value, ensure_ascii=False)
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return "null"
if isinstance(value, (dict, list)):
return yaml.safe_dump(value, sort_keys=False).rstrip()
return str(value)
def get_missing_config_fields() -> List[Dict[str, Any]]:
"""
Check which config fields are missing or outdated (recursive).
Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys
present in defaults but absent from the user's loaded config.
"""
config = load_config()
missing = []
def _check(defaults: dict, current: dict, prefix: str = ""):
for key, default_value in defaults.items():
if key.startswith('_'):
continue
full_key = key if not prefix else f"{prefix}.{key}"
if key not in current:
missing.append({
"key": full_key,
"default": default_value,
"description": f"New config option: {full_key}",
})
elif isinstance(default_value, dict) and isinstance(current.get(key), dict):
_check(default_value, current[key], full_key)
_check(DEFAULT_CONFIG, config)
return missing
def get_missing_skill_config_vars() -> List[Dict[str, Any]]:
"""Return skill-declared config vars that are missing or empty in config.yaml.
Scans all enabled skills for ``metadata.hermes.config`` entries, then checks
which ones are absent or empty under ``skills.config.<key>`` in the user's
config.yaml. Returns a list of dicts suitable for prompting.
"""
try:
from agent.skill_utils import discover_all_skill_config_vars, SKILL_CONFIG_PREFIX
except Exception:
return []
try:
all_vars = discover_all_skill_config_vars()
except Exception as e:
# A malformed SKILL.md, unreadable external skill dir, or similar
# should never break `hermes update`. Skill-config prompting is a
# post-migration nicety, not a blocker.
import logging
logging.getLogger(__name__).debug(
"discover_all_skill_config_vars failed: %s", e
)
return []
if not all_vars:
return []
config = load_config()
missing: List[Dict[str, Any]] = []
for var in all_vars:
# Skill config is stored under skills.config.<logical_key>
storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}"
parts = storage_key.split(".")
current = config
value = None
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
value = current
else:
value = None
break
# Missing = key doesn't exist or is empty string
if value is None or (isinstance(value, str) and not value.strip()):
missing.append(var)
return missing
# ``_normalize_custom_provider_entry`` runs on every ``load_picker_context()``
# call (i.e. per interactive picker/inventory request), so any warning it emits
# fires repeatedly for the same static config. Deduplicate per (provider,
# signature): on Windows a repeated-warning storm contends on
# ``concurrent-log-handler``'s cross-process rotation lock and can peg a core /
# stall the gateway/serve event loop. The cache lives for the process lifetime.
_PROVIDER_NORMALIZE_WARNED: set = set()
def _warn_once_per_provider(
provider_key: str, signature: str, msg: str, *args: Any
) -> None:
"""Emit ``logger.warning(msg, *args)`` at most once per (provider, signature)."""
dedup_key = (provider_key or "?", signature)
if dedup_key in _PROVIDER_NORMALIZE_WARNED:
return
_PROVIDER_NORMALIZE_WARNED.add(dedup_key)
logger.warning(msg, *args)
def _normalize_custom_provider_entry(
entry: Any,
*,
provider_key: str = "",
) -> Optional[Dict[str, Any]]:
"""Return a runtime-compatible custom provider entry or ``None``."""
if not isinstance(entry, dict):
return None
# Accept camelCase aliases commonly used in hand-written configs.
_CAMEL_ALIASES: Dict[str, str] = {
"apiKey": "api_key",
"baseUrl": "base_url",
"apiMode": "api_mode",
"keyEnv": "key_env",
"apiKeyEnv": "key_env", # alias — OpenClaw-compatible + docs variant
"defaultModel": "default_model",
"contextLength": "context_length",
"rateLimitDelay": "rate_limit_delay",
}
# api_key_env is a documented snake_case alias for key_env (see
# website/docs/guides/azure-foundry.md). Normalize it up front so the
# rest of the normalizer treats it as the canonical field.
if "api_key_env" in entry and "key_env" not in entry:
entry["key_env"] = entry["api_key_env"]
_KNOWN_KEYS = {
# ``provider`` duplicates the ``providers.<name>`` mapping key and is
# unused here, but Hermes' own config writer has historically emitted it
# into provider entries. Accept it silently so those (self-written)
# configs don't warn on every load.
"provider",
"name", "api", "url", "base_url", "api_key", "key_env", "api_key_env",
"api_mode", "transport", "model", "default_model", "models",
"context_length", "rate_limit_delay",
"request_timeout_seconds", "stale_timeout_seconds",
"discover_models", "extra_body", "extra_headers",
"ssl_ca_cert", "ssl_verify",
}
for camel, snake in _CAMEL_ALIASES.items():
if camel in entry and snake not in entry:
_warn_once_per_provider(
provider_key, f"camel:{camel}",
"providers.%s: camelCase key '%s' auto-mapped to '%s' "
"(use snake_case to avoid this warning)",
provider_key or "?", camel, snake,
)
entry[snake] = entry[camel]
unknown = set(entry.keys()) - _KNOWN_KEYS - set(_CAMEL_ALIASES.keys())
if unknown:
_warn_once_per_provider(
provider_key, "unknown:" + ",".join(sorted(unknown)),
"providers.%s: unknown config keys ignored: %s",
provider_key or "?", ", ".join(sorted(unknown)),
)
from urllib.parse import urlparse
base_url = ""
for url_key in ("base_url", "url", "api"):
raw_url = entry.get(url_key)
if isinstance(raw_url, str) and raw_url.strip():
candidate = raw_url.strip()
# Accept URLs containing unresolved placeholder tokens — both
# ``${ENV_VAR}`` env-refs and bare ``{region}``-style templates —
# without URL validation. They are expanded at runtime, so a
# caller reaching this normalizer with raw (un-expanded) config
# would otherwise see the provider silently dropped (#14457).
if re.search(r"\{[^}]+\}", candidate):
base_url = candidate
break
parsed = urlparse(candidate)
if parsed.scheme and parsed.netloc:
base_url = candidate
break
else:
logger.warning(
"providers.%s: '%s' value '%s' is not a valid URL "
"(no scheme or host) — skipped",
provider_key or "?", url_key, candidate,
)
if not base_url:
return None
name = ""
raw_name = entry.get("name")
if isinstance(raw_name, str) and raw_name.strip():
name = raw_name.strip()
elif provider_key.strip():
name = provider_key.strip()
if not name:
return None
normalized: Dict[str, Any] = {
"name": name,
"base_url": base_url,
}
provider_key = provider_key.strip()
if provider_key:
normalized["provider_key"] = provider_key
api_key = entry.get("api_key")
if isinstance(api_key, str) and api_key.strip():
normalized["api_key"] = api_key.strip()
key_env = entry.get("key_env")
if isinstance(key_env, str) and key_env.strip():
normalized["key_env"] = key_env.strip()
api_mode = entry.get("api_mode") or entry.get("transport")
if isinstance(api_mode, str) and api_mode.strip():
normalized["api_mode"] = api_mode.strip()
model_name = entry.get("model") or entry.get("default_model")
if isinstance(model_name, str) and model_name.strip():
normalized["model"] = model_name.strip()
models = entry.get("models")
if isinstance(models, dict) and models:
normalized["models"] = models
elif isinstance(models, list) and models:
# Hand-edited configs (and older Hermes versions) may write
# ``models`` as a plain list of ids or as ``[{id: ...}]`` rows.
# Preserve both by converting to the dict shape downstream code
# expects; otherwise normalize silently drops the list and /model
# shows the provider with (0) models.
normalized_models: Dict[str, Any] = {}
for item in models:
if isinstance(item, str) and item.strip():
normalized_models[item.strip()] = {}
continue
if not isinstance(item, dict):
continue
model_id = item.get("id")
if not isinstance(model_id, str) or not model_id.strip():
model_id = item.get("name")
if not isinstance(model_id, str) or not model_id.strip():
continue
model_meta = {
k: v for k, v in item.items() if k not in {"id", "name"}
}
normalized_models[model_id.strip()] = model_meta
if normalized_models:
normalized["models"] = normalized_models
context_length = entry.get("context_length")
if isinstance(context_length, int) and context_length > 0:
normalized["context_length"] = context_length
rate_limit_delay = entry.get("rate_limit_delay")
if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:
normalized["rate_limit_delay"] = rate_limit_delay
discover_models = entry.get("discover_models")
if isinstance(discover_models, bool):
normalized["discover_models"] = discover_models
extra_body = entry.get("extra_body")
if isinstance(extra_body, dict):
normalized["extra_body"] = dict(extra_body)
# Per-provider extra HTTP headers (proxies, gateways, custom auth).
# Values may carry credentials (e.g. CF-Access-Client-Secret) — never
# log them anywhere downstream.
normalized_headers = normalize_extra_headers(entry.get("extra_headers"))
if normalized_headers:
normalized["extra_headers"] = normalized_headers
ssl_ca_cert = entry.get("ssl_ca_cert")
if isinstance(ssl_ca_cert, str) and ssl_ca_cert.strip():
normalized["ssl_ca_cert"] = ssl_ca_cert.strip()
ssl_verify = entry.get("ssl_verify")
if isinstance(ssl_verify, bool):
normalized["ssl_verify"] = ssl_verify
elif isinstance(ssl_verify, str) and ssl_verify.strip():
normalized["ssl_verify"] = ssl_verify.strip()
return normalized
def _custom_provider_entry_to_provider_config(
entry: Any,
*,
provider_key: str = "",
) -> Optional[Dict[str, Any]]:
"""Translate a legacy custom provider entry to the v12 providers shape."""
normalized = _normalize_custom_provider_entry(
dict(entry) if isinstance(entry, dict) else entry,
provider_key=provider_key,
)
if normalized is None:
return None
provider_entry: Dict[str, Any] = {"api": normalized["base_url"]}
for field in (
"name",
"api_key",
"key_env",
"models",
"context_length",
"rate_limit_delay",
"discover_models",
"extra_body",
"extra_headers",
"ssl_ca_cert",
"ssl_verify",
):
if field in normalized:
provider_entry[field] = normalized[field]
if "model" in normalized:
provider_entry["default_model"] = normalized["model"]
if "api_mode" in normalized:
provider_entry["transport"] = normalized["api_mode"]
return provider_entry
def providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:
"""Normalize ``providers`` config entries into the legacy custom-provider shape."""
if not isinstance(providers_dict, dict):
return []
custom_providers: List[Dict[str, Any]] = []
for key, entry in providers_dict.items():
if isinstance(entry, dict) and not is_provider_enabled(entry):
continue
normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))
if normalized is not None:
custom_providers.append(normalized)
return custom_providers
def get_compatible_custom_providers(
config: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""Return a deduplicated custom-provider view across legacy and v12+ config.
``custom_providers`` remains the on-disk legacy format, while ``providers``
is the newer keyed schema. Runtime and picker flows still need a single
list-shaped view, but we should not materialise that compatibility layer
back into config.yaml because it duplicates entries in UIs.
"""
if config is None:
config = load_config()
compatible: List[Dict[str, Any]] = []
seen_provider_keys: set = set()
seen_name_url_pairs: set = set()
def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:
if entry is None:
return
provider_key = str(entry.get("provider_key", "") or "").strip().lower()
name = str(entry.get("name", "") or "").strip().lower()
base_url = str(entry.get("base_url", "") or "").strip().rstrip("/").lower()
model = str(entry.get("model", "") or "").strip().lower()
pair = (name, base_url, model)
if provider_key and provider_key in seen_provider_keys:
return
if name and base_url and pair in seen_name_url_pairs:
return
compatible.append(entry)
if provider_key:
seen_provider_keys.add(provider_key)
if name and base_url:
seen_name_url_pairs.add(pair)
custom_providers = config.get("custom_providers")
if custom_providers is not None:
if not isinstance(custom_providers, list):
return []
for entry in custom_providers:
_append_if_new(_normalize_custom_provider_entry(entry))
for entry in providers_dict_to_custom_providers(config.get("providers")):
_append_if_new(entry)
return compatible
def _coerce_ssl_verify(value: Any) -> Optional[bool]:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"false", "0", "no", "off"}:
return False
if lowered in {"true", "1", "yes", "on"}:
return True
return None
def get_custom_provider_tls_settings(
base_url: str,
custom_providers: Optional[List[Dict[str, Any]]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Return TLS settings from a matching ``custom_providers`` / ``providers`` entry."""
if custom_providers is None:
try:
custom_providers = get_compatible_custom_providers(config)
except Exception:
custom_providers = []
if not base_url or not isinstance(custom_providers, list):
return {}
target_url = normalize_route_base_url(base_url)
for entry in custom_providers:
if not isinstance(entry, dict):
continue
entry_url = normalize_route_base_url(entry.get("base_url"))
if not entry_url or entry_url != target_url:
continue
out: Dict[str, Any] = {}
ca = entry.get("ssl_ca_cert")
if isinstance(ca, str) and ca.strip():
out["ssl_ca_cert"] = ca.strip()
verify = _coerce_ssl_verify(entry.get("ssl_verify"))
if verify is not None:
out["ssl_verify"] = verify
return out
return {}
def apply_custom_provider_tls_to_client_kwargs(
client_kwargs: Dict[str, Any],
base_url: str,
custom_providers: Optional[List[Dict[str, Any]]] = None,
config: Optional[Dict[str, Any]] = None,
) -> None:
"""Attach per-provider TLS knobs to OpenAI client kwargs when matched."""
tls = get_custom_provider_tls_settings(base_url, custom_providers, config)
if tls.get("ssl_ca_cert"):
client_kwargs["ssl_ca_cert"] = tls["ssl_ca_cert"]
if "ssl_verify" in tls:
client_kwargs["ssl_verify"] = tls["ssl_verify"]
def normalize_extra_headers(extra_headers: Any) -> Dict[str, str]:
"""Normalize a raw ``extra_headers`` value into a ``dict[str, str]``.
Stringifies keys and values and drops entries whose value is ``None``.
Returns ``{}`` for non-dict or empty inputs. This is the single shared
normalizer for per-provider ``extra_headers`` across config normalization,
runtime resolution, client construction, and live ``/models`` discovery.
SECURITY: header values routinely carry credentials (Cloudflare Access
service tokens, proxy auth, custom bearer schemes). Callers must never
log the returned values.
"""
if not isinstance(extra_headers, dict) or not extra_headers:
return {}
return {str(k): str(v) for k, v in extra_headers.items() if v is not None}
def get_custom_provider_extra_headers(
base_url: str,
custom_providers: Optional[List[Dict[str, Any]]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Dict[str, str]:
"""Return ``extra_headers`` from a matching ``providers`` / ``custom_providers`` entry.
Matches the entry whose normalized route identity equals *base_url*,
mirroring :func:`get_custom_provider_tls_settings`, and returns its
``extra_headers`` dict, or ``{}`` when no entry matches or declares none.
SECURITY: header values routinely carry credentials (Cloudflare Access
service tokens, proxy auth, custom bearer schemes). Callers must never
log the returned values.
"""
if custom_providers is None:
try:
custom_providers = get_compatible_custom_providers(config)
except Exception:
custom_providers = []
if not base_url or not isinstance(custom_providers, list):
return {}
target_url = normalize_route_base_url(base_url)
for entry in custom_providers:
if not isinstance(entry, dict):
continue
entry_url = normalize_route_base_url(entry.get("base_url"))
if not entry_url or entry_url != target_url:
continue
return normalize_extra_headers(entry.get("extra_headers"))
return {}
def apply_custom_provider_extra_headers_to_client_kwargs(
client_kwargs: Dict[str, Any],
base_url: str,
custom_providers: Optional[List[Dict[str, Any]]] = None,
config: Optional[Dict[str, Any]] = None,
) -> None:
"""Merge per-provider ``extra_headers`` onto OpenAI client ``default_headers``.
Provider-specific headers win over provider/SDK defaults already present in
``client_kwargs`` — they are the most specific configuration level. No-op
when the base_url matches no ``providers`` / ``custom_providers`` entry or
the entry declares no headers.
SECURITY: values may carry credentials — never log them.
"""
extra_headers = get_custom_provider_extra_headers(base_url, custom_providers, config)
if not extra_headers:
return
merged = dict(client_kwargs.get("default_headers") or {})
merged.update(extra_headers)
client_kwargs["default_headers"] = merged
def get_custom_provider_context_length(
model: str,
base_url: str,
custom_providers: Optional[List[Dict[str, Any]]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Optional[int]:
"""Look up a per-model ``context_length`` override from ``custom_providers``.
Matches any entry whose normalized route identity equals ``base_url`` and
returns ``custom_providers[i].models.<model>.context_length`` if present and
valid. Returns ``None`` when no override applies.
This is the single source of truth for custom-provider context overrides,
used by:
* ``AIAgent.__init__`` (startup resolution)
* ``AIAgent.switch_model`` (mid-session ``/model`` switch)
* ``hermes_cli.model_switch.resolve_display_context_length`` (``/model`` confirmation display)
* ``gateway.run._format_session_info`` (``/info`` display)
* ``agent.model_metadata.get_model_context_length`` (when custom_providers is threaded through)
Before this helper existed, the lookup was duplicated in ``run_agent.py``'s
startup path only; every other path (notably ``/model`` switch) fell back
to the 128K default. See #15779.
"""
if not model or not base_url:
return None
if custom_providers is None:
try:
custom_providers = get_compatible_custom_providers(config)
except Exception:
if config is None:
return None
raw = config.get("custom_providers")
custom_providers = raw if isinstance(raw, list) else []
if not isinstance(custom_providers, list):
return None
target_url = normalize_route_base_url(base_url)
if not target_url:
return None
for entry in custom_providers:
if not isinstance(entry, dict):
continue
entry_url = normalize_route_base_url(entry.get("base_url"))
if not entry_url or entry_url != target_url:
continue
models = entry.get("models")
if not isinstance(models, dict):
continue
model_cfg = models.get(model)
if not isinstance(model_cfg, dict):
continue
raw_ctx = model_cfg.get("context_length")
if raw_ctx is None:
continue
try:
ctx = int(raw_ctx)
except (TypeError, ValueError):
continue
if ctx > 0:
return ctx
return None
def _coerce_config_version(value: Any) -> int:
"""Return a safe integer config version, treating invalid values as legacy."""
if isinstance(value, bool):
return 0
try:
version = int(value)
except (TypeError, ValueError):
return 0
return max(version, 0)
def check_config_version() -> Tuple[int, int]:
"""
Check the raw on-disk config schema version.
``load_config()`` deliberately starts from ``DEFAULT_CONFIG`` and deep-merges
the user's file, which is correct for runtime reads but wrong for deciding
whether the user's persisted schema has been migrated. A config file with no
raw ``_config_version`` must remain visible as legacy instead of inheriting
the latest default version in memory.
Returns (current_version, latest_version).
"""
latest = _coerce_config_version(DEFAULT_CONFIG.get("_config_version", 1)) or 1
config_path = get_config_path()
if not config_path.exists():
return latest, latest
try:
with open(config_path, encoding="utf-8") as f:
config = fast_safe_load(f) or {}
except Exception as e:
# Invalid YAML needs a parse warning, not an automatic schema rewrite
# that could replace the user's broken file with defaults.
_warn_config_parse_failure(config_path, e)
return latest, latest
if not isinstance(config, dict):
config = {}
current = _coerce_config_version(config.get("_config_version"))
return current, latest
# =============================================================================
# Config structure validation
# =============================================================================
# Fields that are valid at root level of config.yaml.
# DEFAULT_CONFIG is the single source of truth for documented roots; keep this
# set derived so new defaults (skills, security, browser, …) are accepted
# automatically. A few optional/legacy roots are valid on disk but intentionally
# absent from DEFAULT_CONFIG (omitted when unused / alternate schema forms).
_EXTRA_KNOWN_ROOT_KEYS = {
"custom_providers", # legacy list form; modern equivalent is providers: {}
"fallback_model", # optional single dict or chain list; omitted when disabled
"mcp_servers", # MCP server definitions written by setup/tools flows
# Roots read from the raw user YAML (or written by our own flows) that are
# intentionally absent from DEFAULT_CONFIG:
"image_gen", # image-generation provider config (agent/image_gen_registry.py)
"video_gen", # video-generation provider config (agent/video_gen_registry.py)
"plugins", # plugin enable/disable lists (hermes_cli/plugins_cmd.py)
"smart_model_routing", # written by the setup wizard (hermes_cli/setup.py)
"platform_toolsets", # written by the setup wizard (hermes_cli/setup.py)
"known_plugin_toolsets", # written/read by hermes_cli/tools_config.py toolset-save flow
"session_reset", # top-level form read by gateway/config.py + setup
"group_sessions_per_user", # top-level form bridged by gateway/config.py
"thread_sessions_per_user", # top-level form bridged by gateway/config.py
"stt_echo_transcripts", # top-level form bridged by gateway/config.py
"reset_triggers", # top-level form bridged by gateway/config.py
"always_log_local", # top-level form bridged by gateway/config.py
"filter_silence_narration", # top-level form bridged by gateway/config.py
"multiplex_profiles", # top-level form accepted alongside gateway.multiplex_profiles
"profile_routes", # top-level form accepted alongside gateway.profile_routes
"platforms", # top-level per-platform map merged by gateway/config.py
"require_mention", # top-level convenience form honored by the gateway (#3979)
"unauthorized_dm_behavior", # top-level form read by gateway/config.py
"signal", # Signal settings bridged to env vars by gateway/config.py
}
_KNOWN_ROOT_KEYS = frozenset(DEFAULT_CONFIG.keys()) | _EXTRA_KNOWN_ROOT_KEYS
# Valid fields inside a custom_providers list entry
_VALID_CUSTOM_PROVIDER_FIELDS = {
"name", "base_url", "api_key", "api_mode", "model", "models",
"context_length", "rate_limit_delay", "extra_body",
"ssl_ca_cert", "ssl_verify",
# key_env is read at runtime by runtime_provider.py and auxiliary_client.py
# — include it here so the set accurately describes the supported schema.
"key_env",
}
# Fields that look like they should be inside custom_providers, not at root
_CUSTOM_PROVIDER_LIKE_FIELDS = {"base_url", "api_key", "rate_limit_delay", "api_mode"}
@dataclass
class ConfigIssue:
"""A detected config structure problem."""
severity: str # "error", "warning"
message: str
hint: str
def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["ConfigIssue"]:
"""Validate config.yaml structure and return a list of detected issues.
Catches common YAML formatting mistakes that produce confusing runtime
errors (like "Unknown provider") instead of clear diagnostics.
Can be called with a pre-loaded config dict, or will load from disk.
"""
if config is None:
try:
config = load_config()
except Exception:
return [ConfigIssue("error", "Could not load config.yaml", "Run 'hermes setup' to create a valid config")]
issues: List[ConfigIssue] = []
# ── custom_providers must be a list, not a dict ──────────────────────
cp = config.get("custom_providers")
if cp is not None:
if isinstance(cp, dict):
issues.append(ConfigIssue(
"error",
"custom_providers is a dict — it must be a YAML list (items prefixed with '-')",
"Change to:\n"
" custom_providers:\n"
" - name: my-provider\n"
" base_url: https://...\n"
" api_key: ...",
))
# Check if dict keys look like they should be list-entry fields
cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()
suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS
if suspicious:
issues.append(ConfigIssue(
"warning",
f"Root-level keys {sorted(suspicious)} look like custom_providers entry fields",
"These should be indented under a '- name: ...' list entry, not at root level",
))
elif isinstance(cp, list):
# Validate each entry in the list
for i, entry in enumerate(cp):
if not isinstance(entry, dict):
issues.append(ConfigIssue(
"warning",
f"custom_providers[{i}] is not a dict (got {type(entry).__name__})",
"Each entry should have at minimum: name, base_url",
))
continue
if not entry.get("name"):
issues.append(ConfigIssue(
"warning",
f"custom_providers[{i}] is missing 'name' field",
"Add a name, e.g.: name: my-provider",
))
if not entry.get("base_url"):
issues.append(ConfigIssue(
"warning",
f"custom_providers[{i}] is missing 'base_url' field",
"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1",
))
# ── fallback_model: single dict OR list of dicts (chain) ─────────────
fb = config.get("fallback_model")
if fb is not None:
if isinstance(fb, list):
# Chain fallback — validate each entry
for i, entry in enumerate(fb):
if not isinstance(entry, dict):
issues.append(ConfigIssue(
"error",
f"fallback_model[{i}] should be a dict, got {type(entry).__name__}",
"Each entry needs provider + model",
))
else:
if not entry.get("provider"):
issues.append(ConfigIssue(
"warning",
f"fallback_model[{i}] is missing 'provider' field",
"Add: provider: openrouter (or another provider)",
))
if not entry.get("model"):
issues.append(ConfigIssue(
"warning",
f"fallback_model[{i}] is missing 'model' field",
"Add: model: <model-name>",
))
elif not isinstance(fb, dict):
issues.append(ConfigIssue(
"error",
f"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}",
"Change to:\n"
" fallback_model:\n"
" provider: openrouter\n"
" model: anthropic/claude-sonnet-4",
))
elif fb:
if not fb.get("provider"):
issues.append(ConfigIssue(
"warning",
"fallback_model is missing 'provider' field — fallback will be disabled",
"Add: provider: openrouter (or another provider)",
))
if not fb.get("model"):
issues.append(ConfigIssue(
"warning",
"fallback_model is missing 'model' field — fallback will be disabled",
"Add: model: anthropic/claude-sonnet-4 (or another model)",
))
# ── Check for fallback_model accidentally nested inside custom_providers ──
if isinstance(cp, dict) and "fallback_model" not in config and "fallback_model" in (cp or {}):
issues.append(ConfigIssue(
"error",
"fallback_model appears inside custom_providers instead of at root level",
"Move fallback_model to the top level of config.yaml (no indentation)",
))
# ── model section: should exist when custom_providers is configured ──
model_cfg = config.get("model")
if cp and not model_cfg:
issues.append(ConfigIssue(
"warning",
"custom_providers defined but no 'model' section — Hermes won't know which provider to use",
"Add a model section:\n"
" model:\n"
" provider: custom\n"
" default: your-model-name\n"
" base_url: https://...",
))
# ── Root-level keys that look misplaced ──────────────────────────────
# Only provider-like fields (base_url, api_key, …) are flagged. Arbitrary
# unknown top-level keys are deliberately NOT warned about: top-level
# scalars are bridged into os.environ (gateway/run.py, hermes send) so
# users can feed skills and external apps env-style keys from config.yaml
# — a closed-world allowlist can never enumerate those.
for key in config:
if key.startswith("_"):
continue
if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:
issues.append(ConfigIssue(
"warning",
f"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?",
f"Move '{key}' under the appropriate section",
))
return issues
def print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:
"""Print config structure warnings to stderr at startup.
Called early in CLI and gateway init so users see problems before
they hit cryptic "Unknown provider" errors. Prints nothing if
config is healthy.
"""
try:
issues = validate_config_structure(config)
except Exception:
return
if not issues:
return
lines = ["\033[33m⚠ Config issues detected in config.yaml:\033[0m"]
for ci in issues:
marker = "\033[31m✗\033[0m" if ci.severity == "error" else "\033[33m⚠\033[0m"
lines.append(f" {marker} {ci.message}")
lines.append(" \033[2mRun 'hermes doctor' for fix suggestions.\033[0m")
sys.stderr.write("\n".join(lines) + "\n\n")
def warn_deprecated_cwd_env_vars(config: Optional[Dict[str, Any]] = None) -> None:
"""Warn if MESSAGING_CWD or TERMINAL_CWD is set in .env instead of config.yaml.
These env vars are deprecated — the canonical setting is terminal.cwd
in config.yaml. Prints a migration hint to stderr.
"""
messaging_cwd = os.environ.get("MESSAGING_CWD")
terminal_cwd_env = os.environ.get("TERMINAL_CWD")
if config is None:
try:
config = load_config()
except Exception:
return
terminal_cfg = config.get("terminal", {})
config_cwd = terminal_cfg.get("cwd", ".") if isinstance(terminal_cfg, dict) else "."
# Only warn if config.yaml doesn't have an explicit path
config_has_explicit_cwd = config_cwd not in {".", "auto", "cwd", ""}
lines: list[str] = []
if messaging_cwd:
lines.append(
f" \033[33m⚠\033[0m MESSAGING_CWD={messaging_cwd} found in .env — "
f"this is deprecated."
)
if terminal_cwd_env and not config_has_explicit_cwd:
# TERMINAL_CWD in env but not from config bridge — likely from .env
lines.append(
f" \033[33m⚠\033[0m TERMINAL_CWD={terminal_cwd_env} found in .env — "
f"this is deprecated."
)
if lines:
hint_path = os.environ.get("HERMES_HOME", "~/.hermes")
lines.insert(0, "\033[33m⚠ Deprecated .env settings detected:\033[0m")
lines.append(
" \033[2mMove to config.yaml instead: "
"terminal:\\n cwd: /your/project/path\033[0m"
)
lines.append(
f" \033[2mThen remove the old entries from {hint_path}/.env\033[0m"
)
sys.stderr.write("\n".join(lines) + "\n\n")
def _persist_migration(config: Dict[str, Any]) -> None:
"""Persist a migrated config under the migration write invariant.
THE INVARIANT (single source of truth for the whole migration pipeline):
a migration may only persist values that DIFFER from the current schema
default, plus explicit removals/renames of user data. Pure schema defaults
are never materialised to disk — ``load_config()``'s deep-merge supplies
them at read time, so writing them adds nothing and actively shadows future
default changes (see ``save_config``'s docstring). Materialising defaults on
every version bump is what rewrote hand-curated configs into full
DEFAULT_CONFIG dumps (the "hermes update / hermes -p blows up my config"
reports).
Every migration step MUST route its write through this helper instead of
calling ``save_config`` directly. It is a thin wrapper over
``save_config(config)`` (default-stripping ON, no ``merge_existing``);
centralising the call makes the invariant impossible to regress one
migration at a time. Callers must pass the full raw config returned by
``read_raw_config()`` after in-place mutations (including key removals);
deep-merging the on-disk file back in would resurrect keys the migration
just deleted. Partial-save preservation for unrelated top-level sections
belongs on ``save_config(..., merge_existing=True)``, not here.
"""
save_config(config)
def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:
"""
Migrate config to latest version, prompting for new required fields.
Args:
interactive: If True, prompt user for missing values
quiet: If True, suppress output
Returns:
Dict with migration results: {"env_added": [...], "config_added": [...], "warnings": [...]}
"""
results = {"env_added": [], "config_added": [], "warnings": []}
# ── Always: sanitize .env (split concatenated keys) ──
try:
fixes = sanitize_env_file()
if fixes and not quiet:
print(f" ✓ Repaired .env file ({fixes} corrupted entries fixed)")
except Exception:
pass # best-effort; don't block migration on sanitize failure
# Check config version
current_ver, latest_ver = check_config_version()
# ── Version 3 → 4: migrate tool progress from .env to config.yaml ──
if current_ver < 4:
config = read_raw_config()
display = config.get("display", {})
if not isinstance(display, dict):
display = {}
if "tool_progress" not in display:
old_enabled = get_env_value("HERMES_TOOL_PROGRESS")
old_mode = get_env_value("HERMES_TOOL_PROGRESS_MODE")
if old_enabled and old_enabled.lower() in {"false", "0", "no"}:
display["tool_progress"] = "off"
results["config_added"].append("display.tool_progress=off (from HERMES_TOOL_PROGRESS=false)")
elif old_mode and old_mode.lower() in {"new", "all", "verbose"}:
display["tool_progress"] = old_mode.lower()
results["config_added"].append(f"display.tool_progress={old_mode.lower()} (from HERMES_TOOL_PROGRESS_MODE)")
else:
display["tool_progress"] = "all"
results["config_added"].append("display.tool_progress=all (default)")
config["display"] = display
_persist_migration(config)
if not quiet:
print(f" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}")
# ── Version 4 → 5: add timezone field ──
if current_ver < 5:
config = read_raw_config()
if "timezone" not in config:
old_tz = os.getenv("HERMES_TIMEZONE", "")
if old_tz and old_tz.strip():
config["timezone"] = old_tz.strip()
results["config_added"].append(f"timezone={old_tz.strip()} (from HERMES_TIMEZONE)")
else:
config["timezone"] = ""
results["config_added"].append("timezone= (empty, uses server-local)")
_persist_migration(config)
if not quiet:
tz_display = config["timezone"] or "(server-local)"
print(f" ✓ Added timezone to config.yaml: {tz_display}")
# ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──
# The new Anthropic auth flow no longer uses this env var.
if current_ver < 9:
try:
old_token = get_env_value("ANTHROPIC_TOKEN")
if old_token:
save_env_value("ANTHROPIC_TOKEN", "")
if not quiet:
print(" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)")
except Exception:
pass
# ── Version 11 → 12: migrate custom_providers list → providers dict ──
if current_ver < 12:
config = read_raw_config()
custom_list = config.get("custom_providers")
if isinstance(custom_list, list) and custom_list:
providers_dict = config.get("providers", {})
if not isinstance(providers_dict, dict):
providers_dict = {}
migrated_count = 0
for entry in custom_list:
if not isinstance(entry, dict):
continue
old_name = entry.get("name", "")
old_url = entry.get("base_url", "") or entry.get("url", "") or entry.get("api", "") or ""
if not old_url:
continue # skip entries with no URL
# Generate a kebab-case key from the display name
key = old_name.strip().lower().replace(" ", "-").replace("(", "").replace(")", "")
# Remove consecutive hyphens and trailing hyphens
while "--" in key:
key = key.replace("--", "-")
key = key.strip("-")
if not key:
# Fallback: derive from URL hostname
try:
from urllib.parse import urlparse
parsed = urlparse(old_url)
key = (parsed.hostname or "endpoint").replace(".", "-")
except Exception:
key = f"endpoint-{migrated_count}"
# Don't overwrite existing entries
base_key = key
suffix = migrated_count
while key in providers_dict:
key = f"{base_key}-{suffix}"
suffix += 1
new_entry = _custom_provider_entry_to_provider_config(
entry,
provider_key=key,
)
if new_entry is None:
continue
if not old_name:
new_entry.pop("name", None)
if new_entry.get("api_key") in {"no-key", "no-key-required", ""}:
new_entry.pop("api_key", None)
providers_dict[key] = new_entry
migrated_count += 1
if migrated_count > 0:
config["providers"] = providers_dict
# Remove the old list — runtime reads via get_compatible_custom_providers()
config.pop("custom_providers", None)
_persist_migration(config)
if not quiet:
print(f" ✓ Migrated {migrated_count} custom provider(s) to providers: section")
for key in list(providers_dict.keys())[-migrated_count:]:
ep = providers_dict[key]
print(f"{key}: {ep.get('api', '')}")
# ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──
# These env vars were written by the old setup wizard but nothing reads
# them anymore (config.yaml is the sole source of truth since March 2026).
# Stale entries cause user confusion — see issue report.
if current_ver < 13:
for dead_var in ("LLM_MODEL", "OPENAI_MODEL"):
try:
old_val = get_env_value(dead_var)
if old_val:
save_env_value(dead_var, "")
if not quiet:
print(f" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)")
except Exception:
pass
# ── Version 13 → 14: migrate legacy flat stt.model to provider section ──
# Old configs (and cli-config.yaml.example) had a flat `stt.model` key
# that was provider-agnostic. When the provider was "local" this caused
# OpenAI model names (e.g. "whisper-1") to be fed to faster-whisper,
# crashing with "Invalid model size". Move the value into the correct
# provider-specific section and remove the flat key.
if current_ver < 14:
# Read raw config (no defaults merged) to check what the user actually
# wrote, then apply changes to the merged config for saving.
raw = read_raw_config()
raw_stt = raw.get("stt", {})
if isinstance(raw_stt, dict) and "model" in raw_stt:
legacy_model = raw_stt["model"]
provider = raw_stt.get("provider", "local")
config = read_raw_config()
stt = config.get("stt", {})
# Remove the legacy flat key
stt.pop("model", None)
# Place it in the appropriate provider section only if the
# user didn't already set a model there
if provider in {"local", "local_command"}:
# Don't migrate an OpenAI model name into the local section
_local_models = {
"tiny.en", "tiny", "base.en", "base", "small.en", "small",
"medium.en", "medium", "large-v1", "large-v2", "large-v3",
"large", "distil-large-v2", "distil-medium.en",
"distil-small.en", "distil-large-v3", "distil-large-v3.5",
"large-v3-turbo", "turbo",
}
if legacy_model in _local_models:
# Check raw config — only set if user didn't already
# have a nested local.model
raw_local = raw_stt.get("local", {})
if not isinstance(raw_local, dict) or "model" not in raw_local:
local_cfg = stt.setdefault("local", {})
local_cfg["model"] = legacy_model
# else: drop it — it was an OpenAI model name, local section
# already defaults to "base" via DEFAULT_CONFIG
else:
# Cloud provider — put it in that provider's section only
# if user didn't already set a nested model
raw_provider = raw_stt.get(provider, {})
if not isinstance(raw_provider, dict) or "model" not in raw_provider:
provider_cfg = stt.setdefault(provider, {})
provider_cfg["model"] = legacy_model
config["stt"] = stt
_persist_migration(config)
if not quiet:
print(" ✓ Migrated legacy stt.model to provider-specific config")
# ── Version 14 → 15: add explicit gateway interim-message gate ──
if current_ver < 15:
config = read_raw_config()
display = config.get("display", {})
if not isinstance(display, dict):
display = {}
if "interim_assistant_messages" not in display:
display["interim_assistant_messages"] = True
config["display"] = display
results["config_added"].append("display.interim_assistant_messages=true (default)")
_persist_migration(config)
if not quiet:
print(" ✓ Added display.interim_assistant_messages=true")
# ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──
if current_ver < 16:
config = read_raw_config()
display = config.get("display", {})
if not isinstance(display, dict):
display = {}
old_overrides = display.get("tool_progress_overrides")
if isinstance(old_overrides, dict) and old_overrides:
platforms = display.get("platforms", {})
if not isinstance(platforms, dict):
platforms = {}
for plat, mode in old_overrides.items():
if plat not in platforms:
platforms[plat] = {}
if "tool_progress" not in platforms[plat]:
platforms[plat]["tool_progress"] = mode
display["platforms"] = platforms
config["display"] = display
_persist_migration(config)
if not quiet:
migrated = ", ".join(f"{p}={m}" for p, m in old_overrides.items())
print(f" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}")
results["config_added"].append("display.platforms (migrated from tool_progress_overrides)")
# ── Version 16 → 17: remove legacy compression.summary_* keys ──
if current_ver < 17:
config = read_raw_config()
comp = config.get("compression", {})
if isinstance(comp, dict):
s_model = comp.pop("summary_model", None)
s_provider = comp.pop("summary_provider", None)
s_base_url = comp.pop("summary_base_url", None)
migrated_keys = []
# Migrate non-empty, non-default values to auxiliary.compression
if s_model and str(s_model).strip():
aux = config.setdefault("auxiliary", {})
aux_comp = aux.setdefault("compression", {})
if not aux_comp.get("model"):
aux_comp["model"] = str(s_model).strip()
migrated_keys.append(f"model={s_model}")
if s_provider and str(s_provider).strip() not in {"", "auto"}:
aux = config.setdefault("auxiliary", {})
aux_comp = aux.setdefault("compression", {})
if not aux_comp.get("provider") or aux_comp.get("provider") == "auto":
aux_comp["provider"] = str(s_provider).strip()
migrated_keys.append(f"provider={s_provider}")
if s_base_url and str(s_base_url).strip():
aux = config.setdefault("auxiliary", {})
aux_comp = aux.setdefault("compression", {})
if not aux_comp.get("base_url"):
aux_comp["base_url"] = str(s_base_url).strip()
migrated_keys.append(f"base_url={s_base_url}")
if migrated_keys or s_model is not None or s_provider is not None or s_base_url is not None:
config["compression"] = comp
_persist_migration(config)
if not quiet:
if migrated_keys:
print(f" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}")
else:
print(" ✓ Removed unused compression.summary_* keys")
# ── Version 20 → 21: plugins are now opt-in; grandfather existing user plugins ──
# The loader now requires plugins to appear in ``plugins.enabled`` before
# loading. Existing installs had all discovered plugins loading by default
# (minus anything in ``plugins.disabled``). To avoid silently breaking
# those setups on upgrade, populate ``plugins.enabled`` with the set of
# currently-installed user plugins that aren't already disabled.
#
# Bundled plugins (shipped in the repo itself) are NOT grandfathered —
# they ship off for everyone, including existing users, so any user who
# wants one has to opt in explicitly.
if current_ver < 21:
config = read_raw_config()
plugins_cfg = config.get("plugins")
if not isinstance(plugins_cfg, dict):
plugins_cfg = {}
# Only migrate if the enabled allow-list hasn't been set yet.
if "enabled" not in plugins_cfg:
disabled = plugins_cfg.get("disabled", []) or []
if not isinstance(disabled, list):
disabled = []
disabled_set = set(disabled)
# Scan ``$HERMES_HOME/plugins/`` for currently installed user plugins.
grandfathered: List[str] = []
try:
user_plugins_dir = get_hermes_home() / "plugins"
if user_plugins_dir.is_dir():
for child in sorted(user_plugins_dir.iterdir()):
if not child.is_dir():
continue
manifest_file = child / "plugin.yaml"
if not manifest_file.exists():
manifest_file = child / "plugin.yml"
if not manifest_file.exists():
continue
try:
with open(manifest_file, encoding="utf-8") as _mf:
manifest = fast_safe_load(_mf) or {}
except Exception:
manifest = {}
name = manifest.get("name") or child.name
if name in disabled_set:
continue
grandfathered.append(name)
except Exception:
grandfathered = []
plugins_cfg["enabled"] = grandfathered
config["plugins"] = plugins_cfg
_persist_migration(config)
results["config_added"].append(
f"plugins.enabled (opt-in allow-list, {len(grandfathered)} grandfathered)"
)
if not quiet:
if grandfathered:
print(
f" ✓ Plugins now opt-in: grandfathered "
f"{len(grandfathered)} existing plugin(s) into plugins.enabled"
)
else:
print(
" ✓ Plugins now opt-in: no existing plugins to grandfather. "
"Use `hermes plugins enable <name>` to activate."
)
# ── Version 22 → 23: seed curator defaults + create logs/curator/ ──
# The curator (background skill maintenance) was added in PR #16049, but
# existing configs from before that PR (or before the April 2026
# unification under `auxiliary.curator`) never wrote the curator section
# to disk. The runtime deep-merge in `load_config()` fills defaults at
# read time, so the curator *functions*; but users can't see/edit the
# settings in their `config.yaml`, and `hermes curator status` has no
# stable logs dir to point at until the first run mkdir's it.
#
# This migration:
# 1. Writes the `curator` top-level section to config.yaml (enabled,
# interval_hours, min_idle_hours, stale_after_days, archive_after_days)
# — only keys the user hasn't already overridden.
# 2. Writes the `auxiliary.curator` aux-task slot (provider, model,
# base_url, api_key, timeout, extra_body) — canonical slot for
# routing the curator fork to a cheaper aux model.
# 3. Creates `~/.hermes/logs/curator/` if missing (belt-and-suspenders
# on top of ensure_hermes_home() — old profiles that predate this
# migration still benefit).
if current_ver < 23:
try:
curator_dir = get_hermes_home() / "logs" / "curator"
curator_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
results["warnings"].append(f"Could not create {curator_dir}: {e}")
config = read_raw_config()
touched = False
# (1) Top-level curator section — only add missing keys
_curator_defaults = DEFAULT_CONFIG.get("curator", {})
raw_curator = config.get("curator")
if not isinstance(raw_curator, dict):
raw_curator = {}
added_curator: List[str] = []
for k, v in _curator_defaults.items():
if k not in raw_curator:
raw_curator[k] = copy.deepcopy(v)
added_curator.append(k)
if added_curator:
config["curator"] = raw_curator
touched = True
# (2) auxiliary.curator task slot
_aux_curator_defaults = (
DEFAULT_CONFIG.get("auxiliary", {}).get("curator", {})
)
raw_aux = config.get("auxiliary")
if not isinstance(raw_aux, dict):
raw_aux = {}
raw_aux_curator = raw_aux.get("curator")
if not isinstance(raw_aux_curator, dict):
raw_aux_curator = {}
added_aux: List[str] = []
for k, v in _aux_curator_defaults.items():
if k not in raw_aux_curator:
raw_aux_curator[k] = copy.deepcopy(v)
added_aux.append(k)
if added_aux:
raw_aux["curator"] = raw_aux_curator
config["auxiliary"] = raw_aux
touched = True
if touched:
_persist_migration(config)
if added_curator:
results["config_added"].append(
f"curator ({len(added_curator)} default key(s))"
)
if not quiet:
print(
" ✓ Curator settings now available "
f"({', '.join(added_curator)}) — edit via `hermes config set`"
)
if added_aux:
results["config_added"].append(
f"auxiliary.curator ({len(added_aux)} default key(s))"
)
if not quiet:
print(
" ✓ auxiliary.curator settings now available "
f"({', '.join(added_aux)}) — edit via `hermes config set`"
)
# ── Version 24 → 25: lower model_catalog TTL 24h → 1h ──
# The model picker now refreshes its curated list hourly so freshly
# published model-catalog.json deploys reach users without a day-long
# stale window. Only rewrite the OLD default (24) — never clobber a
# value the user deliberately customized.
if current_ver < 25:
config = read_raw_config()
raw_mc = config.get("model_catalog")
if isinstance(raw_mc, dict) and raw_mc.get("ttl_hours") == 24:
raw_mc["ttl_hours"] = 1
config["model_catalog"] = raw_mc
_persist_migration(config)
results["config_added"].append("model_catalog.ttl_hours 24→1")
if not quiet:
print(" ✓ Lowered model_catalog.ttl_hours to 1 (hourly picker refresh)")
# ── Version 28 → 29: rename memory/skills write_mode → write_approval ──
# The tri-state write_mode (on|off|approve) was replaced by a clear boolean
# write_approval (default false = gate off, writes flow freely; true =
# require approval). Only an explicit "approve" carried gating intent, so
# it maps to true; everything else (on/off/unset) → false. The old
# "off = block all writes" mode is dropped — memory_enabled: false disables
# memory entirely. Only rewrite a key the user actually persisted; never
# invent one.
if current_ver < 29:
config = read_raw_config()
touched = False
for subsystem in ("memory", "skills"):
sub = config.get(subsystem)
if not isinstance(sub, dict) or "write_mode" not in sub:
continue
old = sub.pop("write_mode")
old_norm = old.strip().lower() if isinstance(old, str) else old
sub["write_approval"] = (old_norm == "approve")
config[subsystem] = sub
touched = True
results["config_added"].append(
f"{subsystem}.write_mode → write_approval={sub['write_approval']}"
)
if touched:
_persist_migration(config)
if not quiet:
print(" ✓ Renamed write_mode → write_approval (boolean gate)")
# ── Version 29 → 30: curator.consolidate defaults to false ──
# Consolidation (the LLM umbrella-building fork) is opt-in, OFF by default;
# the deterministic inactivity prune still runs whenever the curator is
# enabled. No write is needed: the schema default (curator.consolidate=false)
# is supplied by load_config()'s deep-merge at read time, and persisting a
# default-valued key would only bloat a lean config (it gets stripped on
# save anyway). Existing installs that WANT the old always-consolidate
# behavior set it to true explicitly via `hermes config set`.
# ── Version 30 → 31: switch verify_on_stop OFF (one-time) ──
# verify_on_stop defaulted to the "auto" sentinel (surface-aware: on for
# interactive coding surfaces). In practice the verification narrative was
# more noise than signal — it even fired on doc/markdown/skill edits with
# nothing to verify. The new default is OFF. This migration switches
# existing installs off ONCE, but only when the user never expressed an
# explicit preference: we rewrite the value only if it's missing or still
# the "auto" sentinel. An explicit true/false the user set is preserved.
if current_ver < 31:
config = read_raw_config()
raw_agent = config.get("agent")
if not isinstance(raw_agent, dict):
raw_agent = {}
cur = raw_agent.get("verify_on_stop")
is_auto_sentinel = (
isinstance(cur, str) and cur.strip().lower() == "auto"
)
# Only flip the non-committal states; leave explicit bool/on/off alone.
if cur is None or is_auto_sentinel:
raw_agent["verify_on_stop"] = False
config["agent"] = raw_agent
_persist_migration(config)
results["config_added"].append("agent.verify_on_stop=false")
if not quiet:
print(
" ✓ Turned off verify-on-stop (agent.verify_on_stop: false). "
"Set it to true to re-enable, or \"auto\" for the legacy "
"surface-aware behavior."
)
# ── Version 31 → 32: flip the BAKED-IN literal true to OFF (one-time) ──
# The v30→v31 flip above only caught missing/"auto" values. But the very
# first ship of verify-on-stop (config v30, commit 2f1a47b90) defaulted
# DEFAULT_CONFIG["agent"]["verify_on_stop"] to a literal True, and
# migrate_config persists defaults with strip_defaults=False — so every
# install that updated through v30 got `verify_on_stop: true` written into
# config.yaml as a literal. v31's guard deliberately preserves an explicit
# bool, so it skipped that whole population and left them ON. That literal
# true was never a user choice: the feature had no off-switch worth setting
# it against until v31 introduced one, so a true persisted before v32 is
# always the old machine default. Flip it off once here. A true the user
# sets AFTER v32 (config already at version 32) is never touched.
if current_ver < 32:
config = read_raw_config()
raw_agent = config.get("agent")
if isinstance(raw_agent, dict) and raw_agent.get("verify_on_stop") is True:
raw_agent["verify_on_stop"] = False
config["agent"] = raw_agent
_persist_migration(config)
results["config_added"].append("agent.verify_on_stop=false")
if not quiet:
print(
" ✓ Turned off verify-on-stop (agent.verify_on_stop: false) — "
"the old default was written into your config as a literal "
"true. Set it to true again to re-enable, or \"auto\" for the "
"legacy surface-aware behavior."
)
# ── Version 32 → 33: unify delegation concurrency caps ──
# delegation.max_async_children is deprecated: max_concurrent_children now
# caps both a single batch's parallelism and concurrent background
# delegation units. Fold a raised max_async_children into
# max_concurrent_children (take the max so nobody loses headroom), then
# drop the stale key.
if current_ver < 33:
config = read_raw_config()
raw_deleg = config.get("delegation")
if isinstance(raw_deleg, dict) and "max_async_children" in raw_deleg:
old_async = raw_deleg.pop("max_async_children")
try:
old_async_i = int(old_async)
except (TypeError, ValueError):
old_async_i = None
if old_async_i is not None and old_async_i > 3:
try:
cur_children = int(raw_deleg.get("max_concurrent_children", 3))
except (TypeError, ValueError):
cur_children = 3
if old_async_i > cur_children:
raw_deleg["max_concurrent_children"] = old_async_i
results["config_added"].append(
f"delegation.max_concurrent_children={old_async_i} "
f"(folded from deprecated max_async_children)"
)
config["delegation"] = raw_deleg
_persist_migration(config)
if not quiet:
print(
" ✓ Removed deprecated delegation.max_async_children — "
"delegation.max_concurrent_children now caps background "
"delegations too."
)
# ── Post-migration: disable exfiltration-shaped MCP stdio entries ──
# Users can hand-edit mcp_servers, and older installs may already contain a
# malicious entry. Preserve the stanza for auditability but mark it
# disabled so the next startup will not spawn it. (#45620)
config = read_raw_config()
raw_mcp_servers = config.get("mcp_servers")
if isinstance(raw_mcp_servers, dict):
try:
from hermes_cli.mcp_security import validate_mcp_server_entry as _validate_mcp_server_entry
except Exception:
_validate_mcp_server_entry = None
if _validate_mcp_server_entry:
mcp_touched = False
for server_name, entry in raw_mcp_servers.items():
if not isinstance(entry, dict):
continue
issues = _validate_mcp_server_entry(server_name, entry)
if not issues:
continue
entry["enabled"] = False
mcp_touched = True
results["warnings"].append(
f"Disabled suspicious MCP server '{server_name}'"
)
if not quiet:
for issue in issues:
print(f"{issue}")
print(f" ⚠ Disabled MCP server '{server_name}' pending review")
if mcp_touched:
config["mcp_servers"] = raw_mcp_servers
_persist_migration(config)
# ── Always: validate platform_toolsets after migration ──
# A migration (or hand-edit) that leaves an invalid toolset name in
# platform_toolsets silently disables the affected tools — resolve_toolset()
# returns [] for an unknown name, so the agent quietly loses tools with no
# error or warning. Surface it loudly instead. See #38798.
try:
from toolsets import validate_toolset
from hermes_cli.toolset_validation import validate_platform_toolsets
ts_warnings = validate_platform_toolsets(
read_raw_config().get("platform_toolsets"), validate_toolset
)
for w in ts_warnings:
results["warnings"].append(w)
if not quiet:
print(f"{w}")
except Exception as _ts_val_err:
# best-effort; never block migration on validation
logger.debug("platform_toolsets validation skipped: %s", _ts_val_err)
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")
# Check for missing required env vars
missing_env = get_missing_env_vars(required_only=True)
if missing_env and not quiet:
print("\n⚠️ Missing required environment variables:")
for var in missing_env:
print(f"{var['name']}: {var['description']}")
if interactive and missing_env:
print("\nLet's configure them now:\n")
for var in missing_env:
if var.get("url"):
print(f" Get your key at: {var['url']}")
if var.get("password"):
value = masked_secret_prompt(f" {var['prompt']}: ")
else:
value = input(f" {var['prompt']}: ").strip()
if value:
save_env_value(var["name"], value)
results["env_added"].append(var["name"])
print(f" ✓ Saved {var['name']}")
else:
results["warnings"].append(f"Skipped {var['name']} - some features may not work")
print()
# Check for missing optional env vars and offer to configure interactively
# Skip "advanced" vars (like OPENAI_BASE_URL) -- those are for power users
missing_optional = get_missing_env_vars(required_only=False)
required_names = {v["name"] for v in missing_env} if missing_env else set()
missing_optional = [
v for v in missing_optional
if v["name"] not in required_names and not v.get("advanced")
]
# Only offer to configure env vars that are NEW since the user's previous version
new_var_names = set()
for ver in range(current_ver + 1, latest_ver + 1):
new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))
if new_var_names and interactive and not quiet:
new_and_unset = [
(name, OPTIONAL_ENV_VARS[name])
for name in sorted(new_var_names)
if not get_env_value(name) and name in OPTIONAL_ENV_VARS
]
if new_and_unset:
print(f"\n {len(new_and_unset)} new optional key(s) in this update:")
for name, info in new_and_unset:
print(f"{name}{info.get('description', '')}")
print()
try:
answer = input(" Configure new keys? [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
answer = "n"
if answer in {"y", "yes"}:
print()
for name, info in new_and_unset:
if info.get("url"):
print(f" {info.get('description', name)}")
print(f" Get your key at: {info['url']}")
else:
print(f" {info.get('description', name)}")
if info.get("password"):
value = masked_secret_prompt(
f" {info.get('prompt', name)} (Enter to skip): "
)
else:
value = input(f" {info.get('prompt', name)} (Enter to skip): ").strip()
if value:
save_env_value(name, value)
results["env_added"].append(name)
print(f" ✓ Saved {name}")
print()
else:
print(" Set later with: hermes config set <key> <value>")
# Check for missing config fields.
#
# New default keys are NOT materialised to disk: load_config() deep-merges
# DEFAULT_CONFIG at read time, so a missing key already takes effect with
# its default (see _persist_migration's invariant). We surface the list for
# the informational "N new config option(s) available" display in
# `hermes update`, but only the version bump is persisted.
missing_config = get_missing_config_fields()
if missing_config:
results["config_added"].extend(field["key"] for field in missing_config)
if current_ver < latest_ver:
config = read_raw_config()
config["_config_version"] = latest_ver
_persist_migration(config)
# ── Skill-declared config vars ──────────────────────────────────────
# Skills can declare config.yaml settings they need via
# metadata.hermes.config in their SKILL.md frontmatter.
# Prompt for any that are missing/empty.
missing_skill_config = get_missing_skill_config_vars()
if missing_skill_config and interactive and not quiet:
print(f"\n {len(missing_skill_config)} skill setting(s) not configured:")
for var in missing_skill_config:
skill_name = var.get("skill", "unknown")
print(f"{var['key']}{var['description']} (from skill: {skill_name})")
print()
try:
answer = input(" Configure skill settings? [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
answer = "n"
if answer in {"y", "yes"}:
print()
config = read_raw_config()
try:
from agent.skill_utils import SKILL_CONFIG_PREFIX
except Exception:
SKILL_CONFIG_PREFIX = "skills.config"
for var in missing_skill_config:
default = var.get("default", "")
default_hint = f" (default: {default})" if default else ""
value = input(f" {var['prompt']}{default_hint}: ").strip()
if not value and default:
value = str(default)
if value:
storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}"
_set_nested(config, storage_key, value)
results["config_added"].append(var["key"])
print(f" ✓ Saved {var['key']} = {value}")
else:
results["warnings"].append(
f"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later"
)
print()
_persist_migration(config)
else:
print(" Set later with: hermes config set <key> <value>")
return results
def _merge_partial_save(raw: dict, override: dict) -> dict:
"""Merge *override* over *raw* for partial ``save_config`` writes.
Top-level sections omitted from *override* are preserved from *raw*.
Shared top-level dict sections are deep-merged so a caller can update one
nested key without dropping sibling keys from disk. Intentional key
removals within a section are not supported here — migration writes must
route through ``_persist_migration`` with a full ``read_raw_config()`` dict
instead.
"""
result = copy.deepcopy(override)
for key, value in raw.items():
if key not in result:
result[key] = copy.deepcopy(value)
elif isinstance(result.get(key), dict) and isinstance(value, dict):
result[key] = _deep_merge(value, result[key])
return result
def _deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge *override* into *base*, preserving nested defaults.
Keys in *override* take precedence. If both values are dicts the merge
recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will
keep the default ``tts.elevenlabs.model_id`` intact.
An empty section key in config.yaml (``terminal:`` with no value) parses
as YAML ``None``; treating that as an override would replace the entire
default dict with ``None`` and crash every downstream consumer that
expects a mapping (#58277). A ``None`` override of a dict default is
ignored — same as the key being absent.
"""
result = base.copy()
for key, value in override.items():
if (
key in result
and isinstance(result[key], dict)
and isinstance(value, dict)
):
result[key] = _deep_merge(result[key], value)
elif key in result and isinstance(result[key], dict) and value is None:
continue
else:
result[key] = value
return result
def _strip_dotted_keys(cfg: dict, dotted_keys: set) -> Tuple[dict, set]:
"""Remove the given dotted leaf keys from a nested config dict.
Returns ``(pruned_cfg, set_of_stripped_keys_that_were_present)``. Used by
``save_config`` to drop managed-scope leaves before persisting, so a bulk
write never writes a user value that would lose to the managed layer on the
next load. Only keys actually present in ``cfg`` are reported as stripped.
"""
stripped: set = set()
for dotted in dotted_keys:
parts = dotted.split(".")
node = cfg
for p in parts[:-1]:
if not isinstance(node, dict) or p not in node:
node = None
break
node = node[p]
if isinstance(node, dict) and parts[-1] in node:
del node[parts[-1]]
stripped.add(dotted)
return cfg, stripped
def _env_expand_match(m: re.Match) -> str:
"""Expand one ``${...}`` config reference.
Two accepted shapes, matching what MCP server config already resolves
(``tools/mcp_tool.py::_env_ref_name``):
* ``${VAR}`` — legacy bare name, resolved via ``os.environ``.
* ``${env:VAR}`` — Cursor-style SecretRef, same resolution after the
``env:`` prefix is stripped. Before this, the prefixed form worked in
MCP config but stayed a literal string in config.yaml — a confusing
half-support.
Other SecretRef sources (``file:``, ``bitwarden:``, ``vault:``, ...)
are NOT resolved here — external secret backends inject their values
into the environment at startup (the ``secrets:`` block), so a config
ref only ever needs the env shape. Unknown prefixes warn once and stay
verbatim so callers can detect them.
"""
raw = m.group(0)
inner = m.group(1).strip()
if inner.startswith("env:"):
name = inner[len("env:"):].strip()
if not name:
return raw
val = os.environ.get(name)
if val is not None:
return val
logger.warning(
"Config ref %r: %s is not set (check ~/.hermes/.env); "
"keeping the literal placeholder", raw, name,
)
return raw
if ":" in inner and re.match(r"^[a-z][a-z0-9_-]*:", inner):
# Looks like a SecretRef with a non-env source. Values from vault
# backends arrive via the secrets: block as env vars — point there
# instead of silently treating "bitwarden:FOO" as a var named
# "bitwarden:FOO".
logger.warning(
"Config ref %r uses source %r which is not resolvable in "
"config.yaml — external secret sources inject env vars at "
"startup, so reference the variable as ${env:NAME} instead",
raw, inner.split(":", 1)[0],
)
return raw
# Legacy ``${VAR}`` — bare name.
return os.environ.get(inner, raw)
def _env_ref_var_name(ref: str) -> Optional[str]:
"""Normalize a ``${...}`` body to the env-var name it reads, or None
when the ref uses a non-env source and never touches the environment."""
ref = ref.strip()
if ref.startswith("env:"):
name = ref[len("env:"):].strip()
return name or None
if ":" in ref and re.match(r"^[a-z][a-z0-9_-]*:", ref):
return None
return ref
def _expand_env_vars(obj):
"""Recursively expand ``${VAR}`` / ``${env:VAR}`` references in config
values.
Only string values are processed; dict keys, numbers, booleans, and
None are left untouched. Unresolved references (variable not in
``os.environ``) are kept verbatim so callers can detect them.
"""
if isinstance(obj, str):
return re.sub(r"\${([^}]+)}", _env_expand_match, obj)
if isinstance(obj, dict):
return {k: _expand_env_vars(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_expand_env_vars(item) for item in obj]
return obj
def _env_ref_snapshot(obj, snapshot=None):
"""Map every ``${VAR}`` / ``${env:VAR}`` name referenced in config values
to its current ``os.environ`` value (``None`` when unset).
Stored alongside cached ``load_config()`` results so a cache hit can
detect that the cached expansion was made against a *different*
environment — e.g. a ``load_config()`` that ran before
``load_hermes_dotenv()`` populated the process env, or an env var
rotated in-process after the first load. File mtime/size alone cannot
see either case (#58514).
``${env:VAR}`` refs are tracked under the real variable name; refs
with a non-env source prefix never read the environment, so they are
excluded from the snapshot.
"""
if snapshot is None:
snapshot = {}
if isinstance(obj, str):
for raw in re.findall(r"\${([^}]+)}", obj):
name = _env_ref_var_name(raw)
if name is not None:
snapshot[name] = os.environ.get(name)
elif isinstance(obj, dict):
for value in obj.values():
_env_ref_snapshot(value, snapshot)
elif isinstance(obj, list):
for item in obj:
_env_ref_snapshot(item, snapshot)
return snapshot
def _items_by_unique_name(items):
"""Return a name-indexed dict only when all items have unique string names."""
if not isinstance(items, list):
return None
indexed = {}
for item in items:
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
return None
name = item["name"]
if name in indexed:
return None
indexed[name] = item
return indexed
def _preserve_env_ref_templates(current, raw, loaded_expanded=None):
"""Restore raw ``${VAR}`` templates when a value is otherwise unchanged.
``load_config()`` expands env refs for runtime use. When a caller later
persists that config after modifying some unrelated setting, keep the
original on-disk template instead of writing the expanded plaintext
secret back to ``config.yaml``.
Prefer preserving the raw template when ``current`` still matches either
the value previously returned by ``load_config()`` for this config path or
the current environment expansion of ``raw``. This handles env-var
rotation between load and save while still treating mixed literal/template
string edits as caller-owned once their rendered value diverges.
"""
if isinstance(current, str) and isinstance(raw, str) and re.search(r"\${[^}]+}", raw):
if current == raw:
return raw
if isinstance(loaded_expanded, str) and current == loaded_expanded:
return raw
if _expand_env_vars(raw) == current:
return raw
return current
if isinstance(current, dict) and isinstance(raw, dict):
return {
key: _preserve_env_ref_templates(
value,
raw.get(key),
loaded_expanded.get(key) if isinstance(loaded_expanded, dict) else None,
)
for key, value in current.items()
}
if isinstance(current, list) and isinstance(raw, list):
# Prefer matching named config objects (e.g. custom_providers) by name
# so harmless reordering doesn't drop the original template. If names
# are duplicated, fall back to positional matching instead of silently
# shadowing one entry.
current_by_name = _items_by_unique_name(current)
raw_by_name = _items_by_unique_name(raw)
loaded_by_name = _items_by_unique_name(loaded_expanded)
if current_by_name is not None and raw_by_name is not None:
return [
_preserve_env_ref_templates(
item,
raw_by_name.get(item.get("name")),
loaded_by_name.get(item.get("name")) if loaded_by_name is not None else None,
)
for item in current
]
return [
_preserve_env_ref_templates(
item,
raw[index] if index < len(raw) else None,
loaded_expanded[index]
if isinstance(loaded_expanded, list) and index < len(loaded_expanded)
else None,
)
for index, item in enumerate(current)
]
return current
def _explicit_config_paths(config: Dict[str, Any]) -> Set[Tuple[str, ...]]:
"""Return leaf paths explicitly present in a raw config dict.
Computed on the **raw** (un-normalized, un-expanded) config so that
values injected by normalisation (e.g. ``agent.max_turns`` from
``DEFAULT_CONFIG``) are not mistakenly treated as user-set.
Used by ``save_config`` to build the *preserve* set passed to
``_strip_default_values`` so only user-authored keys survive the
defaults-strip pass.
"""
paths: Set[Tuple[str, ...]] = set()
def _walk(value: Any, path: Tuple[str, ...]) -> None:
if isinstance(value, dict):
for key, child in value.items():
_walk(child, path + (key,))
return
if path:
paths.add(path)
_walk(config, ())
return paths
def _strip_default_values(
config: Dict[str, Any],
defaults: Dict[str, Any] = DEFAULT_CONFIG,
preserve_keys: Optional[Set[Tuple[str, ...]]] = None,
) -> Dict[str, Any]:
"""Return *config* without keys whose values match *defaults*.
Keys in *preserve_keys* (explicitly present in the user's raw config,
before any normalisation) are always kept even when they equal the
default, so user-set values such as ``memory.user_char_limit: 2200``
survive a ``save_config`` round-trip.
Nested dicts whose every child is stripped are removed entirely so
default-only subtrees (e.g. ``gateway``) never bloat ``config.yaml``
when the user has nothing to say about them.
"""
preserve_keys = {("_config_version",)} | set(preserve_keys or ())
def _strip(value: Any, default: Any, path: Tuple[str, ...]) -> Any:
if path in preserve_keys:
return copy.deepcopy(value)
if isinstance(value, dict) and value:
default_dict = default if isinstance(default, dict) else {}
stripped: Dict[str, Any] = {}
for key, child in value.items():
child_default = default_dict.get(key)
stripped_child = _strip(child, child_default, path + (key,))
if stripped_child is not None:
stripped[key] = stripped_child
if stripped:
return stripped
# Entire subtree stripped — remove it
return None
if value == default:
return None
return copy.deepcopy(value)
result: Dict[str, Any] = {}
for key, value in config.items():
stripped = _strip(value, defaults.get(key), (key,))
if stripped is not None:
result[key] = stripped
return result
def _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:
"""Move stale root-level provider/base_url/context_length into model section.
Some users (or older code) placed ``provider:``, ``base_url:``, or
``context_length:`` at the config root instead of inside ``model:``.
These root-level keys are only used as a fallback when the corresponding
``model.*`` key is empty — they never override an existing value.
After migration the root-level keys are removed so they can't cause
confusion on subsequent loads.
Also aliases ``api_base`` → ``base_url`` (issue #8919). ``api_base`` is the
intuitive name OpenAI-SDK / LiteLLM users reach for, and ``hermes config set``
blindly accepts any dotted key — so ``model.api_base`` got written, confirmed,
and then silently ignored by the runtime resolver (which reads only
``model.base_url``), causing requests to fall back to OpenRouter. We migrate
the alias to the canonical key (fallback-only — never override an explicit
``base_url``) and drop the alias so it can't confuse later loads.
Finally, canonicalizes the model-id key to ``model.default`` (issue #34500).
The runtime resolver and ~14 other readers select the chat model via
``model.default``; ``model.model`` was already aliased inline at some sites
but ``model.name`` was not, so a custom-provider config like
``model: {name: <id>, provider: <custom>}`` resolved to an empty model and
the API request went out with ``model=`` (HTTP 400 from OpenAI-compatible
backends) — while display paths (``hermes status``/``dump``) read ``name``
and *showed* the model, making the failure silent. Normalizing here (the
single load/save chokepoint) means every reader, present and future, sees a
populated ``default`` and the stale alias is migrated out of config.yaml on
the next save. Precedence: ``default`` > ``model`` > ``name`` (never
overrides an explicit ``default``, so existing configs are unaffected).
"""
# Only act if there's something to migrate: root-level keys, an api_base
# alias, or a model dict whose id lives under a non-canonical key.
model_in = config.get("model")
model_has_alias = isinstance(model_in, dict) and model_in.get("api_base")
# A model dict needs canonicalization if its id lives under a non-canonical
# key (``model``/``name``) — either because ``default`` is empty (we must
# promote the alias) or because ``default`` is set but a stale alias still
# lingers (we must drop it so config.yaml ends up canonical).
model_needs_canon = isinstance(model_in, dict) and (
model_in.get("model") or model_in.get("name")
)
has_root = any(
config.get(k) for k in ("provider", "base_url", "context_length", "api_base")
)
if not has_root and not model_has_alias and not model_needs_canon:
return config
config = dict(config)
model = config.get("model")
if not isinstance(model, dict):
model = {"default": model} if model else {}
else:
model = dict(model)
config["model"] = model
for key in ("provider", "base_url", "context_length"):
root_val = config.get(key)
if root_val and not model.get(key):
model[key] = root_val
config.pop(key, None)
# api_base is an alias for base_url, at the root OR inside model.
for alias_val in (config.get("api_base"), model.get("api_base")):
if alias_val and not model.get("base_url"):
model["base_url"] = alias_val
config.pop("api_base", None)
model.pop("api_base", None)
# Canonicalize the model id to ``default``. ``model`` and ``name`` are
# last-resort aliases (in that order) — only consulted when ``default`` is
# empty, then dropped so later loads/saves can't reintroduce the ambiguity.
if not (model.get("default") or ""):
alias = model.get("model") or model.get("name")
if alias:
model["default"] = alias
if model.get("default"):
# Drop the now-redundant aliases so config.yaml ends up canonical.
model.pop("model", None)
model.pop("name", None)
return config
def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize legacy root-level max_turns into agent.max_turns.
Only injects the schema default when the user actually set max_turns
somewhere (root level or under ``agent``). A bare ``load_config()``
call that passes the result straight to ``save_config()`` should not
materialise ``agent.max_turns`` in config.yaml when the user never set
it — that makes the default sticky and blocks future schema changes.
"""
config = dict(config)
agent_config = dict(config.get("agent") or {})
had_root = "max_turns" in config
had_agent = "max_turns" in agent_config
if had_root and not had_agent:
agent_config["max_turns"] = config["max_turns"]
# Only inject the default when the user explicitly set max_turns
# (either root-level or under agent). Otherwise leave it absent so
# save_config can omit it and the schema default fills in at runtime.
if not had_root and not had_agent:
pass # deliberately do not inject DEFAULT_CONFIG default
elif "max_turns" not in agent_config:
agent_config["max_turns"] = DEFAULT_CONFIG["agent"]["max_turns"]
config["agent"] = agent_config
config.pop("max_turns", None)
return config
def is_provider_enabled(provider_cfg: Optional[Dict[str, Any]]) -> bool:
"""Return whether a ``providers.<name>`` config block is enabled.
A provider is enabled by default. Only an explicit ``enabled: false`` in
the block hides it from the model picker, ``/models`` listings, the
runtime resolver and the doctor / status output.
Backward-compat: configs without the ``enabled`` key keep working as
before — the default is ``True``.
Pass any non-dict (None, list, string) and you get ``True`` too, so
malformed entries don't disappear silently; they'll still be flagged
by the existing validation paths.
"""
if not isinstance(provider_cfg, dict):
return True
flag = provider_cfg.get("enabled", True)
if isinstance(flag, bool):
return flag
# YAML can produce strings for "true"/"false" depending on quoting.
if isinstance(flag, str):
return flag.strip().lower() not in {"false", "0", "no", "off"}
return bool(flag)
def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> Any:
"""Traverse nested dict keys safely, returning ``default`` on any miss.
Canonical helper for the ``cfg.get("X", {}).get("Y", default)`` pattern
that appears 50+ times across the codebase. Handles three common gotchas
in one place:
1. Missing intermediate keys (returns ``default``, no KeyError).
2. An intermediate value that's not a dict (e.g. a user wrote a string
where a section was expected). Returns ``default`` instead of
AttributeError on ``.get()``.
3. ``cfg is None`` (callers sometimes pass ``load_config() or None``).
Named ``cfg_get`` rather than ``cfg_path`` to avoid shadowing the
ubiquitous ``cfg_path = _hermes_home / "config.yaml"`` local variable
that appears in gateway/run.py, cron/scheduler.py, main.py, etc.
Explicit ``None`` values are returned as-is (matches ``dict.get(key,
default)`` semantics — ``default`` is only returned when the key is
*absent*, not when it's present but set to ``None``).
Examples:
>>> cfg_get({"agent": {"reasoning_effort": "high"}}, "agent", "reasoning_effort")
'high'
>>> cfg_get({}, "agent", "reasoning_effort", default="medium")
'medium'
>>> cfg_get({"agent": "oops_a_string"}, "agent", "reasoning_effort", default="low")
'low'
>>> cfg_get(None, "anything", default=42)
42
>>> cfg_get({"a": {"b": None}}, "a", "b", default="def") # explicit None preserved
>>> cfg_get({"a": {"b": False}}, "a", "b", default=True) # falsy values preserved
False
"""
if not isinstance(cfg, dict):
return default
node: Any = cfg
for key in keys:
if not isinstance(node, dict):
return default
if key not in node:
return default
node = node[key]
return node
def read_raw_config() -> Dict[str, Any]:
"""Read ~/.hermes/config.yaml as-is, without merging defaults or migrating.
Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't
be parsed. Use this for lightweight config reads where you just need a
single value and don't want the overhead of ``load_config()``'s deep-merge
+ migration pipeline.
Cached on the config file's (mtime_ns, size) — same strategy as
``load_config()``. Returns a deepcopy on every call since some callers
mutate the result before passing to ``save_config()``.
"""
with _CONFIG_LOCK:
try:
config_path = get_config_path()
st = config_path.stat()
cache_key = (st.st_mtime_ns, st.st_size)
except (FileNotFoundError, OSError):
return {}
path_key = str(config_path)
cached = _RAW_CONFIG_CACHE.get(path_key)
if cached is not None and cached[:2] == cache_key:
return copy.deepcopy(cached[2])
try:
with open(config_path, encoding="utf-8") as f:
data = fast_safe_load(f) or {}
except Exception as e:
_warn_config_parse_failure(config_path, e)
return {}
if not isinstance(data, dict):
data = {}
_RAW_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(data))
return data
def require_readable_config_before_write(config_path: Optional[Path] = None) -> None:
"""Refuse to replace an existing config.yaml that cannot be read."""
if config_path is None:
config_path = get_config_path()
try:
config_path.stat()
except FileNotFoundError:
return
except OSError as exc:
raise RuntimeError(
f"Refusing to overwrite {config_path}: existing config.yaml cannot be accessed "
f"({exc}). Fix the file permissions or move it aside first."
) from exc
try:
with open(config_path, "rb") as f:
f.read(1)
except OSError as exc:
raise RuntimeError(
f"Refusing to overwrite {config_path}: existing config.yaml cannot be read "
f"({exc}). Fix the file permissions or move it aside first."
) from exc
def atomic_config_write(config_path: Path, data: Any, **kwargs: Any) -> None:
"""Fail-closed atomic write for ``config.yaml``.
The single chokepoint every config-update path should use instead of
calling :func:`utils.atomic_yaml_write` directly. It runs
:func:`require_readable_config_before_write` first, so a full-file
replacement can never silently clobber an existing ``config.yaml`` that
degraded to an empty dict on read (permission error, broken mount,
transient I/O). New-file creation still works when the path is absent.
Root cause this guards: ``read_raw_config()`` returns ``{}`` for BOTH an
absent file and an unreadable-but-present file. Callers that read then
overwrite can't tell the two apart, so an unreadable config would be
replaced with only defaults or the single edited section. Routing every
write through this helper enforces the invariant in one place rather than
relying on each of ~15 independent write sites to remember the guard.
``kwargs`` are forwarded verbatim to ``atomic_yaml_write``
(``sort_keys``, ``default_flow_style``, ``extra_content``, ...).
"""
from utils import atomic_yaml_write
require_readable_config_before_write(config_path)
atomic_yaml_write(config_path, data, **kwargs)
def load_config() -> Dict[str, Any]:
"""Load configuration from ~/.hermes/config.yaml.
Cached on the config file's (mtime_ns, size). Returns a deepcopy of
the cached value when unchanged, since most call sites mutate the
result (e.g. ``cfg["model"]["default"] = ...`` before ``save_config``).
The cache is keyed on ``str(config_path)`` so profile switches
(which change ``HERMES_HOME`` and therefore ``get_config_path()``)
don't collide.
Read-only callers should use ``load_config_readonly()`` to skip the
defensive deepcopy — that path matters in agent-loop hot spots like
``get_provider_request_timeout`` which is called once per API turn.
"""
return _load_config_impl(want_deepcopy=True)
def load_config_readonly() -> Dict[str, Any]:
"""Fast-path variant of ``load_config()`` for callers that ONLY READ.
Returns the cached config dict directly without the defensive deepcopy
that ``load_config()`` applies. **Mutating the returned dict (or any
nested structure) corrupts the in-process cache for every subsequent
caller** — only use this when you are absolutely sure your code path
will not write to the result. If you need to mutate or pass to
``save_config``, call ``load_config()`` instead.
Why this exists: ``load_config()`` cache-hit cost is ~265us per call,
half of which (~135us) is the defensive deepcopy. The agent loop calls
into config reads (timeouts, thresholds, feature flags) ~20-50x per
conversation; skipping deepcopy here removes a measurable allocation
source and the GC pressure that comes with it.
Note: this returns a plain ``dict`` (not ``MappingProxyType``) so
existing ``isinstance(x, dict)`` guards downstream keep working. The
safety guarantee is purely documented, not enforced — be careful.
"""
return _load_config_impl(want_deepcopy=False)
def write_platform_config_field(
platform_key: str,
field_key: str,
value: Any,
*,
raw: bool = False,
) -> None:
"""Persist one scalar field under ``platforms.<platform_key>``.
``raw=True`` preserves CLI setup flows that intentionally edit only the
user's raw config file. Dashboard routes use the default loaded-config path
so they retain their existing profile-scoped ``load_config`` behavior.
"""
config = read_raw_config() if raw else load_config()
platforms = config.setdefault("platforms", {})
if not isinstance(platforms, dict):
platforms = {}
config["platforms"] = platforms
platform_config = platforms.setdefault(platform_key, {})
if not isinstance(platform_config, dict):
platform_config = {}
platforms[platform_key] = platform_config
platform_config[field_key] = value
save_config(config)
TERMINAL_CONFIG_ENV_MAP = {
"backend": "TERMINAL_ENV",
"modal_mode": "TERMINAL_MODAL_MODE",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
"ssh_key": "TERMINAL_SSH_KEY",
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
"container_disk": "TERMINAL_CONTAINER_DISK",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"docker_env": "TERMINAL_DOCKER_ENV",
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"docker_network": "TERMINAL_DOCKER_NETWORK",
"docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS",
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
}
def _terminal_env_value(value: Any) -> str:
if isinstance(value, (list, dict)):
return json.dumps(value)
return str(value)
def terminal_config_env_var_for_key(key: str) -> Optional[str]:
"""Return the env var mirrored by a ``terminal.*`` config key."""
prefix = "terminal."
if not key.startswith(prefix):
return None
return TERMINAL_CONFIG_ENV_MAP.get(key[len(prefix):])
def apply_terminal_config_to_env(
*,
env: Optional[Dict[str, str]] = None,
config: Optional[Dict[str, Any]] = None,
override: Optional[bool] = None,
) -> Dict[str, str]:
"""Bridge ``terminal.*`` config into the env vars terminal tools read.
``tools.terminal_tool`` is intentionally environment-driven because it also
runs in child processes (TUI, dashboard PTY, gateway workers). This helper
gives those child-process launch paths the same config bridge as classic
CLI without importing ``cli.py`` and paying for its startup side effects.
When the user config contains a ``terminal`` section, config.yaml is
authoritative and overrides existing env values. Otherwise defaults only
backfill missing env vars so exported/.env values keep working.
"""
target = os.environ if env is None else env
raw_config = read_raw_config()
file_has_terminal_config = isinstance(raw_config.get("terminal"), dict)
should_override = file_has_terminal_config if override is None else override
cfg = config if config is not None else load_config_readonly()
terminal_cfg = cfg.get("terminal", {}) if isinstance(cfg, dict) else {}
if not isinstance(terminal_cfg, dict):
return target
for cfg_key, env_var in TERMINAL_CONFIG_ENV_MAP.items():
if cfg_key not in terminal_cfg:
continue
value = terminal_cfg[cfg_key]
if cfg_key == "cwd":
raw_cwd = str(value or "").strip()
if raw_cwd in {".", "auto", "cwd"}:
continue
if isinstance(value, str):
value = os.path.expanduser(value)
if should_override or env_var not in target:
target[env_var] = _terminal_env_value(value)
return target
def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
with _CONFIG_LOCK:
ensure_hermes_home()
config_path = get_config_path()
path_key = str(config_path)
try:
st = config_path.stat()
user_sig: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size)
except FileNotFoundError:
user_sig = None
# Managed scope: fold the managed config file's (mtime, size) into the
# cache signature so editing /etc/hermes/config.yaml invalidates the
# cached merged result. (0, 0) means "no managed config file".
from hermes_cli import managed_scope
managed_dir = managed_scope.get_managed_dir()
managed_cfg_path = (managed_dir / "config.yaml") if managed_dir else None
try:
mst = managed_cfg_path.stat() if managed_cfg_path else None
managed_sig = (mst.st_mtime_ns, mst.st_size) if mst else (0, 0)
except OSError:
managed_sig = (0, 0)
# Combined cache signature: user file + managed file. None only when the
# user config is absent AND no managed file exists (nothing to cache on).
if user_sig is not None:
cache_sig: Optional[Tuple[int, int, int, int]] = (
user_sig[0],
user_sig[1],
managed_sig[0],
managed_sig[1],
)
elif managed_sig != (0, 0):
cache_sig = (0, 0, managed_sig[0], managed_sig[1])
else:
cache_sig = None
cached = _LOAD_CONFIG_CACHE.get(path_key)
if cached is not None and cache_sig is not None and cached[:4] == cache_sig:
# File signatures match, but the cached expansion is only valid if
# every ${VAR} it was expanded against still has the same value.
# Without this, a load_config() that ran before load_hermes_dotenv()
# pins unexpanded literals (e.g. auxiliary.<task>.api_key) for the
# life of the process (#58514).
env_snapshot = cached[5] if len(cached) > 5 else {}
if all(os.environ.get(k) == v for k, v in env_snapshot.items()):
return copy.deepcopy(cached[4]) if want_deepcopy else cached[4]
config = copy.deepcopy(DEFAULT_CONFIG)
if user_sig is not None:
try:
with open(config_path, encoding="utf-8") as f:
user_config = fast_safe_load(f) or {}
if "max_turns" in user_config:
agent_user_config = dict(user_config.get("agent") or {})
if agent_user_config.get("max_turns") is None:
agent_user_config["max_turns"] = user_config["max_turns"]
user_config["agent"] = agent_user_config
user_config.pop("max_turns", None)
config = _deep_merge(config, user_config)
except Exception as e:
# Last-known-good fallback (port of openai/codex#31188's
# invariant: a parse failure in a policy/config file must not
# silently replace the effective policy with an empty/default
# one). Falling through to DEFAULT_CONFIG here drops EVERY user
# override — including security-critical ``approvals.deny``
# rules, which are supposed to block commands even under yolo.
# A long-running gateway whose user mid-edits config.yaml into
# broken YAML would silently lose those rules on the next load.
# Within a running process we still have the last successfully
# loaded config — keep serving it until the file is fixed.
# Fresh processes with no last-known-good keep the existing
# DEFAULT_CONFIG fallback.
lkg = _LAST_EXPANDED_CONFIG_BY_PATH.get(path_key)
_warn_config_parse_failure(
config_path,
e,
fallback="last-known-good" if lkg is not None else "defaults",
)
if lkg is not None:
# save_config() stores the pre-expansion normalized dict
# (env-ref templates preserved); the load path stores the
# expanded one. Expand defensively — idempotent when the
# stored value is already expanded.
from typing import cast as _cast
lkg_copy: Dict[str, Any] = _cast(
Dict[str, Any], _expand_env_vars(copy.deepcopy(lkg))
)
if cache_sig is not None:
# Cache under the corrupt file's signature (empty env
# snapshot: always valid) so repeated loads don't
# re-parse the broken file; fixing the file changes the
# signature and triggers a normal reload.
_empty_env: Dict[str, Optional[str]] = {}
_LOAD_CONFIG_CACHE[path_key] = (
cache_sig[0], cache_sig[1],
cache_sig[2], cache_sig[3],
lkg_copy, _empty_env,
)
return copy.deepcopy(lkg_copy) if want_deepcopy else lkg_copy
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
expanded = _expand_env_vars(normalized)
# Managed scope wins at the leaf. Applied AFTER user expansion so a user
# ${VAR} cannot shadow a managed literal: managed values are expanded only
# against the process environment, never against user-config-defined refs.
# This deliberately inverts the usual env-over-config precedence for the
# keys the managed layer pins — see docs/design/managed-scope.md §4.1.
managed_config = managed_scope.load_managed_config()
if managed_config:
managed_expanded = _expand_env_vars(managed_config)
expanded = _deep_merge(expanded, managed_expanded)
_LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded)
if cache_sig is not None:
# Cache stores a separate deepcopy so subsequent ``load_config()``
# (deepcopy=True) callers can mutate freely without affecting the
# cached value, and ``load_config_readonly()`` (deepcopy=False)
# callers all see the same stable cached object. The cached tuple is
# (user_mtime, user_size, managed_mtime, managed_size, value,
# env_ref_snapshot). The snapshot records the environment values
# this expansion was made against so later loads can detect env
# drift (late .env load, in-process rotation) — see cache hit above.
cached_copy = copy.deepcopy(expanded)
env_snapshot = _env_ref_snapshot(normalized)
if managed_config:
_env_ref_snapshot(managed_config, env_snapshot)
_LOAD_CONFIG_CACHE[path_key] = (*cache_sig, cached_copy, env_snapshot)
# On the readonly path return the same cached object subsequent
# calls will see — keeps "two readonly calls return the same
# object" invariant that callers may rely on for identity checks.
if not want_deepcopy:
return cached_copy
else:
_LOAD_CONFIG_CACHE.pop(path_key, None)
# First-load result is a fresh dict (not aliased to the cache); safe
# to return directly. For the deepcopy=True path this is the
# canonical "freshly-built mutable result" the function has always
# returned. For the deepcopy=False path with no cache (e.g. config
# file missing), it's also fine — callers get an isolated object.
return expanded
_SECURITY_COMMENT = """
# ── Security ──────────────────────────────────────────────────────────
# Secret redaction is ON by default — strings that look like API keys,
# tokens, and passwords are masked in tool output, logs, and chat
# responses before the model or user ever sees them. Set redact_secrets
# to false to disable (e.g. when developing the redactor itself).
# tirith pre-exec scanning is enabled by default when the tirith binary
# is available. Configure via security.tirith_* keys or env vars
# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
#
# security:
# redact_secrets: true
# tirith_enabled: true
# tirith_path: "tirith"
# tirith_timeout: 5
# tirith_fail_open: true
"""
_FALLBACK_COMMENT = """
# ── Fallback Model ────────────────────────────────────────────────────
# Automatic provider failover when primary is unavailable.
# Uncomment and configure to enable. Triggers on rate limits (429),
# overload (529), service errors (503), or connection failures.
#
# Supported providers:
# openrouter (OPENROUTER_API_KEY) — routes to any model
# openai-codex (OAuth — hermes auth) — OpenAI Codex
# nous (OAuth — hermes auth) — Nous Portal
# zai (ZAI_API_KEY) — Z.AI / GLM
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)
# minimax (MINIMAX_API_KEY) — MiniMax
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
# bedrock (AWS IAM / boto3) — AWS Bedrock (Converse API)
#
# For custom OpenAI-compatible endpoints, add base_url and key_env.
#
# fallback_model:
# provider: openrouter
# model: anthropic/claude-sonnet-4
"""
_COMMENTED_SECTIONS = """
# ── Security ──────────────────────────────────────────────────────────
# Secret redaction is ON by default. Set to false to pass tool output,
# logs, and chat responses through unmodified (e.g. for redactor dev).
#
# security:
# redact_secrets: true
# ── Fallback Model ────────────────────────────────────────────────────
# Automatic provider failover when primary is unavailable.
# Uncomment and configure to enable. Triggers on rate limits (429),
# overload (529), service errors (503), or connection failures.
#
# Supported providers:
# openrouter (OPENROUTER_API_KEY) — routes to any model
# openai-codex (OAuth — hermes auth) — OpenAI Codex
# nous (OAuth — hermes auth) — Nous Portal
# zai (ZAI_API_KEY) — Z.AI / GLM
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)
# minimax (MINIMAX_API_KEY) — MiniMax
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
# bedrock (AWS IAM / boto3) — AWS Bedrock (Converse API)
#
# For custom OpenAI-compatible endpoints, add base_url and key_env.
#
# fallback_model:
# provider: openrouter
# model: anthropic/claude-sonnet-4
"""
def save_config(
config: Dict[str, Any],
*,
strip_defaults: bool = True,
preserve_keys: Optional[Set[Tuple[str, ...]]] = None,
merge_existing: bool = False,
):
"""Save configuration to ~/.hermes/config.yaml.\n
Default values from ``DEFAULT_CONFIG`` are not written to disk unless
the user explicitly set them (i.e. the path exists in the raw config
before any normalisation). This prevents config.yaml from being
contaminated with schema defaults on every save, which makes future
default changes invisible to users.
When ``merge_existing`` is True, the on-disk raw config is deep-merged
under *config* before writing so partial callers (migration steps via
``_persist_migration``) cannot drop unrelated sections the caller omitted.
Full-document replacement callers (dashboard raw YAML editor, callers that
already deep-merge) must leave this False so intentional deletions survive.
"""
with _CONFIG_LOCK:
if is_managed():
managed_error("save configuration")
return
# Managed scope: strip any leaf the managed layer pins, so a bulk write
# (wizard / programmatic save) never persists a user value that would
# silently lose to managed on the next load. Single-key `config set`
# hard-rejects (see set_config_value); this is the mechanical safety net
# for bulk writes so the unmanaged remainder still lands.
from hermes_cli import managed_scope
managed_keys = managed_scope.managed_config_keys()
if managed_keys:
config, _stripped = _strip_dotted_keys(copy.deepcopy(config), managed_keys)
if _stripped:
print(
f"Note: {len(_stripped)} managed setting(s) were not saved "
f"(managed by your administrator): {', '.join(sorted(_stripped))}",
file=sys.stderr,
)
from utils import atomic_yaml_write
ensure_hermes_home()
config_path = get_config_path()
require_readable_config_before_write(config_path)
# Compute explicit user paths BEFORE any normalisation --------
# _normalize_max_turns_config may inject agent.max_turns from
# DEFAULT_CONFIG; using the raw dict preserves which paths the
# user actually set so _strip_default_values can keep them.
_raw_for_paths = read_raw_config()
explicit_raw_paths: Optional[Set[Tuple[str, ...]]] = (
_explicit_config_paths(_raw_for_paths) if _raw_for_paths else None
)
if merge_existing and _raw_for_paths:
config = _merge_partial_save(_raw_for_paths, config)
# ----------------------------------------------------------------
current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
normalized = current_normalized
raw_existing = (
_normalize_root_model_keys(_normalize_max_turns_config(_raw_for_paths))
if _raw_for_paths
else {}
)
if raw_existing:
normalized = _preserve_env_ref_templates(
normalized,
raw_existing,
_LAST_EXPANDED_CONFIG_BY_PATH.get(str(config_path)),
)
# Strip schema-default values so the user's custom settings are not
# silently reset on every save. Keys the user explicitly set (paths
# from the raw pre-normalisation config) are always preserved.
effective_preserve_keys: Set[Tuple[str, ...]] = {("_config_version",)}
if explicit_raw_paths:
effective_preserve_keys.update(explicit_raw_paths)
if preserve_keys:
effective_preserve_keys.update(preserve_keys)
if strip_defaults and effective_preserve_keys:
# _preserve_env_ref_templates may return Any; cast for type-checker.
from typing import cast as _cast
normalized = _cast(Dict[str, Any], normalized)
normalized = _strip_default_values(
normalized, # type: ignore[arg-type]
DEFAULT_CONFIG,
preserve_keys=effective_preserve_keys,
)
# Build optional commented-out sections for features that are off by
# default or only relevant when explicitly configured.
parts = []
sec = normalized.get("security", {})
if not sec or sec.get("redact_secrets") is None:
parts.append(_SECURITY_COMMENT)
fb = normalized.get("fallback_model", {})
fb_is_valid = False
if isinstance(fb, list):
fb_is_valid = any(isinstance(e, dict) and e.get("provider") and e.get("model") for e in fb)
elif isinstance(fb, dict):
fb_is_valid = bool(fb.get("provider") and fb.get("model"))
if not fb_is_valid:
parts.append(_FALLBACK_COMMENT)
atomic_yaml_write(
config_path,
normalized,
extra_content="".join(parts) if parts else None,
)
_secure_file(config_path)
_RAW_CONFIG_CACHE.pop(str(config_path), None)
_LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)
def _parse_env_value(raw_value: str) -> str:
"""Parse the small .env value subset Hermes writes itself."""
value = raw_value.strip()
if len(value) >= 2 and value[0] == value[-1] == '"':
quoted = value[1:-1]
parsed: list[str] = []
i = 0
while i < len(quoted):
ch = quoted[i]
if ch == "\\" and i + 1 < len(quoted):
next_ch = quoted[i + 1]
if next_ch in {'"', "\\"}:
parsed.append(next_ch)
i += 2
continue
parsed.append(ch)
i += 1
return "".join(parsed)
if len(value) >= 2 and value[0] == value[-1] == "'":
return value[1:-1]
return value
def load_env() -> Dict[str, str]:
"""Load environment variables from ~/.hermes/.env.
Sanitizes lines before parsing so that corrupted files (e.g.
concatenated KEY=VALUE pairs on a single line) are handled
gracefully instead of producing mangled values such as duplicated
bot tokens. See #8908.
The parsed dict is memoised keyed on the .env file mtime, because
``get_env_value()`` is called dozens-to-hundreds of times per
interactive menu render (`hermes tools`, `hermes setup`, status
panels). Sanitisation is O(lines × known-keys), so re-parsing the
same file on every call was burning ~300ms of CPU per `hermes tools`
menu paint on top of the OAuth-refresh slowness. The mtime check
invalidates the cache when the user edits .env mid-process.
"""
global _env_cache
env_path = get_env_path()
try:
mtime = env_path.stat().st_mtime
size = env_path.stat().st_size
cache_key = (str(env_path), mtime, size)
except FileNotFoundError:
cache_key = (str(env_path), None, None)
except Exception:
cache_key = None
if cache_key is not None and _env_cache is not None:
cached_key, cached_vars = _env_cache
if cached_key == cache_key:
return dict(cached_vars)
env_vars: Dict[str, str] = {}
if env_path.exists():
# On Windows, open() defaults to the system locale (cp1252) which can
# fail on UTF-8 .env files. Always use explicit UTF-8; tolerate BOM
# via utf-8-sig since users may edit .env in Notepad which adds one.
open_kw = {"encoding": "utf-8-sig", "errors": "replace"}
with open(env_path, **open_kw) as f:
raw_lines = f.readlines()
# Sanitize before parsing: split concatenated lines & drop stale
# placeholders so corrupted .env files don't produce invalid tokens.
lines = _sanitize_env_lines(raw_lines)
for line in lines:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
# Strip the bash-compatible ``export `` prefix so lines like
# ``export API_KEY=...`` parse as ``API_KEY`` rather than being
# stored under the wrong key ``"export API_KEY"`` (#6659).
if line.startswith('export '):
line = line[7:]
key, _, value = line.partition('=')
env_vars[key.strip()] = _parse_env_value(value)
if cache_key is not None:
_env_cache = (cache_key, dict(env_vars))
return env_vars
# Module-level memo for load_env(), keyed on (path, mtime, size).
# Editing .env bumps mtime → next load_env() rebuilds. invalidate_env_cache()
# is the explicit knob for writers that update .env via this module
# (set_env_value, save_env, etc.) without relying on filesystem mtime
# resolution.
_env_cache: Optional[Tuple[Tuple[str, Optional[float], Optional[int]], Dict[str, str]]] = None
def invalidate_env_cache() -> None:
"""Clear the load_env() process-level memo.
Writers that mutate .env (set_env_value, save_env, etc.) call this
to guarantee the next load_env() sees their change even on
filesystems with coarse mtime resolution. Reads invalidate naturally
via the mtime/size check.
"""
global _env_cache
_env_cache = None
_STRUCTURED_VALUE_MARKERS = ("://", "?", "&")
def _looks_like_structured_value(value: str) -> bool:
"""True when ``value`` looks like a URL/query string or holds whitespace.
Such a value is treated as one opaque secret. An embedded
``KNOWN_KEY=`` substring inside it (e.g. a webhook URL carrying a query
parameter, or a proxy base URL with an embedded key) is part of the value,
not the start of a second .env entry, so the concatenation splitter must
not break on it. Plain token secrets (API keys) never contain these.
"""
if any(marker in value for marker in _STRUCTURED_VALUE_MARKERS):
return True
return any(ch.isspace() for ch in value)
def _sanitize_env_lines(lines: list) -> list:
"""Fix corrupted .env lines before reading or writing.
Handles two known corruption patterns:
1. Concatenated KEY=VALUE pairs on a single line (missing newline between
entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).
2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.
Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only
split on real Hermes env var names, avoiding false positives from values
that happen to contain uppercase text with ``=``.
"""
# Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.
# Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.
known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
sanitized: list[str] = []
for line in lines:
raw = line.rstrip("\r\n")
stripped = raw.strip()
# Preserve blank lines and comments
if not stripped or stripped.startswith("#"):
sanitized.append(raw + "\n")
continue
# Detect concatenated KEY=VALUE pairs on one line.
# Search for known KEY= patterns at any position in the line.
# We collect full needle ranges so we can drop matches that are
# fully contained within a longer overlapping needle. Without this,
# suffix collisions corrupt the file: e.g. LM_API_KEY= inside
# GLM_API_KEY= would otherwise split the line into "G\nLM_API_KEY=...".
match_ranges: list[tuple[int, int]] = []
for key_name in known_keys:
needle = key_name + "="
idx = stripped.find(needle)
while idx >= 0:
match_ranges.append((idx, idx + len(needle)))
idx = stripped.find(needle, idx + len(needle))
split_positions = sorted({
s for s, e in match_ranges
if not any(
s2 <= s and e2 >= e and (s2, e2) != (s, e)
for s2, e2 in match_ranges
)
})
# Only treat the line as a concatenation when it actually begins with a
# known KEY= (split_positions[0] == 0). A first match at a non-zero
# offset means the matches sit inside a value, so splitting there would
# silently drop the leading text — keep the line intact instead.
split_into_entries = False
segments: list[str] = []
if len(split_positions) > 1 and split_positions[0] == 0:
segments = [
stripped[pos:(
split_positions[i + 1] if i + 1 < len(split_positions) else len(stripped)
)]
for i, pos in enumerate(split_positions)
]
# A genuine concatenation has a simple token value in every segment
# that precedes a boundary. If a preceding value looks structured
# (a URL/query string or whitespace), the embedded KNOWN_KEY= is
# part of that value rather than a new entry, so we must not split —
# otherwise we truncate the real secret and fabricate a bogus one.
split_into_entries = all(
not _looks_like_structured_value(seg.split("=", 1)[1])
for seg in segments[:-1]
)
if split_into_entries:
for seg in segments:
part = seg.strip()
if part:
sanitized.append(part + "\n")
else:
sanitized.append(stripped + "\n")
return sanitized
def sanitize_env_file() -> int:
"""Read, sanitize, and rewrite ~/.hermes/.env in place.
Returns the number of lines that were fixed (concatenation splits +
placeholder removals). Returns 0 when no changes are needed.
"""
env_path = get_env_path()
if not env_path.exists():
return 0
read_kw = {"encoding": "utf-8-sig", "errors": "replace"}
write_kw = {"encoding": "utf-8"}
with open(env_path, **read_kw) as f:
original_lines = f.readlines()
sanitized = _sanitize_env_lines(original_lines)
if sanitized == original_lines:
return 0
# Count fixes: difference in line count (from splits) + removed lines
fixes = abs(len(sanitized) - len(original_lines))
if fixes == 0:
# Lines changed content (e.g. *** removal) even if count is same
fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)
fixes += abs(len(sanitized) - len(original_lines))
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix=".tmp", prefix=".env_")
try:
with os.fdopen(fd, "w", **write_kw) as f:
f.writelines(sanitized)
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, env_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
_secure_file(env_path)
invalidate_env_cache()
return fixes
def _check_non_ascii_credential(key: str, value: str) -> str:
"""Warn and strip non-ASCII characters from credential values.
API keys and tokens must be pure ASCII — they are sent as HTTP header
values which httpx/httpcore encode as ASCII. Non-ASCII characters
(commonly introduced by copy-pasting from rich-text editors or PDFs
that substitute lookalike Unicode glyphs for ASCII letters) cause
``UnicodeEncodeError: 'ascii' codec can't encode character`` at
request time.
Returns the sanitized (ASCII-only) value. Prints a warning if any
non-ASCII characters were found and removed.
"""
try:
value.encode("ascii")
return value # all ASCII — nothing to do
except UnicodeEncodeError:
pass
# Build a readable list of the offending characters
bad_chars: list[str] = []
for i, ch in enumerate(value):
if ord(ch) > 127:
bad_chars.append(f" position {i}: {ch!r} (U+{ord(ch):04X})")
sanitized = value.encode("ascii", errors="ignore").decode("ascii")
print(
f"\n Warning: {key} contains non-ASCII characters that will break API requests.\n"
f" This usually happens when copy-pasting from a PDF, rich-text editor,\n"
f" or web page that substitutes lookalike Unicode glyphs for ASCII letters.\n"
f"\n"
+ "\n".join(f" {line}" for line in bad_chars[:5])
+ ("\n ... and more" if len(bad_chars) > 5 else "")
+ "\n\n The non-ASCII characters have been stripped automatically.\n"
" If authentication fails, re-copy the key from the provider's dashboard.\n",
file=sys.stderr,
)
return sanitized
def _quote_env_value(value: str) -> str:
"""Quote .env values containing characters with special dotenv meaning."""
if value == "":
return value
# Internal whitespace (space/tab/etc.) must be quoted so shell `set -a; . file`
# word-splits don't break paths like macOS "Application Support". Leading/
# trailing whitespace is already covered by the strip check; any() covers
# internal runs that strip() would leave alone.
needs_quoting = (
"#" in value
or '"' in value
or "'" in value
or value != value.strip()
or any(c.isspace() for c in value)
)
if not needs_quoting:
return value
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def _env_line_defines_key(line: str, key: str) -> bool:
"""True when a .env line assigns ``key`` — plain or ``export``-prefixed.
``load_env()`` accepts the bash-compatible ``export KEY=value`` form
(#6659), so the writers must recognise the same shape. Otherwise a
hand-added ``export`` line is invisible to save (duplicate appended) and
remove (line survives → the value resurrects on the next load, #40041).
"""
stripped = line.strip()
if stripped.startswith("export "):
stripped = stripped[7:].lstrip()
return stripped.startswith(f"{key}=")
def save_env_value(key: str, value: str):
"""Save or update a value in ~/.hermes/.env."""
if is_managed():
managed_error(f"set {key}")
return
# Managed scope guard: a managed env key can't be set by the user — the
# managed .env wins at load anyway. Distinct from is_managed() above.
from hermes_cli import managed_scope
if managed_scope.is_env_managed(key):
managed_dir = managed_scope.get_managed_dir()
src = (managed_dir / ".env") if managed_dir else "the managed scope"
print(
f"Cannot set {key}: it is managed by your administrator ({src}) "
f"and cannot be changed.",
file=sys.stderr,
)
return
if not _ENV_VAR_NAME_RE.match(key):
raise ValueError(f"Invalid environment variable name: {key!r}")
_reject_denylisted_env_var(key)
value = value.replace("\n", "").replace("\r", "")
# API keys / tokens must be ASCII — strip non-ASCII with a warning.
value = _check_non_ascii_credential(key, value)
ensure_hermes_home()
env_path = get_env_path()
# On Windows, open() defaults to the system locale (cp1252) which can
# cause OSError errno 22 on UTF-8 .env files.
read_kw = {"encoding": "utf-8-sig", "errors": "replace"}
write_kw = {"encoding": "utf-8"}
lines = []
if env_path.exists():
with open(env_path, **read_kw) as f:
lines = f.readlines()
# Sanitize on every read: split concatenated keys, drop stale placeholders
lines = _sanitize_env_lines(lines)
serialized_value = _quote_env_value(value)
# Find and update or append. Match both ``KEY=`` and the bash-compatible
# ``export KEY=`` form — load_env() parses export lines (#6659), so a
# user-added ``export GITHUB_TOKEN=...`` shows as set in every UI. If the
# writer didn't match it, a save would append a SECOND line and a later
# delete of that line would silently resurrect the old exported value
# (#40041: "token detected but cannot be replaced through the UI").
found = False
for i, line in enumerate(lines):
if _env_line_defines_key(line, key):
lines[i] = f"{key}={serialized_value}\n"
found = True
break
if not found:
# Ensure there's a newline at the end of the file before appending
if lines and not lines[-1].endswith("\n"):
lines[-1] += "\n"
lines.append(f"{key}={serialized_value}\n")
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')
# Preserve original permissions so Docker volume mounts aren't clobbered.
original_mode = None
if env_path.exists():
try:
original_mode = stat.S_IMODE(env_path.stat().st_mode)
except OSError:
pass
try:
with os.fdopen(fd, 'w', **write_kw) as f:
f.writelines(lines)
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, env_path)
# Preserve the original file mode (e.g. 0640 for Docker volume mounts)
# instead of letting _secure_file unconditionally tighten to 0600.
if original_mode is not None:
try:
os.chmod(env_path, original_mode)
except OSError:
pass
else:
_secure_file(env_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
os.environ[key] = value
invalidate_env_cache()
def remove_env_value(key: str) -> bool:
"""Remove a key from ~/.hermes/.env and os.environ.
Returns True if the key was found and removed, False otherwise.
"""
if is_managed():
managed_error(f"remove {key}")
return False
# Managed scope guard: a managed env key can't be removed by the user.
from hermes_cli import managed_scope
if managed_scope.is_env_managed(key):
managed_dir = managed_scope.get_managed_dir()
src = (managed_dir / ".env") if managed_dir else "the managed scope"
print(
f"Cannot remove {key}: it is managed by your administrator ({src}) "
f"and cannot be changed.",
file=sys.stderr,
)
return False
if not _ENV_VAR_NAME_RE.match(key):
raise ValueError(f"Invalid environment variable name: {key!r}")
env_path = get_env_path()
if not env_path.exists():
os.environ.pop(key, None)
return False
read_kw = {"encoding": "utf-8-sig", "errors": "replace"}
write_kw = {"encoding": "utf-8"}
with open(env_path, **read_kw) as f:
lines = f.readlines()
lines = _sanitize_env_lines(lines)
new_lines = [line for line in lines if not _env_line_defines_key(line, key)]
found = len(new_lines) < len(lines)
if found:
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')
# Preserve original permissions so Docker volume mounts aren't clobbered.
original_mode = None
try:
original_mode = stat.S_IMODE(env_path.stat().st_mode)
except OSError:
pass
try:
with os.fdopen(fd, 'w', **write_kw) as f:
f.writelines(new_lines)
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, env_path)
# Preserve the original file mode (e.g. 0640 for Docker volume
# mounts) instead of letting _secure_file unconditionally tighten
# to 0600. Mirrors save_env_value().
if original_mode is not None:
try:
os.chmod(env_path, original_mode)
except OSError:
pass
else:
_secure_file(env_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
os.environ.pop(key, None)
invalidate_env_cache()
return found
def save_anthropic_oauth_token(value: str, save_fn=None):
"""Persist an Anthropic OAuth/setup token and clear the API-key slot."""
writer = save_fn or save_env_value
writer("ANTHROPIC_TOKEN", value)
writer("ANTHROPIC_API_KEY", "")
def use_anthropic_claude_code_credentials(save_fn=None):
"""Use Claude Code's own credential files instead of persisting env tokens."""
writer = save_fn or save_env_value
writer("ANTHROPIC_TOKEN", "")
writer("ANTHROPIC_API_KEY", "")
def save_anthropic_api_key(value: str, save_fn=None):
"""Persist an Anthropic API key and clear the OAuth/setup-token slot."""
writer = save_fn or save_env_value
writer("ANTHROPIC_API_KEY", value)
writer("ANTHROPIC_TOKEN", "")
def save_env_value_secure(key: str, value: str) -> Dict[str, Any]:
# Route through the unified credential lifecycle so a rotation via the
# secret-capture path also refreshes any config.yaml mirror of the old
# value and lifts a prior env-source suppression (#62269 fix family).
from hermes_cli.credential_lifecycle import save_provider_env_credential
save_provider_env_credential(key, value)
return {
"success": True,
"stored_as": key,
"validated": False,
}
def reload_env() -> int:
"""Re-read ~/.hermes/.env into os.environ. Returns count of vars updated.
Adds/updates vars that changed and removes vars that were deleted from
the .env file (but only vars known to Hermes — OPTIONAL_ENV_VARS and
_EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).
"""
env_vars = load_env()
known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
count = 0
for key, value in env_vars.items():
if os.environ.get(key) != value:
os.environ[key] = value
count += 1
# Remove known Hermes vars that are no longer in .env
for key in known_keys:
if key not in env_vars and key in os.environ:
del os.environ[key]
count += 1
return count
def get_env_value(key: str) -> Optional[str]:
"""Get a value from ~/.hermes/.env or environment."""
# Check environment first
if key in os.environ:
return os.environ[key]
# Then check .env file
env_vars = load_env()
return env_vars.get(key)
def get_env_value_prefer_dotenv(key: str) -> Optional[str]:
"""Resolve a credential env value, preferring ``~/.hermes/.env`` over ``os.environ``.
Used for Hermes-managed credentials where a deliberate edit to ``.env``
must take precedence over a stale value inherited from the parent shell
(Codex CLI, test scripts, login profile exports). Without this, rotating
a key in ``.env`` mid-session leaves callers serving the stale shell
value and produces persistent 401s.
The ``os.environ`` fallback routes through ``secret_scope.get_secret`` so
that, under an active profile scope (multiplexed gateway turn), this read
is scope-checked rather than leaking another profile's raw ``os.environ``
value — matching the credential-pool seeding path's behaviour.
"""
env_vars = load_env()
val = env_vars.get(key)
if val:
return val
try:
from agent.secret_scope import (
UnscopedSecretError,
get_secret as _get_secret,
)
except Exception:
return os.environ.get(key)
try:
return _get_secret(key)
except UnscopedSecretError:
raise
except Exception:
return os.environ.get(key)
# =============================================================================
# Config display
# =============================================================================
def redact_key(key: str) -> str:
"""Redact an API key for display.
Thin wrapper over :func:`agent.redact.mask_secret` — preserves the
"(not set)" placeholder in dim color for the empty case.
"""
from agent.redact import mask_secret
return mask_secret(key, empty=color("(not set)", Colors.DIM))
# Key names (case-insensitive, exact match) whose VALUE is a credential and
# must be masked before printing any config dict to the terminal. Covers the
# fields a custom provider stuffs into the `model`/`custom_providers` blocks
# (`api_key`) plus the usual token/secret/password shapes. Exact-match only so
# benign keys like `token_count` or `secret_santa` don't get masked.
_SECRET_CONFIG_KEYS = frozenset({
"api_key",
"apikey",
"key",
"token",
"access_token",
"refresh_token",
"id_token",
"secret",
"client_secret",
"password",
"passwd",
"auth",
"authorization",
"private_key",
"bearer",
"jwt",
})
def redact_config_value(value: Any, _depth: int = 0) -> Any:
"""Return a copy of ``value`` with credential-shaped keys masked for display.
Recursively walks dicts/lists and replaces the value of any key in
``_SECRET_CONFIG_KEYS`` (case-insensitive) with a masked form via
:func:`agent.redact.mask_secret`. Non-secret keys and scalar values pass
through unchanged. Use this before ``print``-ing any config sub-tree that
might carry a custom-provider ``api_key`` — ``print`` bypasses the logging
redactor, and opaque tokens (e.g. Cloudflare ``cfut_...``) don't match the
vendor-prefix regexes either, so structural key-name masking is required.
"""
from agent.redact import mask_secret
# Defensive bound on recursion depth for pathological/cyclic configs.
if _depth > 20:
return value
if isinstance(value, dict):
out = {}
for k, v in value.items():
if isinstance(k, str) and k.lower() in _SECRET_CONFIG_KEYS and isinstance(v, str) and v:
out[k] = mask_secret(v)
else:
out[k] = redact_config_value(v, _depth + 1)
return out
if isinstance(value, list):
return [redact_config_value(v, _depth + 1) for v in value]
return value
def show_config():
"""Display current configuration."""
config = load_config()
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
print(color("│ ⚕ Hermes Configuration │", Colors.CYAN))
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
# Managed scope: surface that some settings are administrator-pinned so the
# user understands why their config.yaml value may not be the effective one.
from hermes_cli import managed_scope
_managed_keys = managed_scope.managed_config_keys()
_managed_env = managed_scope.load_managed_env()
if _managed_keys or _managed_env:
_managed_dir = managed_scope.get_managed_dir()
print()
print(color(
f" ⚷ Some settings are managed by your administrator ({_managed_dir}) "
f"and cannot be changed",
Colors.YELLOW,
Colors.BOLD,
))
if _managed_keys:
print(color(
f" Managed config keys: {', '.join(sorted(_managed_keys))}",
Colors.YELLOW,
))
if _managed_env:
print(color(
f" Managed env keys: {', '.join(sorted(_managed_env))}",
Colors.YELLOW,
))
# Paths
print()
print(color("◆ Paths", Colors.CYAN, Colors.BOLD))
print(f" Config: {get_config_path()}")
print(f" Secrets: {get_env_path()}")
print(f" Install: {get_project_root()}")
# API Keys
print()
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
keys = [
("OPENROUTER_API_KEY", "OpenRouter"),
("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"),
("EXA_API_KEY", "Exa"),
("PARALLEL_API_KEY", "Parallel"),
("FIRECRAWL_API_KEY", "Firecrawl"),
("TAVILY_API_KEY", "Tavily"),
("BROWSERBASE_API_KEY", "Browserbase"),
("BROWSER_USE_API_KEY", "Browser Use"),
("FAL_KEY", "FAL"),
]
for env_key, name in keys:
value = get_env_value(env_key)
print(f" {name:<14} {redact_key(value)}")
from hermes_cli.auth import get_anthropic_key
anthropic_value = get_anthropic_key()
print(f" {'Anthropic':<14} {redact_key(anthropic_value)}")
# Model settings
print()
print(color("◆ Model", Colors.CYAN, Colors.BOLD))
print(f" Model: {redact_config_value(config.get('model', 'not set'))}")
_cfg_max_turns = config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])
print(f" Max turns: {_cfg_max_turns}")
# Warn on stale HERMES_MAX_ITERATIONS ghost in .env that disagrees with
# config.yaml (issue #17534). Read the .env FILE directly so we catch the
# ghost even when the gateway bridge already overrode os.environ.
try:
_env_ghost = load_env().get("HERMES_MAX_ITERATIONS")
if _env_ghost is not None and str(_env_ghost).strip() != str(_cfg_max_turns).strip():
print(color(
f" ⚠ .env has stale HERMES_MAX_ITERATIONS={_env_ghost} "
f"(run 'hermes doctor --fix' to remove)",
Colors.YELLOW,
))
except Exception:
pass
# Display
print()
print(color("◆ Display", Colors.CYAN, Colors.BOLD))
display = config.get('display', {})
print(f" Personality: {display.get('personality') or 'none'}")
print(f" Reasoning: {'on' if display.get('show_reasoning', True) else 'off'}")
print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}")
ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {}
ump_first = ump.get('first_lines', 2)
ump_last = ump.get('last_lines', 2)
print(f" User preview: first {ump_first} line(s), last {ump_last} line(s)")
# Terminal
print()
print(color("◆ Terminal", Colors.CYAN, Colors.BOLD))
terminal = config.get('terminal', {})
print(f" Backend: {terminal.get('backend', 'local')}")
print(f" Working dir: {terminal.get('cwd', '.')}")
print(f" Timeout: {terminal.get('timeout', 60)}s")
if terminal.get('backend') == 'docker':
print(f" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
elif terminal.get('backend') == 'singularity':
print(f" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}")
elif terminal.get('backend') == 'modal':
print(f" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
modal_token = get_env_value('MODAL_TOKEN_ID')
print(f" Modal token: {'configured' if modal_token else '(not set)'}")
elif terminal.get('backend') == 'daytona':
print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
daytona_key = get_env_value('DAYTONA_API_KEY')
print(f" API key: {'configured' if daytona_key else '(not set)'}")
elif terminal.get('backend') == 'ssh':
ssh_host = get_env_value('TERMINAL_SSH_HOST')
ssh_user = get_env_value('TERMINAL_SSH_USER')
print(f" SSH host: {ssh_host or '(not set)'}")
print(f" SSH user: {ssh_user or '(not set)'}")
# Timezone
print()
print(color("◆ Timezone", Colors.CYAN, Colors.BOLD))
tz = config.get('timezone', '')
if tz:
print(f" Timezone: {tz}")
else:
print(f" Timezone: {color('(server-local)', Colors.DIM)}")
# Compression
print()
print(color("◆ Context Compression", Colors.CYAN, Colors.BOLD))
compression = config.get('compression', {})
enabled = compression.get('enabled', True)
print(f" Enabled: {'yes' if enabled else 'no'}")
if enabled:
print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%")
print(f" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved")
print(f" Protect last: {compression.get('protect_last_n', 20)} messages")
print(f" Protect first: {compression.get('protect_first_n', 3)} non-system head messages")
_aux_comp = config.get('auxiliary', {}).get('compression', {})
_sm = _aux_comp.get('model', '') or '(auto)'
print(f" Model: {_sm}")
comp_provider = _aux_comp.get('provider', 'auto')
if comp_provider and comp_provider != 'auto':
print(f" Provider: {comp_provider}")
# Auxiliary models
auxiliary = config.get('auxiliary', {})
aux_tasks = {
"Vision": auxiliary.get('vision', {}),
"Web extract": auxiliary.get('web_extract', {}),
}
has_overrides = any(
t.get('provider', 'auto') != 'auto' or t.get('model', '')
for t in aux_tasks.values()
)
if has_overrides:
print()
print(color("◆ Auxiliary Models (overrides)", Colors.CYAN, Colors.BOLD))
for label, task_cfg in aux_tasks.items():
prov = task_cfg.get('provider', 'auto')
mdl = task_cfg.get('model', '')
if prov != 'auto' or mdl:
parts = [f"provider={prov}"]
if mdl:
parts.append(f"model={mdl}")
print(f" {label:12s} {', '.join(parts)}")
# Messaging
print()
print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
telegram_token = get_env_value('TELEGRAM_BOT_TOKEN')
discord_token = get_env_value('DISCORD_BOT_TOKEN')
print(f" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}")
print(f" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}")
# Skill config
try:
from agent.skill_utils import discover_all_skill_config_vars, resolve_skill_config_values
skill_vars = discover_all_skill_config_vars()
if skill_vars:
resolved = resolve_skill_config_values(skill_vars)
print()
print(color("◆ Skill Settings", Colors.CYAN, Colors.BOLD))
for var in skill_vars:
key = var["key"]
value = resolved.get(key, "")
skill_name = var.get("skill", "")
display_val = str(value) if value else color("(not set)", Colors.DIM)
print(f" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}")
except Exception:
pass
print()
print(color("" * 60, Colors.DIM))
print(color(" hermes config edit # Edit config file", Colors.DIM))
print(color(" hermes config set <key> <value>", Colors.DIM))
print(color(" hermes setup # Run setup wizard", Colors.DIM))
print()
def edit_config():
"""Open config file in user's editor."""
if is_managed():
managed_error("edit configuration")
return
config_path = get_config_path()
# Ensure config exists
if not config_path.exists():
save_config(DEFAULT_CONFIG, strip_defaults=False)
print(f"Created {config_path}")
# Find editor
editor = os.getenv('EDITOR') or os.getenv('VISUAL')
if not editor:
# Try common editors — order is platform-aware so Windows users
# land on a working editor (notepad) even without Git Bash or nano
# installed. On POSIX, prefer nano/vim over code/notepad because
# it's more likely to be present on headless / server systems.
import shutil
import sys as _sys
if _sys.platform == "win32":
candidates = ['notepad', 'code', 'vim', 'vi', 'nano']
else:
candidates = ['nano', 'vim', 'vi', 'code', 'notepad']
for cmd in candidates:
if shutil.which(cmd):
editor = cmd
break
if not editor:
print("No editor found. Config file is at:")
print(f" {config_path}")
return
print(f"Opening {config_path} in {editor}...")
subprocess.run([editor, str(config_path)])
def _default_value_for_key(dotted_key: str):
"""Return the leaf value declared for *dotted_key* in ``DEFAULT_CONFIG``.
Unknown keys and non-leaf paths return ``None`` so they retain the legacy
best-effort coercion used by ``config set``.
"""
node = DEFAULT_CONFIG
for part in dotted_key.split("."):
if not isinstance(node, dict) or part not in node:
return None
node = node[part]
return node if not isinstance(node, dict) else None
# Known top-level config keys that intentionally accept arbitrary user-supplied
# child keys ("dictionary-shaped" config: the schema declares the dict but the
# user populates its keys). Schema validation accepts ANY path below these
# without deep checking, so users can set e.g. ``mcp_servers.my-server.command``
# or ``providers.openrouter.api_key`` without us needing to know server names.
_OPEN_DICT_TOP_LEVEL_KEYS = frozenset({
"providers",
"credential_pool_strategies",
"mcp_servers",
"hooks",
"quick_commands",
"personalities",
"command_allowlist",
"model_catalog",
"channel_prompts",
"server_actions",
"secrets",
"goals",
})
# Top-level keys whose sub-keys are partially schema-defined (e.g. on a
# PlatformConfig dataclass) but where users may legitimately add fields
# that DEFAULT_CONFIG doesn't enumerate (extras, per-channel overrides,
# etc.). For these we validate the FIRST segment but accept anything below.
_SCHEMA_DEFINED_DICT_KEYS = frozenset({
# Platform configs — PlatformConfig dataclass + dynamic extras
"discord", "telegram", "slack", "whatsapp", "signal", "mattermost",
"matrix", "feishu", "wecom", "weixin", "bluebubbles", "qqbot", "yuanbao",
"email", "sms", "dingtalk",
# MCP server template / dynamic auth dicts
"sessions", "checkpoints",
})
# Top-level keys that can be ANY user-supplied name (platform/provider dict
# shapes where the outer key IS user-defined).
_DYNAMIC_TOP_LEVEL_KEYS = frozenset({
"custom_providers", # list-shaped, but indexed by position
})
# Container keys whose immediate child IS a user-supplied platform name
# (``platforms.<name>.<field>``). These appear both at the top level and
# nested under ``gateway`` — current docs configure platforms under
# ``gateway.platforms.<name>`` (website/docs/developer-guide/
# adding-platform-adapters.md) and ``gateway/config.py`` also resolves a
# top-level ``platforms`` map. Anything below the platform-name segment is
# accepted because ``PlatformConfig`` carries an open ``extra`` mapping.
_PLATFORM_CONTAINER_KEYS = frozenset({"platforms"})
def _known_top_level_keys() -> set[str]:
"""Return the union of known top-level config keys for validation.
Combines :data:`DEFAULT_CONFIG` with the dynamic categories that
accept user-supplied child keys. Used by :func:`_validate_config_key`
to decide whether a ``hermes config set`` invocation is targeting a
known shape.
"""
keys = set(DEFAULT_CONFIG.keys())
keys.update(_OPEN_DICT_TOP_LEVEL_KEYS)
keys.update(_DYNAMIC_TOP_LEVEL_KEYS)
keys.update(_SCHEMA_DEFINED_DICT_KEYS)
return keys
def _suggest_closest_key(key: str, candidates: set[str], cutoff: float = 0.6) -> Optional[str]:
"""Return the closest valid key name from ``candidates`` if any are
similar enough to ``key``, else None. Used by ``hermes config set``
to point users at the right path when they've typo'd a top-level key.
Uses :func:`difflib.get_close_matches` with a conservative cutoff so
we only suggest when there's a strong match — we'd rather say nothing
than mislead a user toward a wrong-but-similar key.
"""
import difflib
matches = difflib.get_close_matches(key, sorted(candidates), n=1, cutoff=cutoff)
return matches[0] if matches else None
def _validate_config_key(key: str) -> tuple[bool, Optional[str]]:
"""Validate a dotted config-key path against the known schema.
Returns ``(is_known, suggested_alternative_or_None)``. Known keys
return ``(True, None)``. Unknown keys return ``(False, <suggestion>)``
where ``<suggestion>`` may be ``None`` if no close match was found.
Validates as deep as DEFAULT_CONFIG can be safely walked, then stops
at any segment that hits an open-dict container (mcp_servers,
providers, hooks, etc.) where users define the inner keys themselves.
Headline case from #34067: ``gateway.discord.gateway_restart_notification``
was silently written, even though ``gateway`` only has 4 known sub-keys
(``strict``, ``media_delivery_allow_dirs``, ``trust_recent_files``,
``trust_recent_files_seconds``). The correct path is
``discord.gateway_restart_notification`` (platform configs live at the
top level, not under a ``platforms`` namespace).
"""
if not key:
return False, None
segments = key.split(".")
top = segments[0]
# ── Underscore-prefixed keys are internal/test markers ───────────
# A leading underscore on the top-level segment (e.g. ``_test.shim_marker``)
# signals an intentionally non-schema, internal key. Test harnesses and
# tooling use these to write a deterministic marker into config.yaml
# without polluting the user-facing schema (see the Docker privilege-drop
# shim test, which writes ``_test.shim_marker`` to probe file ownership).
# Python's own convention treats a leading underscore as "private"; we
# honour that here so schema validation never blocks deliberately-internal
# keys. This is narrow: only the FIRST segment is checked, so a real typo
# like ``agent._max_turns`` still gets caught at the sub-key level.
if top.startswith("_"):
return True, None
known = _known_top_level_keys()
# ── First-segment validation ─────────────────────────────────────
# Top-level ``platforms.<name>.<field>`` is a valid current shape:
# ``gateway/config.py`` resolves a top-level ``platforms`` map in
# addition to ``gateway.platforms``. Accept anything below it.
if top in _PLATFORM_CONTAINER_KEYS:
return True, None
if top not in known:
suggestion = _suggest_closest_key(top, known)
if suggestion is not None:
rest = ".".join(segments[1:])
suggested_full = f"{suggestion}.{rest}" if rest else suggestion
return False, suggested_full
return False, None
# ── Deeper validation ────────────────────────────────────────────
# Walk DEFAULT_CONFIG along the user's segments. Stop at:
# - An open-dict container (user-defined inner keys are OK below it)
# - A schema-defined-but-extensible dict (accept anything below)
# - A leaf scalar (the user's key is fully consumed and valid)
# - An unknown sub-key (return False with a same-level suggestion)
if top in _OPEN_DICT_TOP_LEVEL_KEYS or top in _DYNAMIC_TOP_LEVEL_KEYS or top in _SCHEMA_DEFINED_DICT_KEYS:
# Any path below these is accepted — the user defines the inner
# shape themselves (mcp_servers.<name>.command, discord.<extras>,
# providers.<name>.api_key, etc.).
return True, None
node: Any = DEFAULT_CONFIG.get(top)
consumed = [top]
for seg in segments[1:]:
# ``gateway.platforms.<name>.<field>`` (and any other nested
# ``platforms`` container) — the segment after ``platforms`` is a
# user-supplied platform name, so accept everything below it.
if seg in _PLATFORM_CONTAINER_KEYS:
return True, None
if not isinstance(node, dict):
# We hit a scalar leaf before consuming the user's full path —
# they're trying to set ``foo.bar`` where ``foo`` is a string.
# Accept it (set_config_value's coercion will replace the
# leaf with a dict, matching pre-existing behavior).
return True, None
if seg not in node:
# Suggest the closest sibling at this depth.
sibling_suggestion = _suggest_closest_key(seg, set(node.keys()))
if sibling_suggestion is not None:
fixed_path = ".".join(consumed + [sibling_suggestion])
return False, fixed_path
return False, None
consumed.append(seg)
node = node[seg]
# Walked the entire user-supplied path without hitting an unknown
# segment — it's known.
return True, None
def set_config_value(key: str, value: str, force: bool = False):
"""Set a configuration value.
Args:
key: Dotted config path (e.g. ``terminal.backend``).
value: String value (auto-coerced to bool/int/float when matching).
force: When True, skip the unknown-key warning — useful for scripted
writes of keys the running version doesn't recognize yet. The CLI
exposes this via ``hermes config set --force``.
"""
if is_managed():
managed_error("set configuration values")
return
# Managed scope guard (D2): a key pinned by the managed layer cannot be set by
# the user — the next load would override it anyway. Hard-reject and name the
# source. Distinct from is_managed() above (the package-manager write-lock).
# Env-shaped keys (API keys / tokens) route to save_env_value below, which has
# its own managed-env-key guard; this catches the config.yaml keys.
from hermes_cli import managed_scope
if managed_scope.is_key_managed(key):
managed_dir = managed_scope.get_managed_dir()
src = (managed_dir / "config.yaml") if managed_dir else "the managed scope"
print(
f"Cannot set '{key}': it is managed by your administrator ({src}) "
f"and cannot be changed. Contact your administrator to modify it.",
file=sys.stderr,
)
sys.exit(1)
# Check if it's an API key (goes to .env)
if _is_env_config_key(key):
# Unified lifecycle: also rotates any config.yaml mirror of the old
# value so a stale higher-precedence copy can't win (#62269).
from hermes_cli.credential_lifecycle import save_provider_env_credential
save_provider_env_credential(key.upper(), value)
print(f"✓ Set {key} in {get_env_path()}")
return
# Unknown-key notice (#34067): the key is still written (arbitrary keys
# are supported — top-level scalars are bridged into os.environ for
# skills and external apps), but a plausible-but-wrong dotted path like
# ``gateway.discord.gateway_restart_notification`` previously reported
# bare success and left the user debugging behavior that never changed.
# Warn after the write so the user gets immediate feedback plus a
# "did you mean" hint, without blocking legitimate unknown keys.
is_known, suggestion = _validate_config_key(key)
# Otherwise it goes to config.yaml
# Read the raw user config (not merged with defaults) to avoid
# dumping all default values back to the file
config_path = get_config_path()
require_readable_config_before_write(config_path)
user_config = {}
if config_path.exists():
try:
with open(config_path, encoding="utf-8") as f:
user_config = fast_safe_load(f) or {}
except Exception:
user_config = {}
# Handle nested keys (e.g., "tts.provider") including numeric list
# indices (e.g., "custom_providers.0.api_key"). Delegates to
# _set_nested which preserves list-typed nodes; before #17876 the
# inline navigation here silently overwrote lists with dicts.
# Preserve values for string-typed settings. In particular, enum members
# such as approvals.mode="off" must not become YAML booleans. Unknown keys
# retain the historical best-effort coercion behavior.
coerced_value: Any = value
if not isinstance(_default_value_for_key(key), str):
if value.lower() in {'true', 'yes', 'on'}:
coerced_value = True
elif value.lower() in {'false', 'no', 'off'}:
coerced_value = False
elif value.isdigit():
coerced_value = int(value)
elif value.replace('.', '', 1).isdigit():
coerced_value = float(value)
value = coerced_value
_set_nested(user_config, key, value)
# Normalize the api_base → base_url alias at set-time too (issue #8919),
# so a fresh `hermes config set model.api_base ...` lands on the canonical
# key the runtime resolver actually reads, instead of being silently
# ignored. Mirrors the load-time migration in _normalize_root_model_keys.
_alias_norm = key.strip().lower()
if _alias_norm in ("model.api_base", "api_base"):
user_config = _normalize_root_model_keys(user_config)
key = "model.base_url"
print(" (note: 'api_base' is an alias — saved as model.base_url)")
# Write only user config back (not the full merged defaults)
ensure_hermes_home()
from utils import atomic_yaml_write
atomic_yaml_write(config_path, user_config, sort_keys=False)
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
env_var = terminal_config_env_var_for_key(key)
if env_var and key != "terminal.cwd":
save_env_value(env_var, _terminal_env_value(value))
# Mask the echoed value when the (possibly nested) key is credential-shaped
# — e.g. `hermes config set model.api_key cfut_...` routes to config.yaml
# (lowercase, so it misses the .env api_keys list above) and would otherwise
# print the raw secret to the terminal.
_leaf_key = key.rsplit(".", 1)[-1].lower()
if _leaf_key in _SECRET_CONFIG_KEYS and isinstance(value, str) and value:
from agent.redact import mask_secret
_display_value = mask_secret(value)
else:
_display_value = value
print(f"✓ Set {key} = {_display_value} in {config_path}")
# Post-write unknown-key notice (#34067): value IS saved, but tell the
# user the runtime may never read it and suggest the likely-intended path.
if not is_known and not force:
print(color(
f"'{key}' is not a recognized config key — it was saved anyway, "
"but Hermes may not read it.",
Colors.YELLOW,
))
if suggestion:
print(color(f" Did you mean: {suggestion}", Colors.YELLOW))
print(color(
" (Custom top-level keys are supported and bridged to the "
"environment for skills/external tools. Use --force to skip "
"this notice.)",
Colors.DIM,
))
def get_config_value(key: str, *, as_json: bool = False):
"""Print a resolved configuration value."""
if _is_env_config_key(key):
env_value = get_env_value(key.upper())
value = _MISSING if env_value is None else env_value
else:
value = _get_nested(load_config(), key)
if value is _MISSING:
print(f"Config key not set: {key}", file=sys.stderr)
sys.exit(1)
print(_format_config_get_value(value, as_json=as_json))
def unset_config_value(key: str):
"""Remove a user-set configuration or .env value."""
if is_managed():
managed_error("unset configuration values")
return
# Managed scope guard: a key pinned by the managed layer cannot be unset by
# the user — the next load would reinstate it anyway (mirrors set_config_value).
from hermes_cli import managed_scope
if managed_scope.is_key_managed(key):
managed_dir = managed_scope.get_managed_dir()
src = (managed_dir / "config.yaml") if managed_dir else "the managed scope"
print(
f"Cannot unset '{key}': it is managed by your administrator ({src}) "
f"and cannot be changed. Contact your administrator to modify it.",
file=sys.stderr,
)
sys.exit(1)
if _is_env_config_key(key):
# Unified lifecycle: prune env-seeded credential_pool entries and
# model-cache rows too, so `hermes config unset <KEY>` fully removes
# the provider instead of leaving it resurrectable (#51071 family).
from hermes_cli.credential_lifecycle import remove_provider_env_credential
if not remove_provider_env_credential(key.upper()).get("found"):
print(f"Config key not set: {key}", file=sys.stderr)
sys.exit(1)
print(f"✓ Unset {key} from {get_env_path()}")
return
config_path = get_config_path()
require_readable_config_before_write(config_path)
user_config = {}
if config_path.exists():
try:
with open(config_path, encoding="utf-8") as f:
user_config = fast_safe_load(f) or {}
except Exception:
user_config = {}
removed = _unset_nested(user_config, key)
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
env_var = terminal_config_env_var_for_key(key)
if env_var and key != "terminal.cwd":
removed = remove_env_value(env_var) or removed
if not removed:
print(f"Config key not set: {key}", file=sys.stderr)
sys.exit(1)
ensure_hermes_home()
from utils import atomic_yaml_write
atomic_yaml_write(config_path, user_config, sort_keys=False)
print(f"✓ Unset {key} from {config_path}")
# =============================================================================
# Command handler
# =============================================================================
def config_command(args):
"""Handle config subcommands."""
subcmd = getattr(args, 'config_command', None)
if subcmd is None or subcmd == "show":
show_config()
elif subcmd == "edit":
edit_config()
elif subcmd == "get":
key = getattr(args, 'key', None)
if not key:
print("Usage: hermes config get <key> [--json]")
print()
print("Examples:")
print(" hermes config get model")
print(" hermes config get terminal.backend")
print(" hermes config get skills.config --json")
sys.exit(1)
get_config_value(key, as_json=getattr(args, 'json', False))
elif subcmd == "set":
key = getattr(args, 'key', None)
value = getattr(args, 'value', None)
force = bool(getattr(args, 'force', False))
if not key or value is None:
print("Usage: hermes config set [--force] <key> <value>")
print()
print("Examples:")
print(" hermes config set model anthropic/claude-sonnet-4")
print(" hermes config set terminal.backend docker")
print(" hermes config set OPENROUTER_API_KEY sk-or-...")
print()
print(" --force: skip the unknown-key notice for unrecognized keys")
sys.exit(1)
set_config_value(key, value, force=force)
elif subcmd == "unset":
key = getattr(args, 'key', None)
if not key:
print("Usage: hermes config unset <key>")
print()
print("Examples:")
print(" hermes config unset model")
print(" hermes config unset terminal.backend")
print(" hermes config unset OPENROUTER_API_KEY")
sys.exit(1)
unset_config_value(key)
elif subcmd == "path":
print(get_config_path())
elif subcmd == "env-path":
print(get_env_path())
elif subcmd == "migrate":
print()
print(color("🔄 Checking configuration for updates...", Colors.CYAN, Colors.BOLD))
print()
# Check what's missing
missing_env = get_missing_env_vars(required_only=False)
missing_config = get_missing_config_fields()
current_ver, latest_ver = check_config_version()
if not missing_env and not missing_config and current_ver >= latest_ver:
print(color("✓ Configuration is up to date!", Colors.GREEN))
print()
return
# Show what needs to be updated
if current_ver < latest_ver:
print(f" Config version: {current_ver}{latest_ver}")
if missing_config:
print(f"\n {len(missing_config)} new config option(s) will be added with defaults")
required_missing = [v for v in missing_env if v.get("is_required")]
optional_missing = [
v for v in missing_env
if not v.get("is_required") and not v.get("advanced")
]
if required_missing:
print(f"\n ⚠️ {len(required_missing)} required API key(s) missing:")
for var in required_missing:
print(f"{var['name']}")
if optional_missing:
print(f"\n {len(optional_missing)} optional API key(s) not configured:")
for var in optional_missing:
tools = var.get("tools", [])
tools_str = f" (enables: {', '.join(tools[:2])})" if tools else ""
print(f"{var['name']}{tools_str}")
print()
# Run migration
results = migrate_config(interactive=True, quiet=False)
print()
if results["env_added"] or results["config_added"]:
print(color("✓ Configuration updated!", Colors.GREEN))
if results["warnings"]:
print()
for warning in results["warnings"]:
print(color(f" ⚠️ {warning}", Colors.YELLOW))
print()
elif subcmd == "check":
# Non-interactive check for what's missing
print()
print(color("📋 Configuration Status", Colors.CYAN, Colors.BOLD))
print()
current_ver, latest_ver = check_config_version()
if current_ver >= latest_ver:
print(f" Config version: {current_ver}")
else:
print(color(f" Config version: {current_ver}{latest_ver} (update available)", Colors.YELLOW))
print()
print(color(" Required:", Colors.BOLD))
for var_name in REQUIRED_ENV_VARS:
if get_env_value(var_name):
print(f"{var_name}")
else:
print(color(f"{var_name} (missing)", Colors.RED))
print()
print(color(" Optional:", Colors.BOLD))
for var_name, info in OPTIONAL_ENV_VARS.items():
if get_env_value(var_name):
print(f"{var_name}")
else:
tools = info.get("tools", [])
tools_str = f"{', '.join(tools[:2])}" if tools else ""
print(color(f"{var_name}{tools_str}", Colors.DIM))
missing_config = get_missing_config_fields()
if missing_config:
print()
print(color(f" {len(missing_config)} new config option(s) available", Colors.YELLOW))
print(" Run 'hermes config migrate' to add them")
print()
else:
print(f"Unknown config command: {subcmd}")
print()
print("Available commands:")
print(" hermes config Show current configuration")
print(" hermes config edit Open config in editor")
print(" hermes config get <key> Print a resolved config value")
print(" hermes config set <key> <value> Set a config value")
print(" hermes config unset <key> Remove a config value")
print(" hermes config check Check for missing/outdated config")
print(" hermes config migrate Update config with new options")
print(" hermes config path Show config file path")
print(" hermes config env-path Show .env file path")
sys.exit(1)
# ── Profile-driven env var injection ─────────────────────────────────────────
# Any provider registered in providers/ with auth_type="api_key" automatically
# gets its env_vars exposed in OPTIONAL_ENV_VARS without editing this file.
# Runs once at import time.
_profile_env_vars_injected = False
def _inject_profile_env_vars() -> None:
"""Populate OPTIONAL_ENV_VARS from provider profiles not already listed.
Called once at module load time. Idempotent — repeated calls are no-ops.
"""
global _profile_env_vars_injected
if _profile_env_vars_injected:
return
_profile_env_vars_injected = True
try:
from providers import list_providers
for _pp in list_providers():
if _pp.auth_type not in {"api_key",}:
continue
for _var in _pp.env_vars:
if _var in OPTIONAL_ENV_VARS:
continue
_is_key = not _var.endswith("_BASE_URL") and not _var.endswith("_URL")
OPTIONAL_ENV_VARS[_var] = {
"description": f"{_pp.display_name or _pp.name} {'API key' if _is_key else 'base URL override'}",
"prompt": f"{_pp.display_name or _pp.name} {'API key' if _is_key else 'base URL (leave empty for default)'}",
"url": _pp.signup_url or None,
"password": _is_key,
"category": "provider",
"advanced": True,
}
except Exception:
pass
# Eagerly inject so that OPTIONAL_ENV_VARS is fully populated at import time.
_inject_profile_env_vars()
# ── Platform-plugin env var injection ────────────────────────────────────────
# Bundled platform plugins under ``plugins/platforms/*/plugin.yaml`` declare
# their required env vars via ``requires_env``. This mirror of
# ``_inject_profile_env_vars`` surfaces them in ``hermes config`` UI so users
# can configure Teams / IRC / Google Chat without the core repo ever needing
# to know they exist.
#
# Each ``requires_env`` entry may be a bare string (name only) or a dict:
#
# requires_env:
# - TEAMS_CLIENT_ID # minimal
# - name: TEAMS_CLIENT_SECRET # rich
# description: "Teams bot client secret"
# url: "https://portal.azure.com/"
# password: true
# prompt: "Teams client secret"
#
# An optional ``optional_env`` block surfaces non-required vars the same way
# (e.g. allowlist, home channel).
_platform_plugin_env_vars_injected = False
def _inject_platform_plugin_env_vars() -> None:
"""Populate OPTIONAL_ENV_VARS from bundled platform plugin manifests.
Called once at module load time. Idempotent — repeated calls are no-ops.
Failures are swallowed so a malformed plugin.yaml can't break CLI import.
"""
global _platform_plugin_env_vars_injected
if _platform_plugin_env_vars_injected:
return
_platform_plugin_env_vars_injected = True
try:
import yaml # type: ignore
# Resolve the bundled plugins dir from this file's location so the
# injector works regardless of CWD.
repo_root = Path(__file__).resolve().parents[1]
platforms_dir = repo_root / "plugins" / "platforms"
if not platforms_dir.is_dir():
return
for child in platforms_dir.iterdir():
if not child.is_dir():
continue
manifest_path = child / "plugin.yaml"
if not manifest_path.exists():
manifest_path = child / "plugin.yml"
if not manifest_path.exists():
continue
try:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = fast_safe_load(f) or {}
except Exception:
continue
label = manifest.get("label") or manifest.get("name") or child.name
# Merge required + optional env var declarations.
entries = list(manifest.get("requires_env") or [])
entries.extend(manifest.get("optional_env") or [])
for entry in entries:
if isinstance(entry, str):
name = entry
meta: dict = {}
elif isinstance(entry, dict) and entry.get("name"):
name = entry["name"]
meta = entry
else:
continue
if name in OPTIONAL_ENV_VARS:
continue # hardcoded entry wins (back-compat)
# Heuristic: anything named *TOKEN, *SECRET, *KEY, *PASSWORD
# is a password field unless explicitly overridden.
name_upper = name.upper()
is_secret = bool(meta.get("password") or meta.get("secret"))
if not is_secret and not meta.get("password") is False:
is_secret = any(
name_upper.endswith(suf)
for suf in ("_TOKEN", "_SECRET", "_KEY", "_PASSWORD", "_JSON")
)
OPTIONAL_ENV_VARS[name] = {
"description": (
meta.get("description")
or f"{label} configuration"
),
"prompt": meta.get("prompt") or name,
"url": meta.get("url") or None,
"password": is_secret,
"category": meta.get("category") or "messaging",
}
except Exception:
pass
# Eagerly inject so that platform plugin env vars show up in the setup wizard.
_inject_platform_plugin_env_vars()