Compare commits

...
Author SHA1 Message Date
ethernet 82d3d44020 ci: make some *ty* stuff errors 2026-06-12 15:04:24 -04:00
ethernet f5f41a0921 feat: refactor doctor, unify pip install and project root 2026-06-12 15:04:24 -04:00
ethernet af1780f2ca feat: remove no-venv support
- Remove --no-venv/-NoVenv flags from install.sh and install.ps1. Venv
creation is now mandatory.
- Update installation docs to explicitly state that a virtual
environment is strictly required and global/system Python installations
are not supported.
2026-06-11 22:48:39 -04:00
ethernet 74fbd7f01f fix: update all error messages to recommend 'uv pip install' instead of raw pip
- Replace sys.executable -m pip install with 'uv pip install' in error messages across:
  - gateway/run.py (PyNaCl missing)
  - tools/voice_mode.py (sounddevice/numpy missing)
  - cli.py (voice mode dependencies missing)
  - mcp_serve.py (mcp package missing)
  - hermes_cli/web_server.py (fastapi/uvicorn missing)
- Ensures 100% consistency: we NEVER recommend raw pip to the user anywhere in the codebase, even in error messages.
2026-06-11 20:48:49 -04:00
ethernet afa3e89119 feat: add atomic venv recreation and doctor integration
- Add recreate_venv_atomically() to managed_uv.py: builds fresh venv.new, installs deps, and atomically swaps venv -> venv.bak and venv.new -> venv. This guarantees safe migration from legacy pip venvs without stripping dependencies.
- Update get_pip_cmd() circuit breaker to suggest running hermes doctor.
- Add 'Virtual Environment Integrity' check to hermes doctor.
- hermes doctor --fix now automatically detects legacy/broken venvs and atomically recreates them using uv.
2026-06-11 19:46:26 -04:00
ethernet c542884168 fix: update all manual recovery and diagnostic messages to recommend uv
- Update interrupted install recovery message to recommend 'uv pip install' or re-running the installer
- Update Web UI dependency missing message to prioritize 'uv pip install'
- Update hermes doctor missing optional dependency messages to recommend 'uv pip install'
- Ensures 100% consistency: we NEVER recommend raw pip to the user anywhere in the codebase
2026-06-11 19:19:43 -04:00
ethernet 5f298e5b2a fix: update hermes doctor to reflect strict uv requirement
- Check for system uv (e.g., Termux pkg install uv) as a secondary fallback in doctor
- Replace outdated 'will fall back to plain pip' warning with a clear check_fail
- Ensures hermes doctor accurately reflects the new strict uv invariant
2026-06-11 19:19:43 -04:00
ethernet 4deaa42ccb refactor: strictly enforce uv requirement, eliminate ALL raw pip fallbacks
- Remove the degenerate  fallback from  entirely.
- If neither managed uv nor system PATH uv is found, raise a clear .
- This enforces the architectural invariant: Hermes strictly requires uv for dependency management.
- Silently falling back to raw pip only masks environment corruption and re-introduces the ensurepip/PEP-668 bugs this refactor was built to eliminate.
- Termux users are correctly guided to use the canonical  if the managed installer fails.
2026-06-11 19:19:43 -04:00
ethernet 4bdb2ba38c fix: add PATH uv fallback to get_pip_cmd() for Termux compatibility
- The official uv installer may fail on Termux due to glibc vs bionic differences.
- Hermes already has _ensure_uv_for_termux() which falls back to 'pip install uv'.
- Update get_pip_cmd() to check shutil.which('uv') as a secondary fallback before resorting to raw pip, ensuring Termux users who successfully install uv via pip actually get to use it!
2026-06-11 19:19:43 -04:00
ethernet 71bd99b8b0 refactor: unify all venv and pip installation logic into managed_uv.py
- Add get_venv_root() and pip_install() to hermes_cli/managed_uv.py
- pip_install() now handles VIRTUAL_ENV, PATH prepending, PYTHONPATH/PYTHONHOME cleanup, and the get_pip_cmd() fallback in ONE place
- Update tools/lazy_deps.py to use the unified pip_install()
- Update hermes_cli/tools_config.py to use pip_install() and remove redundant fallback logic
- Update hermes_cli/main.py to use get_pip_cmd() and remove all manual VIRTUAL_ENV/PYTHONPATH manipulation
- All dependency installation now flows through a single, authoritative, bulletproof helper!
2026-06-11 19:19:43 -04:00
ethernet 04c8fcd1df refactor: update google-workspace setup script to use get_pip_cmd() 2026-06-11 19:19:43 -04:00
ethernet e77f1de940 refactor: exhaustively replace manual pip invocations with get_pip_cmd()
- Update hermes_cli/setup.py to use get_pip_cmd()
- Update hermes_cli/dingtalk_auth.py to use get_pip_cmd()
- Update agent/lsp/install.py to use get_pip_cmd()
- Update hermes_cli/main.py update fallback to use get_pip_cmd()
- tools/env_probe.py intentionally left alone as it probes system pip for diagnostics
2026-06-11 19:19:43 -04:00
ethernet c64972af77 refactor: replace manual pip invocations with centralized get_pip_cmd()
- Update scripts/install_psutil_android.py to use get_pip_cmd()
- Update plugins/platforms/google_chat/oauth.py to use get_pip_cmd()
- Update plugins/memory/honcho/cli.py to use get_pip_cmd()
- Update plugins/google_meet/cli.py to use get_pip_cmd()
- Update hermes_cli/memory_setup.py to use get_pip_cmd() and remove messy manual uv/pip fallback logic
2026-06-11 19:19:43 -04:00
ethernet 657bd1d328 refactor: centralize get_pip_cmd() in managed_uv and add doctor check
- Add global get_pip_cmd() to hermes_cli/managed_uv.py
- Remove duplicate _get_pip_cmd() from tools/lazy_deps.py and hermes_cli/tools_config.py
- Update both files to import and use the centralized get_pip_cmd()
- Add 'Dependency Management' section to hermes doctor to verify managed uv availability
2026-06-11 19:19:43 -04:00
ethernet 8e291759fc fix(doctor): clarify that python 3.10 is the minimum supported version 2026-06-11 19:19:43 -04:00
ethernet 02df5207a9 fix(doctor): note that git is required for update 2026-06-11 19:19:43 -04:00
ethernet 23ce5c00ad docs/cli: apply deprecations and platform warnings from support tiers plan
- Step 7: Add deprecate! to Homebrew formula and mark README as frozen/discontinued
- Step 10: Add best-effort support banner to Termux / Android docs
- Step 11: Add explicit unsupported warning for macOS x86_64 (Intel) in `hermes doctor`
2026-06-11 19:19:43 -04:00
ethernet 8ee19f354d refactor: rip out ensurepip and standardize entirely on managed uv
- Add strict rule to AGENTS.md: always use ensure_uv() / resolve_uv() for dependency installation
- Create _get_pip_cmd() helpers returning ["<managed_uv>", "pip"] with degenerate fallback
- Update hermes_cli/tools_config.py to use resolve_uv() instead of shutil.which("uv")
- Update hermes_cli/main.py install and recovery paths to use ensure_uv()
- Update tools/lazy_deps.py to use managed uv path directly without hermes_cli dependency
- Update tools/environments/modal.py to remove ensurepip from dockerfile setup
- Update scripts/install.ps1 to use $UvCmd for SDK installation instead of ensurepip
- Update tests to reflect removal of ensurepip bootstrapping
2026-06-11 19:19:43 -04:00
ethernet f62abc9ac2 docs: add pip deprecation and migration guides to installation and updating docs
- Add prominent platform support callout to installation.md
- Remove 'pip install' row from installation layout table
- Add 'Migrating from pip / PyPI' subsection to installation.md
- Replace 'pip installs' sections in updating.md with clear deprecation notices and migration links
2026-06-11 16:16:31 -04:00
ethernet 967574f9d7 docs: restructure platform support to prioritize CONTRIBUTING.md
- Add full 'Platform Support' section to CONTRIBUTING.md with detailed tier breakdown
- Update AGENTS.md to provide a concise summary for agents and link to CONTRIBUTING.md
- Update 'Contribution Priorities' in CONTRIBUTING.md to reference the new platform support section
2026-06-11 16:15:18 -04:00
ethernet 3017095449 docs: add platform support tiers to AGENTS.md and create reference doc
- Add 'Platform Support' section to AGENTS.md outlining the 3 support tiers (Explicitly supported, Best-effort, Explicitly unsupported)
- Create canonical user-facing reference at website/docs/reference/platform-support.md
- Include migration guides for deprecated pip/PyPI and Homebrew installations
- Clarify Nix and Termux best-effort boundaries
2026-06-11 15:44:48 -04:00
ethernet 89be1f52b2 plan 2026-06-11 15:39:45 -04:00
ethernet bfd6d165a7 platform support tiers 2026-06-11 13:36:04 -04:00
Davy a72bb03757 fix(docker): optimize image size — .dockerignore, drop dev deps, split build layers (#38749)
* fix(docker): optimize image size with .dockerignore, drop dev deps, split build layers

Three changes to reduce the Docker image size and speed up rebuilds:

1. .dockerignore — exclude ~69 MB of files that are never needed inside
   the container: apps/ (desktop Tauri source), tests/, website/
   (Docusaurus), docs/, infographic/, nix/, plans/, packaging/, and
   various dotfiles (.envrc, .hadolint.yaml, .mailmap, etc.).  The
   existing .dockerignore already covered node_modules and .git; these
   additions prevent the remaining non-runtime content from inflating
   both the build context and the final image (COPY . .).

2. pyproject.toml — add a [docker] extra that mirrors [all] but omits
   [dev] (debugpy, pytest, pytest-asyncio, pytest-timeout, ty, ruff,
   setuptools).  The published image doesn't need test/debug tooling.
   Estimated savings: ~30-50 MB of Python packages.

3. Dockerfile — use --extra docker instead of --extra all in the
   uv sync layer.  Also split the COPY + npm run build so that the
   web/ and ui-tui/ frontend builds are cached independently from
   Python source changes (COPY . .).  A Python-only commit no longer
   invalidates the (slower) frontend build layer.

Note: the build-only apt packages (gcc, python3-dev, libffi-dev,
libolm-dev) are still installed in the final image.  Removing them
requires a true multi-stage build (builder → runtime), which is a
larger refactor tracked separately.

* fix(docker): remove redundant [docker] extra, revert to --extra all

The [docker] extra was identical to [all] on main — the PR had added [dev]
to [all] then created [docker] as [all] minus [dev], a no-op round-trip.
Revert [all] to its original form and drop the [docker] extra.

Keep the .dockerignore additions and frontend build layer reordering.
2026-06-10 03:08:00 -07:00
Gille 47e77ae166 fix(curator): use shared atomic state writer 2026-06-10 03:04:54 -07:00
Gille 4c797d0e23 fix(desktop): hide Windows console children launched by GUI 2026-06-10 03:04:54 -07:00
teknium1 189ffe7362 test: port voice-reply suffix assertions, fix change-detector cap test, add AUTHOR_MAP entry
- Add output_path suffix assertions (.ogg Telegram / .mp3 non-Telegram) to
  _send_voice_reply tests, covering the OGG voice-note path that landed on
  main in ae82eed2b (the PR's third commit was redundant with it).
- Convert test_gemini_default_is_32000 back to an invariant against
  PROVIDER_MAX_TEXT_LENGTH instead of a hardcoded literal.
- Map barronlroth@gmail.com -> barronlroth in scripts/release.py.
2026-06-10 02:57:39 -07:00
Barron Roth 2c19208224 feat(tts): add Gemini audio tag rewrite 2026-06-10 02:57:39 -07:00
Barron Roth 5718811de0 feat(tts): add Gemini persona prompt file 2026-06-10 02:57:39 -07:00
Teknium af3c8b80b5 fix(tests): close pid-file read race in test_grandchild_reaped_via_pgroup (#43447)
The grandchild wrote its pid with open('w').write(...), so the polling
reader in the test could observe the file after creation but before the
write flushed, parsing '' -> ValueError: invalid literal for int().
Write to a temp file and os.replace() it into place so the pid file only
ever appears fully written.
2026-06-10 02:57:27 -07:00
Teknium 70d5d7e39b fix(memory,skills): repair write-approval inline prompt, gateway staging, and gateway /skills review (#43452)
Follow-ups to #38199/#43354 found in post-merge review:

- Inline CLI memory approval never worked: the per-thread approval callback
  was not passed to prompt_dangerous_approval, so the prompt_toolkit
  fail-closed guard (#15216) denied every gated foreground write without
  showing a prompt. Now invokes the registered callback directly; a crashed
  prompt falls back to staging instead of a silent deny.
- Gateway sessions claimed inline support but prompt_dangerous_approval has
  no gateway round-trip (that lives in the pending-approval queue), so gated
  gateway memory writes hit the input() fallback and denied. Gateway
  contexts now stage for /memory pending review.
- /skills pending|approve|reject|diff|approval now works on the gateway
  (gateway_config_gate on skills.write_approval), so skills staged from a
  messaging session can be reviewed there. Diff output truncated for chat.
- memory_tool validates required params before the gate so invalid writes
  are rejected immediately instead of staged and failing at approve time.
- Stale tri-state write_mode docstrings updated to the boolean gate; docs
  table corrected (inline prompt is interactive-CLI-only).
- 6 new tests covering the interactive approve/deny/error paths, gateway
  staging, skills never-prompt invariant, and pre-gate validation.
2026-06-10 02:57:15 -07:00
Teknium a5c32cdf30 fix(update): self-heal a venv left half-built by an interrupted install (#42172)
* fix(update): self-heal a venv left half-built by an interrupted install

An update killed mid dependency-install (Ctrl-C, terminal close, WSL OOM)
could leave the venv with pip wiped and core deps (e.g. Pillow) missing,
with no automatic recovery — the user had to manually run ensurepip +
reinstall.

Drop an install-scoped .update-incomplete breadcrumb right before the dep
install and clear it only after core-dependency verification passes. On the
next launch (any command except 'update' itself), if the marker is present,
unconditionally bootstrap pip via ensurepip then re-run the .[all] install +
verification, then clear the marker. Failure leaves the marker for retry and
prints the manual recovery command. Never raises — recovery cannot block
launch.

* fix(update): address review — stderr-only recovery output, single-flight lock, gitignore marker

- Route all recovery output (status lines + streamed pip/uv install via
  fd-level dup2) to stderr so protocol-on-stdout launches (hermes acp)
  never get install noise on the JSON-RPC stream.
- Single-flight O_EXCL lockfile (.update-incomplete.lock) so a gateway
  start + CLI launch (or two profiles) can't run concurrent installs
  into the shared venv; stale locks (>1h) are broken for the next launch.
- gitignore .update-incomplete + lock so source-tree installs keep a
  clean git status and update's autostash skips them.
- Document why the loose 'update' argv substring match is intentional
  (over-match defers one launch; under-match would race the real update).
- 4 new tests: lock held → skip, stale lock broken, lock released,
  output lands on stderr only.
2026-06-10 02:57:05 -07:00
Ben Barclay 15813336cc fix(config): preserve original .env file mode in remove_env_value too (#43349)
#33699 fixed save_env_value so an operator-set .env mode (e.g. 0640 on a
Docker bind-mount) survives a config write instead of being re-tightened
to 0600 by the unconditional _secure_file() call. The sibling
remove_env_value() had the identical bug: it restores original_mode and
then unconditionally called _secure_file(env_path), clobbering the mode
back to 0600 on every `hermes config remove KEY`.

Apply the same fix: move _secure_file() into the else branch so it only
runs when no original mode was captured (a freshly created .env still
gets 0600 hardening; existing operator-set modes survive).

Added test_remove_env_value_preserves_existing_file_mode_on_posix, which
fails on the unfixed remove path (expected 0o640, got 0o600) and passes
with the fix.
2026-06-10 19:53:07 +10:00
Siddharth Balyan 183d86b3e0 fix(openrouter): route reasoning_effort to verbosity for adaptive Anthropic models (#43436)
* fix(openrouter): route reasoning_effort to verbosity for adaptive Anthropic models

Reasoning-mandatory Anthropic models (Claude 4.6+/fable/mythos-class) over
OpenRouter ignore reasoning.effort and use adaptive thinking. #42991 correctly
stopped Hermes from sending a reasoning field to them (it 400s), but put nothing
in its place — leaving agent.reasoning_effort a silent no-op on the OpenRouter
path: the model always ran at its adaptive default (high) regardless of config.

OpenRouter honors the requested effort on the top-level verbosity field instead
(maps to Anthropic output_config.effort). Route the existing
reasoning_config[effort] there for these models while still never emitting a
reasoning field, preserving the #42991 fix. No new config arg — the value the
user already sets via agent.reasoning_effort now flows to verbosity.

- low/medium/high/xhigh/max pass through verbatim (OpenRouter accepts the
  extended scale for Claude; verified live HTTP 200 + monotonic token spend).
- effort unset/none/disabled omits verbosity so the model keeps its default.
- native Anthropic transport already correct; unchanged.

Fixes #43432

* test(openrouter): cover real effort range (add minimal, frame max as passthrough)

Adversarial review noted the verbosity tests looped over 'max' — a value
parse_reasoning_effort can never produce — while omitting 'minimal', which it
can. Align the routing test with the real config range
(VALID_REASONING_EFFORTS = minimal/low/medium/high/xhigh) and keep a separate
value-agnostic passthrough test that documents why xhigh/max must survive
verbatim (TypedDict, no runtime literal validation; OpenRouter accepts the
extended scale for Claude).

* docs: explain reasoning_effort -> verbosity routing for adaptive Anthropic models

Document that reasoning_effort transparently maps to OpenRouter's verbosity
field for adaptive-thinking Anthropic models (Claude 4.6+/Fable/Mythos), where
reasoning.effort is ignored. Note xhigh is the configurable ceiling (max is wire-
only). Add verbosity as a top-level-kwarg example in the provider-plugin guide.
2026-06-10 15:03:01 +05:30
Teknium cd9a9cd8e5 fix(gateway): Slack approval UX in threads — block-size overflow + typed-prefix instruction text (#43444)
Two fixes for the reported Slack thread approval UX:

1. Slack Block Kit approval/confirm sends silently overflowed the
   3000-char section-block cap (flat 2900-char truncation + header +
   reason), so long execute_code approvals failed with invalid_blocks
   and fell back to the plain-text prompt with no buttons. Budget the
   command preview against the rendered fixed parts so blocks never
   exceed the cap (send_exec_approval + send_slash_confirm).

2. The text fallbacks told users to reply /approve — which Slack blocks
   inside threads and Matrix clients reserve client-side. Add a
   typed_command_prefix capability flag on BasePlatformAdapter
   (default "/"; Slack and Matrix set "!" to match their existing
   bang-prefix rewrite) and use it in the shared fallback prompt
   builders (exec approval, update prompt, destructive slash confirm,
   expensive-model confirm) plus Matrix's reaction-prompt text.
   The slash-confirm text-intercept now also accepts bang-prefixed
   replies (!always, !cancel) since those keywords aren't registered
   commands and the adapters' rewrite doesn't touch them.
2026-06-10 02:30:01 -07:00
Evi Nova 5d8c44a393 fix(docker): pre-install matrix deps in Docker image (#30399) (#42413)
The Matrix gateway requires mautrix[encryption] which pulls in
python-olm. While python-olm was removed from [all] due to missing
Windows/macOS wheels, it has binary manylinux wheels for Linux
amd64/arm64. The Docker image only runs on Linux, so adding --extra
matrix to the uv sync line is safe.

libolm-dev is already in the apt-get install line for runtime linking.

Fixes: #30399
2026-06-10 19:23:06 +10:00
kshitij 2f19512341 fix(cli): repair non-UTF-8 stdout/stderr on all platforms, not just Windows (#43439)
`hermes setup` (and other banner-printing commands) crash with an unhandled
UnicodeEncodeError on Linux hosts whose locale selects a non-UTF-8 codec —
e.g. a fresh Raspberry Pi / minimal Debian with a latin-1 or C/POSIX locale.
The setup wizard prints box-drawing characters (┌│├└─) and the ⚕ glyph before
any stream repair runs, so the command dies before it can start.

The existing _ensure_utf8() shim already knew how to re-wrap the standard
streams as UTF-8, but it returned early on `sys.platform != "win32"`, so the
identical crash class on Linux was never covered.

- Drop the win32 gate: repair any stdout/stderr whose encoding is not UTF-8.
- Prefer TextIOWrapper.reconfigure() so the stream object is fixed in place
  (cached sys.stdout references keep working); fall back to reopening the fd
  with closefd=False (the CPython-recommended safe variant).
- Use errors="replace" — matching the sibling hermes_cli/stdio.py shim — so a
  stray un-encodable byte degrades gracefully instead of crashing.
- Only set the PYTHONUTF8/PYTHONIOENCODING child-process hints when a repair
  actually happened, so a healthy UTF-8 host sees zero footprint (no stream
  swap, no env mutation).

This is intentionally the earliest, platform-agnostic guard, running at import
time before any banner prints. hermes_cli/stdio.py::configure_windows_stdio()
still runs later from the entry points for the Windows-only extras (console
code-page flip, EDITOR default, PATH augmentation); it early-returns on
non-Windows and its stream reconfigure is an idempotent no-op once we've
already repaired the streams here.

Add regression tests covering latin-1 and ascii/POSIX streams, the reconfigure
fallback, already-UTF-8 no-op (identity preserved + no env mutation), the
repair-sets-env and respects-explicit-env contracts, and hostile/None streams.
2026-06-10 02:21:00 -07:00
brooklyn! f222bd26e7 Merge pull request #43430 from NousResearch/bb/desktop-tool-codicons-filled
style(desktop): filled glyphs for in-thread tool icons
2026-06-10 03:52:04 -05:00
Brooklyn Nicholson 38273676ea fix(desktop): carve sticky user bubbles out of the titlebar drag region
Sticky human bubbles park at --sticky-human-top (~4px), sliding under the
titlebar's -webkit-app-region:drag strips. Electron resolves drag regions at
the compositor level — z-index and pointer-events don't apply — so clicking a
stuck bubble dragged the window instead of opening the edit composer. Add
no-drag to the shared bubble base class (read-only bubble + edit composer).

Covers the runtime side with a test: clicking a user bubble opens the inline
edit composer through both the incremental external-store runtime and the
stock one.

(cherry picked from commit db4e1f4f3eaee955fe057aedcfea6122c476535a)
2026-06-10 03:46:03 -05:00
Brooklyn Nicholson c1308ebf3f style(desktop): filled SVG glyphs for in-thread tool icons
Replace the earlier text-stroke approach (which only bolds outline
codicons — a font glyph has no fillable region) with dedicated solid
SVG glyphs for tool rows. Adds ToolIcon, keyed by the same names as
TOOL_META, with a codicon fallback for uncovered tools.
2026-06-10 03:41:55 -05:00
teknium1 fa32af886f fix: dedupe concurrent gateway restarts + surface restart outcome in onboarding UI
Follow-ups to the salvaged Telegram QR onboarding auto-restart:

- _spawn_gateway_restart() reuses a live in-flight 'hermes gateway restart'
  child instead of spawning a second racing one (stale cached frontend +
  new backend both requesting a restart, or restart-button double-click).
  Both /api/gateway/restart and the onboarding apply path go through it.
- ChannelsPage polls /api/actions/gateway-restart/status after a
  server-initiated restart and surfaces a non-zero exit (e.g. systemd
  linger missing) via the manual-restart banner, since restart_started
  only means the child spawned.
- Test for the reuse path + _ACTION_PROCS isolation in existing tests.
2026-06-10 01:35:12 -07:00
Shannon Sands 984e69ff62 Auto-restart gateway after Telegram QR onboarding 2026-06-10 01:35:12 -07:00
Brooklyn Nicholson e80754647c style(desktop): render in-thread tool codicons as filled glyphs
Outline codicons read too thin at conversation-tool scale; a scoped
filled modifier thickens tool-row and code-card icons without changing
icon semantics elsewhere in the shell.
2026-06-10 03:30:25 -05:00
Teknium 298bb93d39 feat(skills): show live per-source progress while browsing (#43398)
do_browse waited on a frozen 'Fetching skills...' spinner while sources
resolved, so a slow source looked like a hang. parallel_search_sources
already exposes an on_source_done(sid, count) callback fired as each source
completes — wire it into the status line so it ticks off sources live
(official (12), + github (4), + clawhub (500)). The page is still rendered
once, after the full set is merged and trust-sorted, so browse's
official-first ordering and pagination contract are untouched.
2026-06-10 01:02:40 -07:00
Teknium eee1da45f0 fix(skills): bound ClawHub catalog walk to requested page on cold start (#43395)
Browse renders one page but the cold-cache fallback walked the entire
50k+ ClawHub catalog, then sliced off the first N — pure waste behind the
12s budget band-aid. _load_catalog_index now takes max_items: browse's
empty-query path bounds the walk to its limit and stops early; the offline
index builder still passes limit=0 (unbounded) and walks to exhaustion.
A bounded walk is partial, so it is not written to the shared full-catalog
cache (same poison-guard as the budget-truncated case).
2026-06-10 01:01:53 -07:00
konsisumer 6a30cfca82 fix(gateway): stop typing before post-delivery callbacks (#37556) 2026-06-10 00:46:00 -07:00
Teknium 888bf96025 chore(release): add tomekpanek to AUTHOR_MAP 2026-06-10 00:34:38 -07:00
tomekpanek 383d44bc9a fix(web): rank explicit credentials above managed-gateway probe
Backend selection ordered firecrawl (including the Nous-managed-tool-gateway
probe) ahead of explicit-credential backends, so a user who had both a
Nous OAuth token AND a TAVILY_API_KEY (or EXA/PARALLEL key) got firecrawl
auto-selected — then the request failed at runtime because the free Nous
tier does not include web search, and there is no fallback to the next
available backend. Explicit user setup lost to a managed convenience.

Reorder so direct-credential backends (tavily > exa > parallel > firecrawl-
direct) are tried first, then the managed-gateway firecrawl probe, then
free-tier fallbacks. Behaviour for users with only Nous OAuth (no
explicit key) is unchanged — firecrawl-via-gateway is still selected.

Behaviour change to flag: a user with BOTH a Nous OAuth token AND a
TAVILY_API_KEY (or EXA/PARALLEL key) now gets the explicit backend
instead of the managed gateway. This matches the principle of least
surprise — a user does not set TAVILY_API_KEY without intent — and
sidesteps the silent runtime failure of the gateway path on free tiers.
2026-06-10 00:34:38 -07:00
Teknium 243cada157 fix(model): cover typed gateway /model path + async-safe pricing lookups
Follow-ups on top of #26016's expensive-model guard:

- gateway/slash_commands.py: typed '/model <name>' now routes through the
  expensive-model confirmation gate (slash-confirm buttons / text fallback)
  instead of bypassing the guard the pickers enforce. Cancel leaves the
  session override and --global config untouched.
- telegram/discord/web_server: run expensive_model_warning() via
  asyncio.to_thread — it can hit models.dev or a /models endpoint on a
  cache miss, which would otherwise block the event loop.
- telegram: picker callback no longer toasts 'Model switched!' when the
  switch callback raised (both mm: and mc: paths).
- tests: new tests/gateway/test_model_command_expensive_confirm.py pins
  the typed-path gate (prompt, confirm-once, cancel, cheap-model no-op).
2026-06-10 00:24:06 -07:00
Robin FernandesandClaude Fable 5 af978ecb17 fix(model): require confirmation for expensive model selections
Rebased onto current main and re-ported across the restructured
surfaces: model flows now thread confirm_provider/base_url/api_key
through hermes_cli/model_setup_flows.py, the Discord picker lives in
plugins/platforms/discord/adapter.py, and the web dashboard picker
applies chat-mode switches via config.set so the expensive-model
confirmation can ride the response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:24:06 -07:00
teknium1 4eadef18a9 fix: guard role_authorized check against MagicMock test sources
Compare source.role_authorized with 'is True' so a MagicMock source
(test fixtures that build bare runners via object.__new__) doesn't
auto-truthy through the gate. The real SessionSource field is a bool,
so production behavior is unchanged. Fixes test_signal_in_allowlist_maps.
2026-06-10 00:18:11 -07:00
teknium1 099146fedd chore: add AUTHOR_MAP entry for PR #33958 contributor 2026-06-10 00:18:11 -07:00
Joel Chan e5580f43c2 fix(discord): propagate role_authorized flag so DISCORD_ALLOWED_ROLES works end-to-end
DISCORD_ALLOWED_ROLES was checked by the Discord adapter (_is_allowed_user)
but gateway._is_user_authorized only read DISCORD_ALLOWED_USERS, so
role-authorized users were rejected with "Unauthorized user" at the
gateway layer despite passing the adapter gate.

- Add role_authorized: bool = False to SessionSource
- Add role_authorized param to build_source (base.py)
- Compute _role_authorized in on_message when user passes via role not user ID
- Thread _role_authorized through _handle_message -> build_source
- Check source.role_authorized early in _is_user_authorized (run.py)

Fixes #33952
2026-06-10 00:18:11 -07:00
达令小新 5a4297a11a fix(model_metadata): prefer hardcoded 1M for MiniMax M3 over stale models.dev probe 2026-06-09 23:24:40 -07:00
xxxigm aea0b7397b test(discord): cover voice timeout under voice-off mode
Assert the inactivity handler skips disconnect (and the channel spam) when the
voice-mode getter reports "off", and still disconnects on genuine inactivity
when the mode is active.
2026-06-09 23:24:26 -07:00
xxxigm 311900842e fix(discord): don't auto-disconnect voice when reply mode is off
The voice inactivity timer (VOICE_TIMEOUT) only counted the bot's OWN audio
playback as activity. Under /voice off (text-only replies, but still in the
channel — leaving is /voice leave) nothing ever reset it, so every 300s the bot
disconnected and spammed "Left voice channel (inactivity timeout)."

The adapter now learns the live voice-reply mode via a getter wired from run.py
and skips the auto-disconnect while mode is off. It also resets the timer when a
user actually speaks to the bot, so an active listener (incl. voice-on
text-only sessions that never play audio) isn't dropped mid-conversation.
2026-06-09 23:24:26 -07:00
briandevans 105625d650 fix(skills): honour overall_timeout and bound ClawHub catalog walk
parallel_search_sources accepted an overall_timeout but never honoured it.
The ThreadPoolExecutor ran inside a `with ... as pool` block, whose __exit__
calls shutdown(wait=True); even after as_completed() raised TimeoutError on
schedule, leaving the block blocked the caller until every worker finished.
A single slow source (e.g. ClawHub) therefore stalled the entire browse for
minutes. Manage the executor manually and shut it down with
wait=False, cancel_futures=True in a finally, so the timeout actually returns
and not-yet-started work is dropped.

ClawHubSource._load_catalog_index walked up to 750 sequential pages with no
wall-clock bound (each request under its own timeout=30, so nothing errored),
and wrote the result to the index cache unconditionally — so an interrupted or
slow walk poisoned the cache with a partial catalog. Add a
CATALOG_WALK_BUDGET_SECONDS deadline that breaks the walk early, and only write
the cache when the walk reaches a natural stop (cursor exhausted or page cap),
never on a budget-truncated walk.

Adds regression tests covering both bugs (timeout honoured + slow source
flagged; budget abort does not poison cache) plus their happy-path invariants.
2026-06-09 23:22:54 -07:00
teknium 2ce3ae3d16 fix(error-classifier): don't misclassify unsupported-param 400s as context overflow
A GPT-5 model rejecting max_tokens returns a 400 whose message contains the
literal substring 'max_tokens' — one of the _CONTEXT_OVERFLOW_PATTERNS. The 400
path in _classify_400 checked overflow patterns before any request-validation
check (which only existed on the 5xx path), so the parameter error was routed
into the compression loop, re-sent with the same bad param, and ended in
'Cannot compress further' on a tiny context.

Hoist a request-validation guard (unsupported/unknown parameter) above the
context-overflow check in _classify_400. Deliberately excludes the generic
invalid_request_error code, which OpenAI also stamps on real overflow 400s, so
genuine overflows still compress. Pairs with the max_completion_tokens param
fix that stops the bad request at the source.

Also adds AUTHOR_MAP entry for the salvaged PR #13902 commit.
2026-06-09 23:22:10 -07:00
Xiangji 19c07c4037 fix(params): send max_completion_tokens for newer OpenAI families on custom endpoints
Third-party OpenAI-compatible endpoints (self-hosted gateways, OpenRouter,
Azure proxies) fronting gpt-4o / gpt-4.1 / gpt-5+ / o1-o4 models silently
received max_tokens and 400'd with unsupported_parameter, because the three
kwarg-selection sites only checked base_url_hostname(...) == "api.openai.com"
and fell through to max_tokens on every other host. The constraint is
enforced server-side by the model family, not by the URL, so name-based
detection is required as a fallback.

Changes:
- utils.py: new shared helper model_forces_max_completion_tokens(model) that
  prefix-matches gpt-4o, gpt-4.1, gpt-5, o1, o3, o4 families on normalized
  (lowercased, vendor-prefix-stripped) names.
- run_agent.py: _max_tokens_param ORs the helper into the URL check.
- agent/auxiliary_client.py:
  - auxiliary_max_tokens_param gains an optional keyword-only model arg.
  - _build_call_kwargs inline branch applies the same check for both
    provider == "custom" and non-custom paths.

Tests:
- tests/test_model_forces_max_completion_tokens.py: 31 new cases covering
  positive families, negatives (classic gpt-4, claude, llama, mistral, qwen,
  deepseek), vendor prefixes, case-insensitivity, whitespace, None/empty,
  and substring-not-prefix guards.
- tests/run_agent/test_run_agent.py::TestMaxTokensParam: 5 new model-based
  cases (custom + gpt-5.4, openrouter + gpt-4o-mini, custom + o1-preview,
  classic gpt-4-turbo keeps max_tokens, llama3 keeps max_tokens).
- tests/agent/test_auxiliary_client.py::TestAuxiliaryMaxTokensParam: new
  class, 7 tests covering the URL x model matrix.
2026-06-09 23:22:10 -07:00
Teknium ab55008631 chore: add AUTHOR_MAP entry for OndrejDrapalik
Maps the salvaged #36781 commit author email to the GitHub login so the
release attribution + CI author check resolve.
2026-06-09 23:21:24 -07:00
Ondrej Drapalik 1c055a4c58 fix(xai): accept Grok Build code during loopback wait + tiny screenshot guard
xAI's consent page renders the authorization code in-page instead of
redirecting to the loopback callback, so the listener just hangs and the
manual-paste flow demands a callback URL that never contains the token.

- auth.py: poll stdin non-blockingly while waiting for the xAI loopback
  callback; accept a pasted bare Grok Build code and substitute the locally
  generated state (PKCE code_verifier still binds the exchange). No need to
  wait for timeout or re-run with --manual-paste.
- computer_use: parse PNG/JPEG dimensions from base64 and fall back to the
  text/AX/SOM payload when the screenshot is below the provider minimum
  (8x8), which xAI rejects with HTTP 400.
- model_setup_flows.py: xAI credential reuse prompt uses the standard radio
  picker via a shared _prompt_auth_credentials_choice helper.
- main.py: thread a title through _prompt_provider_choice; re-home the helper
  import (flows live in model_setup_flows.py post-decomposition).

Salvaged from #36781 onto current main (contributor's main.py edits re-homed
to model_setup_flows.py, where the flows were extracted since the PR opened).
2026-06-09 23:21:24 -07:00
Teknium 095f526b11 refactor(memory,skills): replace tri-state write_mode with boolean write_approval (default off) (#43354)
The shipped tri-state write_mode (on|off|approve) conflated two concepts —
whether writes are enabled and whether they're gated — so 'on' (writes flow
freely, gate inactive) read like 'gating is on'. Replace it with a single
clear boolean gate that defaults off.

  memory.write_approval / skills.write_approval:
    false (default) — write freely; the approval gate is off (pre-gate behaviour)
    true            — require approval: memory foreground prompts inline, memory
                      background-review + all skill writes stage for review

The old 'off = block all writes' mode is dropped; memory_enabled: false already
disables memory entirely, so a third 'block' state was redundant.

- tools/write_approval.py: get_write_mode/MODE_* → write_approval_enabled() bool;
  evaluate_gate() loses the config-driven 'blocked' path (blocked now only comes
  from an interactive user denial).
- tools/memory_tool.py, tools/skill_manager_tool.py: comment + behaviour follow.
- hermes_cli/config.py: memory/skills write_mode → write_approval (False);
  _config_version 28→29 with a 28→29 migration that renames any persisted
  write_mode (approve→true, on/off/unset→false) and drops the old key.
- slash commands: '/memory|/skills mode <on|off|approve>' → 'approval <on|off>'
  ('mode' kept as a back-compat alias); set_mode_fn callback now takes a bool.
- write_approval_commands.py, cli_commands_mixin.py, gateway/slash_commands.py,
  commands.py: handlers + registry args/subcommands updated.
- docs + tests rewritten for the boolean model; added migration tests.
2026-06-09 23:21:14 -07:00
synapsesx 9ca9697342 fix(gateway): return tuple from voice transcription on placeholder caption (#42090)
## What does this PR do?

The voice-during-active-run feature (#41984) changed
`_enrich_message_with_transcription` so that it returns a
`(enriched_text, successful_transcripts)` tuple instead of a bare string,
which lets callers echo the raw transcript back to the user. The signature
and every other return path were updated to match, but one branch was
missed: when a successfully transcribed clip arrives with the Discord
"empty content" placeholder as its caption, the method still returned the
prefix string on its own. All four call sites unpack the result with
`text, transcripts = await self._enrich_message_with_transcription(...)`,
so that path raised `ValueError: too many values to unpack (expected 2)`
and the inbound voice message was dropped instead of reaching the agent.

This is a real user-facing path rather than a corner case: a Discord voice
note sent without a caption is delivered as exactly that placeholder, so a
captionless voice message that transcribed correctly would crash the
handler precisely when transcription had worked. The fix returns the
proper tuple from that branch so the placeholder is still stripped while
the transcripts continue to flow back to the caller for the echo.

## Related Issue

N/A

## Type of Change

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

## Changes Made

- `gateway/run.py`: in `_enrich_message_with_transcription`, return
  `(prefix, successful_transcripts)` instead of a bare `prefix` from the
  empty-content-placeholder branch, so the contract matches the signature
  and the other return paths.
- `tests/gateway/test_stt_config.py`: add
  `test_enrich_message_with_transcription_returns_tuple_for_empty_content_placeholder`,
  which drives a successful transcription with the placeholder caption and
  asserts the placeholder is stripped while the transcript is still returned.

## How to Test

1. Check out `main` and run the new test — it fails with
   `ValueError: too many values to unpack (expected 2)`, reproducing the
   crash a captionless Discord voice note would trigger.
2. Apply this change and re-run
   `pytest tests/gateway/test_stt_config.py -q` — all tests pass.
3. `ruff check gateway/run.py tests/gateway/test_stt_config.py` and
   `python scripts/check-windows-footguns.py gateway/run.py
   tests/gateway/test_stt_config.py` both pass.

## Checklist

### Code

- [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md)
- [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/ -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)

### Documentation & Housekeeping

- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
2026-06-09 23:16:23 -07:00
Ben Barclay 63a421d4c0 fix(dashboard): _require_token endpoints all 401 behind the OAuth gate (#42578)
* fix(dashboard): let _require_token endpoints work behind the OAuth gate

In gated/OAuth mode (non-loopback bind without --insecure) the dashboard
authenticates the SPA via a session cookie and deliberately does NOT inject
the legacy ephemeral _SESSION_TOKEN into index.html. gated_auth_middleware
verifies the cookie and attaches request.state.session before any non-public
/api/ route runs; the legacy auth_middleware short-circuits in this mode too.

But several handlers call _require_token() directly, which only validated the
(absent) _SESSION_TOKEN header. So every cookie-authenticated request to those
endpoints 401'd — making plugin install/enable/disable, /api/dashboard/plugins/hub,
and the other _require_token routes permanently unreachable behind the gate.
In the UI this surfaced as a 401: {"detail":"Unauthorized"} popup on plugin
install for any publicly-bound (e.g. Fly-hosted NAS) dashboard.

Fix: _require_token now defers to the active gate. When auth_required is True it
accepts the request iff the gate attached a verified session (and 401s otherwise);
loopback/--insecure behavior is unchanged (still validates the session token).

Adds two regression tests driving the full in-process stub OAuth round trip:
the install endpoint must NOT 401 a logged-in request, and must still 401 with
no cookie. Verified the accept-test fails on the pre-fix code.

* test(dashboard): cover the whole _require_token route class under the gate

The install popup was one symptom of a class-wide bug: all 14 endpoints that
call _require_token directly (API-key reveal, provider validation, the
OAuth-provider connect/disconnect flow, and plugin enable/disable/update/
delete/visibility/providers) 401'd cookie-authenticated requests in gated mode.

Add a parametrized test hitting a representative spread (plugins/hub, env/reveal,
providers/validate, an oauth provider route, agent-plugin enable) asserting a
logged-in caller is never 401'd — proving the fix covers the class, not just
agent-plugins/install.
2026-06-09 22:57:49 -07:00
Ben Barclayandblut-agent e4a1b35a39 fix(config): preserve original .env file mode instead of unconditionally tightening to 0600 (#33699)
`save_env_value()` captures the original .env file mode (e.g. 0640 for Docker
volume mounts) and restores it via `os.chmod` — but then unconditionally calls
`_secure_file(env_path)` on the next line, which re-tightens the mode to 0600
and defeats the entire preservation logic. The intent (preserve when
`original_mode` is captured, secure otherwise) was already in the code but
got short-circuited.

Move `_secure_file()` into the `else` branch so it only runs when no original
mode was captured — fresh `.env` files written for the first time still get
the 0600 hardening treatment, but operator-set modes survive subsequent writes.

Salvages #31518 by @blut-agent (config.py portion only). Their PR also bundled
unrelated lowercase-lookup changes in `hermes_cli/commands.py`; this salvage
takes only the focused config fix. The commands.py changes are reasonable on
their own merits but belong in a separate PR.

Co-authored-by: blut-agent <278569635+blut-agent@users.noreply.github.com>
2026-06-10 15:42:16 +10:00
214 changed files with 9650 additions and 3717 deletions
+42
View File
@@ -63,3 +63,45 @@ data/
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
hermes-config/
runtime/
# ---------- Not needed inside the Docker image ----------
# Desktop app source (Tauri/Electron); never installed in the container
apps/
# Test suite — not shipped in production images
tests/
# Documentation site (Docusaurus) and supplementary docs
website/
docs/
# Assets only used by the GitHub README
assets/
infographic/
# Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them)
plugins/hermes-achievements/docs/
# Nix / Homebrew / AUR packaging metadata — irrelevant to Docker
nix/
flake.nix
flake.lock
packaging/
# Design and planning documents
plans/
.plans/
# ACP registry manifest (icon + agent.json) — not consumed at runtime
acp_registry/
# Repo-level dotfiles that are git-only or dev-tooling config
.env.example
.envrc
.gitattributes
.hadolint.yaml
.mailmap
# Top-level LICENSE (not matched by *.md); not needed inside the container
LICENSE
+6
View File
@@ -114,6 +114,12 @@ docs/superpowers/*
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
# — ignore so `git status` stays clean and update's autostash skips them.
.update-incomplete
.update-incomplete.lock
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/
+16
View File
@@ -26,6 +26,15 @@ reviewing any change:
high. Most new capability should arrive as a CLI command + skill, a
service-gated tool, or a plugin — not as core surface.
## Platform Support
Hermes Agent's platform support is formalized into three tiers. For the full tier breakdown, contribution guidelines, and user-facing migration policies, see the [Platform Support section in CONTRIBUTING.md](./CONTRIBUTING.md#platform-support) and the [Platform Support Reference](https://hermes-agent.nousresearch.com/docs/reference/platform-support).
**Summary for agents:**
- **Explicitly Supported**: Linux (x86_64/arm64), macOS arm64, Windows (x86_64/arm64), Docker. Use `curl | bash`, PowerShell, or Docker installers.
- **Best-Effort**: Termux, AUR, Homebrew, Nix. PRs accepted but won't block releases.
- **Explicitly Unsupported**: macOS x86_64, pip/PyPI packaging, FreeBSD. Do not accept PRs for these.
## Contribution Rubric — What We Want / What We Don't
This is the project's intent layer. Use it two ways:
@@ -123,6 +132,13 @@ conservative at the waist.
without E2E proof, and plugins that touch core files.** Plugins live in their
own directory and work within the ABCs/hooks we provide; if a plugin needs
more, widen the generic plugin surface, don't special-case it in core.
- **Dependency installation via uv helpers, not raw pip.** Hermes manages its
own uv binary. Always use `ensure_uv()` or
`resolve_uv()` from `hermes_cli.managed_uv`, or a dedicated helper like
`_get_pip_cmd()` that returns `[uv_path, "pip"]`. **Do not** write
`[sys.executable, "-m", "pip"]` or `[sys.executable, "-m", "ensurepip"]`
directly in subprocess calls. The helpers guarantee the managed uv path and
only fall back to raw pip in explicitly documented degenerate edge cases.
### Before you call it a bug — verify the premise (and when NOT to close)
+33 -1
View File
@@ -9,7 +9,7 @@ Thank you for contributing to Hermes Agent! This guide covers everything you nee
We value contributions in this order:
1. **Bug fixes** — crashes, incorrect behavior, data loss. Always top priority.
2. **Cross-platform compatibility**macOS, different Linux distros, and WSL2 on Windows. We want Hermes to work everywhere.
2. **Cross-platform compatibility**See the [Platform Support](#platform-support) section below for tier-aware guidelines.
3. **Security hardening** — shell injection, prompt injection, path traversal, privilege escalation. See [Security](#security-considerations).
4. **Performance and robustness** — retry logic, error handling, graceful degradation.
5. **New skills** — but only broadly useful ones. See [Should it be a Skill or a Tool?](#should-it-be-a-skill-or-a-tool)
@@ -18,6 +18,38 @@ We value contributions in this order:
---
## Platform Support
Hermes Agent's platform support is formalized into three tiers. This ensures we can maintain high quality and reliability while still welcoming community contributions.
### Explicitly Supported (Guaranteed)
These platforms are fully supported, tested, and guaranteed to work. We provide first-party installers and prioritize fixes for these environments.
| Platform | Supported Installers |
|----------|----------------------|
| Linux (x86_64 / arm64) | `curl \| bash` installer, Docker image |
| Latest Debian, Ubuntu, Fedora | `curl \| bash` installer |
| Official Docker image | `docker pull` |
| macOS (arm64 / Apple Silicon) | Desktop app installer, `curl \| bash` installer |
| Windows (x86_64 / arm64) | Desktop app installer, PowerShell installer |
### Best-Effort Support
We welcome community PRs for fixes on these platforms, and they generally work, but Nous will not prioritize them. We also do not accept packaging-specific code changes into the core repository for these platforms.
- **Termux / Android**: Community-supported. Best-effort fixes are welcome, but will not block Hermes releases.
- **AUR Packaging**: Community-maintained.
- **Homebrew Packaging**: Deprecated. See the [Platform Support docs](https://hermes-agent.nousresearch.com/docs/reference/platform-support) for migration.
- **Nix Packaging**: The `flake.nix` and NixOS module are maintained in-tree as a primary deployment method. However, niche Nix-specific packaging bugs (e.g., a new dependency failing to build under Nix) are treated as best-effort.
### Explicitly Unsupported
We do not accept PRs attempting to add or restore support for these platforms.
- **macOS (x86_64 / Intel)**: No longer supported.
- **Packaging via pip / PyPI**: Deprecated and discontinued.
- **FreeBSD**: Not supported.
---
## Should it be a Skill or a Tool?
This is the most common question for new contributors. The answer is almost always **skill**.
+20 -9
View File
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
@@ -146,9 +146,9 @@ RUN npm install --prefer-offline --no-audit && \
#
# `uv sync --frozen --no-install-project --extra all --extra messaging`
# installs the deps reachable through the composite `[all]` extra
# (handpicked set intended for the production image), plus gateway
# messaging adapters that should work in the published image without a
# first-boot lazy install. We do NOT use `--all-extras`:
# (handpicked set intended for the production image — excludes `[dev]`),
# plus gateway messaging adapters that should work in the published image
# without a first-boot lazy install. We do NOT use `--all-extras`:
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
# redundancy), none of which belong in the published container.
@@ -164,19 +164,30 @@ RUN npm install --prefer-offline --no-audit && \
# image update and recall/retain then fails with
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
#
# The Matrix gateway's deps ([matrix] extra) are baked in because
# python-olm (transitive via mautrix[encryption]) builds from source on
# Python/image combinations without usable wheels. The Docker image is
# Linux-only, so keeping the native libolm/build-toolchain packages here
# avoids the cross-platform failures that kept [matrix] out of [all]
# while still making Matrix work in the published container. Fixes #30399.
#
# The editable link is created after the source copy below.
COPY pyproject.toml uv.lock ./
RUN touch ./README.md
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix
# ---------- Frontend build (cached independently from Python source) ----------
# Copy only the frontend source trees first so that Python-only changes don't
# invalidate the (relatively slow) web + ui-tui build layer.
COPY web/ web/
COPY ui-tui/ ui-tui/
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
COPY --chown=hermes:hermes . .
# Build browser dashboard and terminal UI assets.
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
# ---------- Permissions ----------
# Make install dir world-readable so any HERMES_UID can read it at runtime.
# The venv needs to be traversable too.
+2 -1
View File
@@ -234,7 +234,8 @@ def main(argv: list[str] | None = None) -> None:
logger.info("Starting hermes-agent ACP adapter")
# Ensure the project root is on sys.path so ``from run_agent import AIAgent`` works
project_root = str(Path(__file__).resolve().parent.parent)
from hermes_constants import get_hermes_source_root
project_root = str(get_hermes_source_root())
if project_root not in sys.path:
sys.path.insert(0, project_root)
+10 -5
View File
@@ -102,7 +102,7 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
from agent.credential_pool import load_pool
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars
from utils import base_url_host_matches, base_url_hostname, model_forces_max_completion_tokens, normalize_proxy_env_vars
logger = logging.getLogger(__name__)
@@ -4300,13 +4300,15 @@ def get_auxiliary_extra_body() -> dict:
return _nous_extra_body() if auxiliary_is_nous else {}
def auxiliary_max_tokens_param(value: int) -> dict:
def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> dict:
"""Return the correct max tokens kwarg for the auxiliary client's provider.
OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer
models (gpt-4o, o-series, gpt-5+) requires 'max_completion_tokens'.
models (gpt-4o, gpt-4.1, gpt-5+, o-series) requires 'max_completion_tokens'.
The Codex adapter translates max_tokens internally, so we use max_tokens
for it as well.
for it as well. Pass ``model`` so third-party OpenAI-compatible endpoints
fronting the newer families are also recognised URL-only detection
misses the case where a custom base URL serves e.g. ``gpt-5.4``.
"""
custom_base = _current_custom_base_url()
or_key = os.getenv("OPENROUTER_API_KEY")
@@ -4316,6 +4318,9 @@ def auxiliary_max_tokens_param(value: int) -> dict:
and _read_nous_auth() is None
and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}):
return {"max_completion_tokens": value}
# ...and for any caller serving a newer OpenAI-family model by name.
if model_forces_max_completion_tokens(model):
return {"max_completion_tokens": value}
return {"max_tokens": value}
+2 -15
View File
@@ -25,7 +25,6 @@ import json
import logging
import os
import re
import tempfile
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -33,6 +32,7 @@ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set
from hermes_constants import get_hermes_home
from tools import skill_usage
from utils import atomic_json_write
logger = logging.getLogger(__name__)
@@ -97,20 +97,7 @@ def load_state() -> Dict[str, Any]:
def save_state(data: Dict[str, Any]) -> None:
path = _state_file()
try:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_state_", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
atomic_json_write(path, data, indent=2, sort_keys=True)
except Exception as e:
logger.debug("Failed to save curator state: %s", e, exc_info=True)
+28
View File
@@ -966,6 +966,34 @@ def _classify_400(
should_fallback=False,
)
# Request-validation errors (unsupported / unknown parameter) MUST be
# checked BEFORE context_overflow. A GPT-5 model rejecting max_tokens
# returns:
# "Unsupported parameter: 'max_tokens' is not supported with this model.
# Use 'max_completion_tokens' instead."
# That string contains the literal substring "max_tokens", which is one of
# the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
# misclassified as context_overflow, routed into the compression loop,
# re-sent with the same bad parameter, and ends in "Cannot compress
# further". These errors are deterministic (every retry gets the identical
# rejection), so classify as a non-retryable format_error and fall back.
#
# NOTE: we deliberately do NOT key off the generic ``invalid_request_error``
# code here — OpenAI stamps that same code on genuine context-overflow 400s,
# so matching it would mis-route real overflows away from compression. The
# unambiguous signals are the explicit "unsupported/unknown parameter"
# message text and the specific parameter-level error codes.
if (
any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS
if p != "invalid_request_error")
or error_code_lower in {"unknown_parameter", "unsupported_parameter"}
):
return result_fn(
FailoverReason.format_error,
retryable=False,
should_fallback=True,
)
# Context overflow from 400
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
+2 -1
View File
@@ -115,7 +115,8 @@ def _locales_dir() -> Path:
)
# agent/i18n.py -> agent/ -> repo root (source checkout, editable install)
source_dir = Path(__file__).resolve().parent.parent / "locales"
from hermes_constants import get_hermes_source_root
source_dir = get_hermes_source_root() / "locales"
if source_dir.is_dir():
return source_dir
+4 -1
View File
@@ -343,8 +343,11 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
pip_target.mkdir(parents=True, exist_ok=True)
try:
logger.info("[install] pip install --target %s %s", pip_target, pkg)
# pip_install() can't be used here — needs --target to install outside
# the venv into a custom staging dir for LSP tool console scripts.
from hermes_cli.managed_uv import get_pip_cmd
proc = subprocess.run(
[sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg],
get_pip_cmd() + ["install", "--target", str(pip_target), "--quiet", pkg],
check=False,
capture_output=True,
text=True,
+11
View File
@@ -1838,6 +1838,17 @@ def get_model_context_length(
from agent.models_dev import lookup_models_dev_context
ctx = lookup_models_dev_context(effective_provider, model)
if ctx:
# MiniMax M3: models.dev reports 512K but actual context is 1M.
# Prefer hardcoded catalog over stale probe value.
if _model_name_suggests_minimax_m3(model):
catalog = DEFAULT_CONTEXT_LENGTHS.get("minimax-m3")
if catalog and ctx < catalog:
logger.info(
"Rejecting models.dev context=%s for %r "
"(MiniMax-M3 underreport); using hardcoded default %s",
ctx, model, f"{catalog:,}",
)
ctx = catalog
return ctx
# 6. OpenRouter live API metadata — provider-unaware fallback.
+3
View File
@@ -13,6 +13,7 @@ DEFAULT_PRICING = {"input": 0.0, "output": 0.0}
_ZERO = Decimal("0")
_ONE_MILLION = Decimal("1000000")
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
CostStatus = Literal["actual", "estimated", "included", "unknown"]
CostSource = Literal[
@@ -570,6 +571,8 @@ def resolve_billing_route(
return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"):
return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api")
if provider_name == "nous" or base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
if provider_name == "anthropic":
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name == "openai":
+11 -2
View File
@@ -40,6 +40,15 @@ const path = require('node:path')
const https = require('node:https')
const { spawn } = require('node:child_process')
const IS_WINDOWS = process.platform === 'win32'
function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
// Stages flagged needs_user_input=true in the manifest are skipped by the
@@ -284,7 +293,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
const child = spawn(ps, fullArgs, {
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
@@ -292,7 +301,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
})
}))
let stdout = ''
let stderr = ''
+22 -15
View File
@@ -107,6 +107,13 @@ const IS_WINDOWS = process.platform === 'win32'
const IS_WSL = isWslEnvironment()
const APP_ROOT = app.getAppPath()
function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
// compositor flicker — accelerated layers can't be presented cleanly over the
// wire, so the window flashes during scroll/streaming/animation. Local
@@ -1106,7 +1113,7 @@ function findSystemPython() {
const out = execFileSync(
'reg',
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
)
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
@@ -1142,10 +1149,10 @@ function findSystemPython() {
if (pyExe) {
for (const version of SUPPORTED_VERSIONS) {
try {
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
}))
const candidate = out.trim()
if (candidate && fileExists(candidate)) return candidate
} catch {
@@ -1280,11 +1287,11 @@ function resolveUpdateRoot() {
function runGit(args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
cwd: options.cwd,
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
stdio: ['ignore', 'pipe', 'pipe']
})
}))
let stdout = ''
let stderr = ''
@@ -1494,7 +1501,7 @@ function forceKillProcessTree(pid) {
if (!IS_WINDOWS) return
if (!Number.isInteger(pid) || pid <= 0) return
try {
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' }))
} catch {
// Already gone, or no permission — best effort; the unlock wait below is
// the real gate.
@@ -1680,11 +1687,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
return new Promise(resolve => {
let child
try {
child = spawn(command, args, {
child = spawn(command, args, hiddenWindowsChildOptions({
cwd,
env: { ...process.env, ...(env || {}) },
stdio: ['ignore', 'pipe', 'pipe']
})
}))
} catch (err) {
resolve({ code: 1, error: err.message })
return
@@ -2671,7 +2678,7 @@ function fetchHtmlTitleWithCurl(rawUrl) {
'--raw',
url
]
const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] }))
const chunks = []
let bytes = 0
@@ -4491,7 +4498,7 @@ async function spawnPoolBackend(profile, entry) {
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
const child = spawn(backend.command, backend.args, {
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
@@ -4509,7 +4516,7 @@ async function spawnPoolBackend(profile, entry) {
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
})
}))
entry.process = child
entry.port = port
entry.token = token
@@ -4691,7 +4698,7 @@ async function startHermes() {
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
hermesProcess = spawn(backend.command, backend.args, {
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
@@ -4714,7 +4721,7 @@ async function startHermes() {
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
})
}))
hermesProcess.stdout.on('data', rememberLog)
hermesProcess.stderr.on('data', rememberLog)
@@ -5986,11 +5993,11 @@ async function getUninstallSummary() {
resolve(value)
}
try {
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
cwd: agentRoot,
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'ignore']
})
}))
child.stdout.on('data', chunk => {
stdout += chunk.toString()
})
@@ -0,0 +1,54 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
}
function requireHiddenChildOptions(source, needle) {
const index = source.indexOf(needle)
assert.notEqual(index, -1, `missing call site: ${needle}`)
const snippet = source.slice(index, index + 700)
assert.match(
snippet,
/hiddenWindowsChildOptions\(/,
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
)
}
test('desktop background child processes opt into hidden Windows consoles', () => {
const source = readElectronFile('main.cjs')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
requireHiddenChildOptions(source, 'execFileSync(pyExe')
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
requireHiddenChildOptions(source, "execFileSync('taskkill'")
requireHiddenChildOptions(source, 'spawn(command, args')
requireHiddenChildOptions(source, "spawn('curl'")
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
})
test('intentional or interactive desktop child processes stay documented', () => {
const source = readElectronFile('main.cjs')
assert.match(source, /windowsHide: false/)
assert.match(source, /nodePty\.spawn\(command, args/)
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
})
test('bootstrap PowerShell runner hides Windows console children', () => {
const source = readElectronFile('bootstrap-runner.cjs')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
})
+1 -1
View File
@@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -722,8 +722,14 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
// edit composer render the same bubble surface (rounded glass card);
// they only differ in border weight, cursor, and padding-right (the
// read-only view reserves room for the restore icon).
//
// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the
// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves
// drag regions at the compositor level — z-index and pointer-events don't help —
// so without the carve-out, clicking a stuck bubble drags the window instead of
// opening the edit composer.
const USER_BUBBLE_BASE_CLASS =
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
@@ -13,9 +13,9 @@ import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
import { ToolIcon } from '@/components/ui/tool-icon'
import { useI18n } from '@/i18n'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
@@ -136,7 +136,7 @@ function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string
const node = status ? (
statusGlyph(status, copy)
) : icon ? (
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
<ToolIcon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
) : null
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
@@ -0,0 +1,141 @@
import { ExportedMessageRepository } from '@assistant-ui/core/internal'
// Clicking a user bubble must open the inline edit composer — through the
// app's incremental external-store runtime (which reimplements capability
// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.
//
// Note: this covers the React/runtime wiring only. The Electron-level failure
// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky
// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
// carve-out in thread.tsx.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { Thread } from './thread'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'edit me please' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit.
function IncrementalHarness({ onEdit }: { onEdit: () => Promise<void> }) {
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: repository,
isRunning: false,
setMessages: () => {},
onNew: async () => {},
onEdit,
onCancel: async () => {},
onReload: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
// Control: stock external store runtime.
function StockHarness({ onEdit }: { onEdit: () => Promise<void> }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage()],
isRunning: false,
onNew: async () => {},
onEdit
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('click-to-edit user message', () => {
it('opens the edit composer with the incremental runtime', async () => {
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
it('opens the edit composer with the stock runtime', async () => {
const { container } = render(<StockHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
})
@@ -0,0 +1,65 @@
import type * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
// Solid (filled) glyphs for in-thread tool rows. Codicons are an outline icon
// *font*, so an outline glyph has no separate fillable region — a filled look
// can't be derived from it (stroke-thickening just bolds the outline). To get
// the Cursor-style filled tool icons we render dedicated solid SVG paths,
// keyed by the same names used in `TOOL_META` (tool-fallback-model.ts).
//
// Paths are Phosphor Icons (MIT) "fill" weight, 256×256 viewBox. Inlining the
// path data mirrors the existing precedent in `directive-text.tsx`.
const TOOL_ICON_PATHS: Record<string, string> = {
diff: 'M118.18,213.08c-.11.14-.24.27-.36.4l-.16.18-.17.15a4.83,4.83,0,0,1-.42.37,3.92,3.92,0,0,1-.32.25l-.3.22-.38.23a2.91,2.91,0,0,1-.3.17l-.37.19-.34.15-.36.13a2.84,2.84,0,0,1-.38.13l-.36.1c-.14,0-.26.07-.4.09l-.42.07-.35.05a7,7,0,0,1-.79,0H64a8,8,0,0,1,0-16H92.69L55,162.34a23.85,23.85,0,0,1-7-17V95a32,32,0,1,1,16,0v50.38A8,8,0,0,0,66.34,151L104,188.69V160a8,8,0,0,1,16,0v48a7,7,0,0,1,0,.8c0,.11,0,.21,0,.32s0,.3-.07.46a2.83,2.83,0,0,1-.09.37c0,.13-.06.26-.1.39s-.08.23-.12.35l-.14.39-.15.31c-.06.13-.12.27-.19.4s-.11.18-.16.28l-.24.39-.21.28ZM208,161V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H143.82l-.6,0c-.14,0-.28,0-.41.06l-.37,0-.43.11-.33.08-.4.14-.34.13-.35.16-.36.18a3.14,3.14,0,0,0-.31.18c-.12.07-.25.14-.36.22a3.55,3.55,0,0,0-.31.23,3.81,3.81,0,0,0-.32.24c-.15.12-.28.24-.42.37l-.17.15-.16.18c-.12.13-.25.26-.36.4l-.26.35-.21.28-.24.39c-.05.1-.11.19-.16.28s-.13.27-.19.4l-.15.31-.14.39c0,.12-.09.23-.12.35s-.07.26-.1.39a2.83,2.83,0,0,0-.09.37c0,.16,0,.31-.07.46s0,.21-.05.32a7,7,0,0,0,0,.8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Z',
edit: 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM192,108.68,147.31,64l24-24L216,84.68Z',
eye: 'M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z',
file: 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM152,88V44l44,44Z',
'file-media':
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM156,88a12,12,0,1,1-12,12A12,12,0,0,1,156,88Zm60,112H40V160.69l46.34-46.35a8,8,0,0,1,11.32,0h0L165,181.66a8,8,0,0,0,11.32-11.32l-17.66-17.65L173,138.34a8,8,0,0,1,11.31,0L216,170.07V200Z',
files:
'M213.66,66.34l-40-40A8,8,0,0,0,168,24H88A16,16,0,0,0,72,40V56H56A16,16,0,0,0,40,72V216a16,16,0,0,0,16,16H168a16,16,0,0,0,16-16V200h16a16,16,0,0,0,16-16V72A8,8,0,0,0,213.66,66.34ZM136,192H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm0-32H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm64,24H184V104a8,8,0,0,0-2.34-5.66l-40-40A8,8,0,0,0,136,56H88V40h76.69L200,75.31Z',
globe:
'M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm78.36,64H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM216,128a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM128,43a115.27,115.27,0,0,1,26,45H102A115.11,115.11,0,0,1,128,43ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48Zm50.35,61.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z',
question:
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,168a12,12,0,1,1,12-12A12,12,0,0,1,128,192Zm8-48.72V144a8,8,0,0,1-16,0v-8a8,8,0,0,1,8-8c13.23,0,24-9,24-20s-10.77-20-24-20-24,9-24,20v4a8,8,0,0,1-16,0v-4c0-19.85,17.94-36,40-36s40,16.15,40,36C168,125.38,154.24,139.93,136,143.28Z',
search:
'M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z',
terminal:
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-91,94.25-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32a8,8,0,0,1,0,12.5ZM176,168H136a8,8,0,0,1,0-16h40a8,8,0,0,1,0,16Z',
tools:
'M232,96a72,72,0,0,1-100.94,66L79,222.22c-.12.14-.26.29-.39.42a32,32,0,0,1-45.26-45.26c.14-.13.28-.27.43-.39L94,124.94a72.07,72.07,0,0,1,83.54-98.78,8,8,0,0,1,3.93,13.19L144,80l5.66,26.35L176,112l40.65-37.52a8,8,0,0,1,13.19,3.93A72.6,72.6,0,0,1,232,96Z',
watch:
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm56,112H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48a8,8,0,0,1,0,16Z'
}
export interface ToolIconProps {
className?: string
name: string
size?: number | string
}
/** Filled tool glyph. Falls back to the outline codicon font for any name not
* covered by the solid set so new tools still render an icon. */
export function ToolIcon({ className, name, size = '0.875rem' }: ToolIconProps) {
const path = TOOL_ICON_PATHS[name]
if (!path) {
return <Codicon className={className} name={name} size={size} />
}
const dimension: React.CSSProperties = { height: size, width: size }
return (
<svg
aria-hidden="true"
className={cn('shrink-0', className)}
fill="currentColor"
style={dimension}
viewBox="0 0 256 256"
>
<path d={path} />
</svg>
)
}
+24 -1
View File
@@ -415,7 +415,8 @@ prompt_caching:
# Auxiliary Models (Advanced — Experimental)
# =============================================================================
# Hermes uses lightweight "auxiliary" models for side tasks: image analysis,
# browser screenshot analysis, web page summarization, and context compression.
# browser screenshot analysis, web page summarization, TTS audio-tag insertion,
# and context compression.
#
# By default these use Gemini Flash via OpenRouter or Nous Portal and are
# auto-detected from your credentials. You do NOT need to change anything
@@ -460,6 +461,12 @@ prompt_caching:
# provider: "auto"
# model: ""
#
# # Gemini 3.1 TTS hidden audio-tag insertion
# tts_audio_tags:
# provider: "auto" # empty model = your main chat model
# model: ""
# timeout: 30
#
# # Session search — summarizes matching past sessions
# session_search:
# provider: "auto"
@@ -835,6 +842,22 @@ platform_toolsets:
# max_tool_rounds: 5 # tool loop limit (0 = disable)
# log_level: "info" # audit verbosity
# =============================================================================
# Text-to-Speech
# =============================================================================
# TTS defaults to Edge TTS unless changed in ~/.hermes/config.yaml.
# Gemini TTS supports persona/director prompt files, and Gemini 3.1 Flash TTS
# can use a hidden auxiliary rewrite pass to insert expressive square-bracket
# audio tags into the TTS script without showing tags in chat.
#
# tts:
# provider: "gemini"
# gemini:
# model: "gemini-3.1-flash-tts-preview"
# voice: "Kore"
# audio_tags: false
# persona_prompt_file: "" # e.g. ~/.hermes/tts/radio-host.md
# =============================================================================
# Voice Transcription (Speech-to-Text)
# =============================================================================
+55 -3
View File
@@ -6516,6 +6516,47 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
}
self._invalidate(min_interval=0.0)
def _confirm_expensive_model_switch(self, result) -> bool:
"""Ask for explicit confirmation before applying costly model switches."""
if not getattr(result, "success", False):
return True
try:
from hermes_cli.model_cost_guard import expensive_model_warning
warning = expensive_model_warning(
result.new_model,
provider=result.target_provider,
base_url=result.base_url or self.base_url or "",
api_key=result.api_key or self.api_key or "",
model_info=result.model_info,
)
except Exception:
warning = None
if warning is None:
return True
choices = [
("once", "Switch anyway", "Use this model for the current Hermes session."),
("cancel", "Cancel", "Keep the current model."),
]
raw = self._prompt_text_input_modal(
title="!!! Expensive Model Warning !!!",
detail=warning.message,
choices=choices,
timeout=120,
)
choice = self._normalize_slash_confirm_choice(raw, choices)
return choice == "once"
def _confirm_and_apply_model_switch_result(self, result, persist_global: bool) -> None:
try:
if result.success and not self._confirm_expensive_model_switch(result):
_cprint(" Model switch cancelled.")
return
self._apply_model_switch_result(result, persist_global)
except Exception as exc:
_cprint(f" ✗ Model selection failed: {exc}")
def _close_model_picker(self) -> None:
self._model_picker_state = None
self._restore_modal_input_snapshot()
@@ -6692,7 +6733,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
custom_providers=state.get("custom_provs"),
)
self._close_model_picker()
self._apply_model_switch_result(result, persist_global)
if getattr(self, "_app", None):
threading.Thread(
target=self._confirm_and_apply_model_switch_result,
args=(result, persist_global),
daemon=True,
).start()
else:
self._confirm_and_apply_model_switch_result(result, persist_global)
return
self._close_model_picker()
@@ -6793,6 +6841,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
_cprint(f"{result.error_message}")
return
if not self._confirm_expensive_model_switch(result):
_cprint(" Model switch cancelled.")
return
# Apply to CLI state.
# Update requested_provider so _ensure_runtime_credentials() doesn't
# overwrite the switch on the next turn (it re-resolves from this).
@@ -8829,7 +8881,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
)
raise RuntimeError(
"Voice mode requires sounddevice and numpy.\n"
f"Install with: {sys.executable} -m pip install sounddevice numpy"
f"Install with: uv pip install sounddevice numpy"
)
if not reqs.get("stt_available", reqs.get("stt_key_set")):
raise RuntimeError(
@@ -9133,7 +9185,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
_cprint(f" {_DIM}Then install/update the Termux:API Android app for microphone capture{_RST}")
_cprint(f" {_BOLD}Option 2: pkg install python-numpy portaudio && python -m pip install sounddevice{_RST}")
else:
_cprint(f"\n {_BOLD}Install: {sys.executable} -m pip install {' '.join(reqs['missing_packages'])}{_RST}")
_cprint(f"\n {_BOLD}Install: uv pip install {' '.join(reqs['missing_packages'])}{_RST}")
return
with self._voice_lock:
+1
View File
@@ -36,6 +36,7 @@ from typing import List, Optional
# Add parent directory to path for imports BEFORE repo-level imports.
# Without this, standalone invocations (e.g. after `hermes update` reloads
# the module) fail with ModuleNotFoundError for hermes_time et al.
# Bootstrap sys.path before imports — cannot use get_hermes_source_root() here yet.
sys.path.insert(0, str(Path(__file__).parent.parent))
from hermes_constants import get_hermes_home
+8
View File
@@ -207,6 +207,14 @@ class GatewayAuthorizationMixin:
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in {"true", "1", "yes"}:
return True
# Adapter-verified role auth: the Discord adapter already confirmed the
# user holds a role in DISCORD_ALLOWED_ROLES before dispatching the message.
# Compare with ``is True`` so the real bool field authorizes while a
# MagicMock source (test fixtures using ``object.__new__`` runners with
# mock sources) does not auto-truthy through this gate (see pitfall #13).
if getattr(source, "role_authorized", False) is True:
return True
if getattr(source, "is_bot", False):
allow_bots_var = platform_allow_bots_map.get(source.platform)
if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in {"mentions", "all"}:
+2 -1
View File
@@ -213,7 +213,8 @@ class Platform(Enum):
"""Return names of bundled platform plugins under ``plugins/platforms/``."""
names: set = set()
try:
platforms_dir = Path(__file__).parent.parent / "plugins" / "platforms"
from hermes_constants import get_hermes_source_root
platforms_dir = get_hermes_source_root() / "plugins" / "platforms"
if platforms_dir.is_dir():
for child in platforms_dir.iterdir():
if (
+29 -4
View File
@@ -33,6 +33,7 @@ _AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a', '.flac'})
# delivered as a regular document.
_TELEGRAM_AUDIO_ATTACHMENT_EXTS = frozenset({'.mp3', '.m4a'})
_TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'})
_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS = 30.0
def _platform_name(platform) -> str:
@@ -1803,6 +1804,18 @@ class BasePlatformAdapter(ABC):
# preview (see gateway/run.py progress_callback).
supports_code_blocks: bool = False
# The command prefix users can always TYPE on this platform to reach
# Hermes commands. Default "/" (most platforms deliver "/approve" etc.
# as plain message text). Platforms where typing a leading "/" is
# intercepted or restricted by the client (Slack blocks native slash
# commands inside threads; Matrix clients reserve "/" for client-local
# commands) ship a "!" alias rewrite in their adapter and set this to
# "!" so user-facing instruction text ("Reply `!approve` ...") tells
# users the form that actually works everywhere. Capability flag —
# shared prompt builders read it via getattr(adapter,
# "typed_command_prefix", "/"); no per-platform branching at call sites.
typed_command_prefix: str = "/"
def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
@@ -4462,6 +4475,15 @@ class BasePlatformAdapter(ABC):
except Exception:
pass # Last resort — don't let error reporting crash the handler
finally:
# Stop typing before any deferred callback work. Post-delivery
# callbacks may perform platform I/O; a stuck callback must not
# leave the typing refresh task running indefinitely.
await _stop_typing_task()
try:
if hasattr(self, "stop_typing"):
await self.stop_typing(event.source.chat_id)
except Exception:
pass
# Fire any one-shot post-delivery callback registered for this
# session (e.g. deferred background-review notifications).
#
@@ -4489,11 +4511,12 @@ class BasePlatformAdapter(ABC):
try:
_post_result = _post_cb()
if inspect.isawaitable(_post_result):
await _post_result
except Exception:
await asyncio.wait_for(
_post_result,
timeout=_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS,
)
except (asyncio.TimeoutError, Exception):
pass
# Stop typing indicator
await _stop_typing_task()
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
# that may have been recreated by _keep_typing after the last stop_typing()
try:
@@ -4651,6 +4674,7 @@ class BasePlatformAdapter(ABC):
guild_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
message_id: Optional[str] = None,
role_authorized: bool = False,
) -> SessionSource:
"""Helper to build a SessionSource for this platform."""
# Normalize empty topic to None
@@ -4671,6 +4695,7 @@ class BasePlatformAdapter(ABC):
guild_id=str(guild_id) if guild_id else None,
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
message_id=str(message_id) if message_id else None,
role_authorized=role_authorized,
)
@abstractmethod
+9 -4
View File
@@ -422,6 +422,11 @@ class MatrixAdapter(BasePlatformAdapter):
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
# Matrix clients commonly reserve typed "/" for client-local commands;
# the adapter accepts "!command" as the alias that always reaches Hermes
# (see _normalize_matrix_bang_command), so instruction text shows "!".
typed_command_prefix = "!"
# Threshold for detecting Matrix client-side message splits.
# When a chunk is near the ~4000-char practical limit, a continuation
# is almost certain.
@@ -1350,11 +1355,11 @@ class MatrixAdapter(BasePlatformAdapter):
"⚠️ **Dangerous command requires approval**\n"
f"```\n{cmd_preview}\n```\n"
f"Reason: {description}\n\n"
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
"Reply `!approve` to execute, `!approve session` to approve this pattern for the session, "
"`!approve always` to approve permanently, or `!deny` to cancel.\n\n"
"You can also click the reaction to approve:\n"
"✅ = /approve\n"
"❎ = /deny"
"✅ = approve\n"
"❎ = deny"
)
result = await self.send(chat_id, text, metadata=metadata)
+25 -8
View File
@@ -318,6 +318,11 @@ class SlackAdapter(BasePlatformAdapter):
MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin
supports_code_blocks = True # Slack mrkdwn renders fenced code blocks
# Slack blocks typed native slash commands inside threads ("/approve is
# not supported in threads. Sorry!"). The adapter rewrites a leading
# "!" to "/" for known commands (see _handle_slack_message), so "!" is
# the prefix that works everywhere — instruction text must show it.
typed_command_prefix = "!"
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.SLACK)
@@ -2692,19 +2697,26 @@ class SlackAdapter(BasePlatformAdapter):
return SendResult(success=False, error="Not connected")
try:
cmd_preview = command[:2900] + "..." if len(command) > 2900 else command
thread_ts = self._resolve_thread_ts(None, metadata)
# Slack hard-caps a section block's text at 3000 chars; an
# oversized block fails the whole send with ``invalid_blocks``
# and the gateway falls back to the plain-text prompt (no
# buttons). execute_code approvals embed the entire script in
# ``command``, so budget the preview against the fixed parts
# instead of a flat truncation that overflows once the header +
# reason are added.
header = ":warning: *Command Approval Required*\n"
reason = f"Reason: {description[:500]}"
budget = 3000 - len(header) - len(reason) - len("``````\n") - len("...")
cmd_preview = command[:budget] + "..." if len(command) > budget else command
blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f":warning: *Command Approval Required*\n"
f"```{cmd_preview}```\n"
f"Reason: {description}"
),
"text": f"{header}```{cmd_preview}```\n{reason}",
},
},
{
@@ -2772,8 +2784,13 @@ class SlackAdapter(BasePlatformAdapter):
return SendResult(success=False, error="Not connected")
try:
body = message[:2900] + "..." if len(message) > 2900 else message
thread_ts = self._resolve_thread_ts(None, metadata)
# Same 3000-char section-block cap as send_exec_approval: budget
# the body against the rendered title so the wrapper never pushes
# the block over the limit (overflow → invalid_blocks → no buttons).
_title = (title or "Confirm")[:150]
budget = 3000 - len(f"*{_title}*\n\n") - len("...")
body = message[:budget] + "..." if len(message) > budget else message
# Encode session_key and confirm_id into the button value so the
# callback handler can resolve without extra bookkeeping.
value = f"{session_key}|{confirm_id}"
@@ -2783,7 +2800,7 @@ class SlackAdapter(BasePlatformAdapter):
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*{title or 'Confirm'}*\n\n{body}",
"text": f"*{_title}*\n\n{body}",
},
},
{
+86 -3
View File
@@ -3030,7 +3030,7 @@ class TelegramAdapter(BasePlatformAdapter):
async def _handle_model_picker_callback(
self, query, data: str, chat_id: str
) -> None:
"""Handle model picker inline keyboard callbacks (mp:/mm:/mb:/mx:/mg:)."""
"""Handle model picker inline keyboard callbacks (mp:/mm:/mc:/mb:/mx:/mg:)."""
state = self._model_picker_state.get(chat_id)
if not state:
await query.answer(text="Picker expired — use /model again.")
@@ -3115,6 +3115,55 @@ class TelegramAdapter(BasePlatformAdapter):
)
await query.answer()
elif data.startswith("mc:"):
# --- Expensive model confirmed: perform the switch ---
try:
idx = int(data[3:])
except ValueError:
await query.answer(text="Invalid selection.")
return
model_list = state.get("model_list", [])
if idx < 0 or idx >= len(model_list):
await query.answer(text="Invalid model index.")
return
model_id = model_list[idx]
provider_slug = state.get("selected_provider", "")
callback = state.get("on_model_selected")
if not callback:
await query.answer(text="Picker expired.")
return
switch_failed = False
try:
result_text = await callback(chat_id, model_id, provider_slug)
except Exception as exc:
logger.error("Model picker switch failed: %s", exc)
result_text = f"Error switching model: {exc}"
switch_failed = True
try:
await query.edit_message_text(
text=self.format_message(result_text),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=None,
)
except Exception:
try:
await query.edit_message_text(
text=result_text,
parse_mode=None,
reply_markup=None,
)
except Exception:
pass
await query.answer(
text="Switch failed." if switch_failed else "Model switched!"
)
self._model_picker_state.pop(chat_id, None)
elif data.startswith("mm:"):
# --- Model selected: perform the switch ---
try:
@@ -3136,11 +3185,43 @@ class TelegramAdapter(BasePlatformAdapter):
await query.answer(text="Picker expired.")
return
try:
from hermes_cli.model_cost_guard import expensive_model_warning
# Pricing lookup can hit models.dev / a /models endpoint on a
# cache miss — keep it off the event loop.
warning = await asyncio.to_thread(
expensive_model_warning,
model_id,
provider=provider_slug,
)
except Exception:
warning = None
if warning is not None:
keyboard = InlineKeyboardMarkup([
[InlineKeyboardButton("Switch anyway", callback_data=f"mc:{idx}")],
[
InlineKeyboardButton("◀ Back", callback_data="mb"),
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
],
])
await query.edit_message_text(
text=self.format_message(
f"⚠ *Expensive Model Warning*\n\n{warning.message}"
),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
)
await query.answer(text="Confirm expensive model")
return
switch_failed = False
try:
result_text = await callback(chat_id, model_id, provider_slug)
except Exception as exc:
logger.error("Model picker switch failed: %s", exc)
result_text = f"Error switching model: {exc}"
switch_failed = True
# Edit message to show confirmation, remove buttons
try:
@@ -3159,7 +3240,9 @@ class TelegramAdapter(BasePlatformAdapter):
)
except Exception:
pass
await query.answer(text="Model switched!")
await query.answer(
text="Switch failed." if switch_failed else "Model switched!"
)
# Clean up state
self._model_picker_state.pop(chat_id, None)
@@ -3260,7 +3343,7 @@ class TelegramAdapter(BasePlatformAdapter):
query_user_name = getattr(query.from_user, "first_name", None)
# --- Model picker callbacks ---
if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")):
if data.startswith(("mp:", "mpg:", "mm:", "mc:", "mb", "mx", "mg:")):
chat_id = str(query.message.chat_id) if query.message else None
if chat_id:
await self._handle_model_picker_callback(query, data, chat_id)
+36 -13
View File
@@ -895,7 +895,8 @@ os.environ["_HERMES_GATEWAY"] = "1"
_ensure_ssl_certs()
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from hermes_constants import get_hermes_source_root
sys.path.insert(0, str(get_hermes_source_root()))
# Resolve Hermes home directory (respects HERMES_HOME override)
from hermes_constants import get_hermes_home
@@ -1551,7 +1552,8 @@ def _check_unavailable_skill(command_name: str) -> str | None:
# Check optional skills (shipped with repo but not installed)
from hermes_constants import get_optional_skills_dir
repo_root = Path(__file__).resolve().parent.parent
from hermes_constants import get_hermes_source_root
repo_root = get_hermes_source_root()
optional_dir = get_optional_skills_dir(repo_root / "optional-skills")
if optional_dir.exists():
for skill_md in optional_dir.rglob("SKILL.md"):
@@ -6085,7 +6087,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
elif platform == Platform.SLACK:
from gateway.platforms.slack import SlackAdapter, check_slack_requirements
if not check_slack_requirements():
logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'")
logger.warning("Slack: slack-bolt not installed. Run: uv pip install -e '.[slack]' (from the hermes-agent checkout)")
return None
return SlackAdapter(config)
@@ -6473,6 +6475,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_tool_approval_live = False
if _pending_confirm and not _tool_approval_live:
_raw_reply = (event.text or "").strip()
# Accept bang-prefixed replies (`!always`, `!cancel`) verbatim.
# Slack/Matrix instruction text shows the `!` prefix (typed `/`
# is blocked in Slack threads), but the adapters only rewrite
# `!<known-command>` — `always`/`cancel` are confirm keywords,
# not registered commands, so the `!` survives to here.
_norm_reply = _raw_reply.lstrip("!/").lower()
_cmd_reply = event.get_command()
_confirm_choice = None
if _cmd_reply in {"approve", "yes", "ok", "confirm"}:
@@ -6481,11 +6489,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_confirm_choice = "always"
elif _cmd_reply in {"cancel", "no", "deny", "nevermind"}:
_confirm_choice = "cancel"
elif _raw_reply.lower() in {"approve", "approve once", "once"}:
elif _norm_reply in {"approve", "approve once", "once"}:
_confirm_choice = "once"
elif _raw_reply.lower() in {"always", "always approve"}:
elif _norm_reply in {"always", "always approve"}:
_confirm_choice = "always"
elif _raw_reply.lower() in {"cancel", "nevermind", "no"}:
elif _norm_reply in {"cancel", "nevermind", "no"}:
_confirm_choice = "cancel"
if _confirm_choice is not None:
_resolved = await _slash_confirm_mod.resolve(
@@ -7059,6 +7067,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "memory":
return await self._handle_memory_command(event)
if canonical == "skills":
return await self._handle_skills_command(event)
if canonical == "fast":
return await self._handle_fast_command(event)
@@ -9367,6 +9378,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
adapter._voice_input_callback = self._handle_voice_channel_input
if hasattr(adapter, "_on_voice_disconnect"):
adapter._on_voice_disconnect = self._handle_voice_timeout_cleanup
# Let the adapter's inactivity timer see the live voice-reply mode so it
# doesn't disconnect a deliberately text-only (/voice off) session.
if hasattr(adapter, "_voice_mode_getter"):
adapter._voice_mode_getter = lambda chat_id: self._voice_mode.get(
self._voice_key(Platform.DISCORD, str(chat_id)), "off"
)
try:
success = await adapter.join_voice_channel(voice_channel)
@@ -9377,7 +9394,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if "pynacl" in err_lower or "nacl" in err_lower or "davey" in err_lower:
return (
"Voice dependencies are missing (PyNaCl / davey). "
f"Install with: `{sys.executable} -m pip install PyNaCl`"
f"Install with: `uv pip install PyNaCl`"
)
return f"Failed to join voice channel: {e}"
@@ -10622,6 +10639,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return result
return result
_p = self._typed_command_prefix_for(event.source.platform)
prompt_message = (
f"⚠️ **Confirm /{command}**\n\n"
f"{detail}\n\n"
@@ -10629,7 +10647,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"• **Approve Once** — proceed this time only\n"
"• **Always Approve** — proceed and silence this prompt permanently\n"
"• **Cancel** — keep current conversation\n\n"
"_Text fallback: reply `/approve`, `/always`, or `/cancel`._"
f"_Text fallback: reply `{_p}approve`, `{_p}always`, or `{_p}cancel`._"
)
return await self._request_slash_confirm(
event=event,
@@ -11024,11 +11042,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.debug("Button-based update prompt failed: %s", btn_err)
if not sent_buttons:
default_hint = f" (default: {default})" if default else ""
_p = getattr(adapter, "typed_command_prefix", "/")
await adapter.send(
chat_id,
f"⚕ **Update needs your input:**\n\n"
f"{prompt_text}{default_hint}\n\n"
f"Reply `/approve` (yes) or `/deny` (no), "
f"Reply `{_p}approve` (yes) or `{_p}deny` (no), "
f"or type your answer directly.",
metadata=metadata,
)
@@ -11538,7 +11557,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# when we successfully transcribed the audio — it's redundant.
_placeholder = "(The user sent a message with no text content)"
if user_text and user_text.strip() == _placeholder:
return prefix
return prefix, successful_transcripts
if user_text:
return f"{prefix}\n\n{user_text}", successful_transcripts
return prefix, successful_transcripts
@@ -14095,14 +14114,18 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"Button-based approval failed, falling back to text: %s", _e
)
# Fallback: plain text approval prompt
# Fallback: plain text approval prompt. Use the adapter's
# typed prefix so Slack/Matrix users are told the form they
# can actually type (`!approve`) — typed "/" is blocked in
# Slack threads and reserved by Matrix clients.
_p = getattr(_status_adapter, "typed_command_prefix", "/")
cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd
msg = (
f"⚠️ **Dangerous command requires approval:**\n"
f"```\n{cmd_preview}\n```\n"
f"Reason: {desc}\n\n"
f"Reply `/approve` to execute, `/approve session` to approve this pattern "
f"for the session, `/approve always` to approve permanently, or `/deny` to cancel."
f"Reply `{_p}approve` to execute, `{_p}approve session` to approve this pattern "
f"for the session, `{_p}approve always` to approve permanently, or `{_p}deny` to cancel."
)
try:
_approval_send_fut = safe_schedule_threadsafe(
+1
View File
@@ -91,6 +91,7 @@ class SessionSource:
guild_id: Optional[str] = None # Discord guild / Slack workspace / Matrix server scope
parent_chat_id: Optional[str] = None # Parent channel when chat_id refers to a thread
message_id: Optional[str] = None # ID of the triggering message (for pin/reply/react)
role_authorized: bool = False # True when adapter granted access via role (not user ID)
@property
def description(self) -> str:
+263 -142
View File
@@ -47,6 +47,19 @@ logger = logging.getLogger("gateway.run")
class GatewaySlashCommandsMixin:
"""In-session slash-command handlers for GatewayRunner."""
def _typed_command_prefix_for(self, platform) -> str:
"""Return the prefix users can always type to reach Hermes commands.
Reads the adapter's ``typed_command_prefix`` capability flag
(default "/"). Slack and Matrix return "!" because typed "/"
commands are blocked in Slack threads / reserved by Matrix clients;
their adapters rewrite "!command" to "/command" on receive.
Instruction text built for those platforms must show the prefix
that actually works when typed.
"""
adapter = self.adapters.get(platform) if getattr(self, "adapters", None) else None
return getattr(adapter, "typed_command_prefix", "/") if adapter is not None else "/"
async def _handle_reset_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
"""Handle /new or /reset command."""
source = event.source
@@ -1146,149 +1159,198 @@ class GatewaySlashCommandsMixin:
if not result.success:
return t("gateway.model.error_prefix", error=result.error_message)
# If there's a cached agent, update it in-place
cached_entry = None
_cache_lock = getattr(self, "_agent_cache_lock", None)
_cache = getattr(self, "_agent_cache", None)
if _cache_lock and _cache is not None:
with _cache_lock:
cached_entry = _cache.get(session_key)
async def _finish_switch() -> str:
"""Apply the resolved switch (agent, session, config) and build the reply."""
# If there's a cached agent, update it in-place
cached_entry = None
_cache_lock = getattr(self, "_agent_cache_lock", None)
_cache = getattr(self, "_agent_cache", None)
if _cache_lock and _cache is not None:
with _cache_lock:
cached_entry = _cache.get(session_key)
if cached_entry and cached_entry[0] is not None:
if cached_entry and cached_entry[0] is not None:
try:
cached_entry[0].switch_model(
new_model=result.new_model,
new_provider=result.target_provider,
api_key=result.api_key,
base_url=result.base_url,
api_mode=result.api_mode,
)
except Exception as exc:
logger.warning("In-place model switch failed for cached agent: %s", exc)
# Persist the new model to the session DB so the dashboard
# shows the updated model (#34850).
_sess_db = getattr(self, "_session_db", None)
if _sess_db is not None:
try:
_sess_entry = self.session_store.get_or_create_session(source)
_sess_db.update_session_model(
_sess_entry.session_id, result.new_model
)
except Exception as exc:
logger.debug(
"Failed to persist model switch to DB: %s", exc
)
# Store a note to prepend to the next user message so the model
# knows about the switch (avoids system messages mid-history).
if not hasattr(self, "_pending_model_notes"):
self._pending_model_notes = {}
self._pending_model_notes[session_key] = (
f"[Note: model was just switched from {current_model} to {result.new_model} "
f"via {result.provider_label or result.target_provider}. "
f"Adjust your self-identification accordingly.]"
)
# Store session override so next agent creation uses the new model
self._session_model_overrides[session_key] = {
"model": result.new_model,
"provider": result.target_provider,
"api_key": result.api_key,
"base_url": result.base_url,
"api_mode": result.api_mode,
}
# Evict cached agent so the next turn creates a fresh agent from the
# override rather than relying on cache signature mismatch detection.
self._evict_cached_agent(session_key)
# Persist to config if --global
if persist_global:
try:
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
else:
cfg = {}
# Coerce scalar/None ``model:`` into a dict before mutation —
# otherwise ``cfg.setdefault("model", {})`` returns the existing
# scalar and the next assignment raises
# ``TypeError: 'str' object does not support item assignment``.
# Reproduces when ``config.yaml`` has ``model: <name>`` (flat
# string) instead of the proper nested ``model: {default: ...}``.
raw_model = cfg.get("model")
if isinstance(raw_model, dict):
model_cfg = raw_model
elif isinstance(raw_model, str) and raw_model.strip():
model_cfg = {"default": raw_model.strip()}
cfg["model"] = model_cfg
else:
model_cfg = {}
cfg["model"] = model_cfg
model_cfg["default"] = result.new_model
model_cfg["provider"] = result.target_provider
if result.base_url:
model_cfg["base_url"] = result.base_url
from hermes_cli.config import save_config
save_config(cfg)
except Exception as e:
logger.warning("Failed to persist model switch: %s", e)
# Build confirmation message with full metadata
provider_label = result.provider_label or result.target_provider
lines = [t("gateway.model.switched", model=result.new_model)]
lines.append(t("gateway.model.provider_label", provider=provider_label))
# Context: always resolve via the provider-aware chain so Codex OAuth,
# Copilot, and Nous-enforced caps win over the raw models.dev entry.
mi = result.model_info
from hermes_cli.model_switch import resolve_display_context_length
_sw2_config_ctx = None
try:
cached_entry[0].switch_model(
new_model=result.new_model,
new_provider=result.target_provider,
api_key=result.api_key,
base_url=result.base_url,
api_mode=result.api_mode,
)
except Exception as exc:
logger.warning("In-place model switch failed for cached agent: %s", exc)
_sw2_cfg = _load_gateway_config()
_sw2_model_cfg = _sw2_cfg.get("model", {})
if isinstance(_sw2_model_cfg, dict):
_sw2_raw = _sw2_model_cfg.get("context_length")
if _sw2_raw is not None:
_sw2_config_ctx = int(_sw2_raw)
except Exception:
pass
ctx = resolve_display_context_length(
result.new_model,
result.target_provider,
base_url=result.base_url or current_base_url or "",
api_key=result.api_key or current_api_key or "",
model_info=mi,
custom_providers=custom_provs,
config_context_length=_sw2_config_ctx,
)
if ctx:
lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}"))
if mi:
if mi.max_output:
lines.append(t("gateway.model.max_output_label", tokens=f"{mi.max_output:,}"))
if mi.has_cost_data():
lines.append(t("gateway.model.cost_label", cost=mi.format_cost()))
lines.append(t("gateway.model.capabilities_label", capabilities=mi.format_capabilities()))
# Persist the new model to the session DB so the dashboard
# shows the updated model (#34850).
_sess_db = getattr(self, "_session_db", None)
if _sess_db is not None:
try:
_sess_entry = self.session_store.get_or_create_session(source)
_sess_db.update_session_model(
_sess_entry.session_id, result.new_model
)
except Exception as exc:
logger.debug(
"Failed to persist model switch to DB: %s", exc
)
# Cache notice
cache_enabled = (
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
or result.api_mode == "anthropic_messages"
)
if cache_enabled:
lines.append(t("gateway.model.prompt_caching_enabled"))
# Store a note to prepend to the next user message so the model
# knows about the switch (avoids system messages mid-history).
if not hasattr(self, "_pending_model_notes"):
self._pending_model_notes = {}
self._pending_model_notes[session_key] = (
f"[Note: model was just switched from {current_model} to {result.new_model} "
f"via {result.provider_label or result.target_provider}. "
f"Adjust your self-identification accordingly.]"
)
if result.warning_message:
lines.append(t("gateway.model.warning_prefix", warning=result.warning_message))
# Store session override so next agent creation uses the new model
self._session_model_overrides[session_key] = {
"model": result.new_model,
"provider": result.target_provider,
"api_key": result.api_key,
"base_url": result.base_url,
"api_mode": result.api_mode,
}
if persist_global:
lines.append(t("gateway.model.saved_global"))
else:
lines.append(t("gateway.model.session_only_hint"))
# Evict cached agent so the next turn creates a fresh agent from the
# override rather than relying on cache signature mismatch detection.
self._evict_cached_agent(session_key)
return "\n".join(lines)
# Persist to config if --global
if persist_global:
try:
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
else:
cfg = {}
# Coerce scalar/None ``model:`` into a dict before mutation —
# otherwise ``cfg.setdefault("model", {})`` returns the existing
# scalar and the next assignment raises
# ``TypeError: 'str' object does not support item assignment``.
# Reproduces when ``config.yaml`` has ``model: <name>`` (flat
# string) instead of the proper nested ``model: {default: ...}``.
raw_model = cfg.get("model")
if isinstance(raw_model, dict):
model_cfg = raw_model
elif isinstance(raw_model, str) and raw_model.strip():
model_cfg = {"default": raw_model.strip()}
cfg["model"] = model_cfg
else:
model_cfg = {}
cfg["model"] = model_cfg
model_cfg["default"] = result.new_model
model_cfg["provider"] = result.target_provider
if result.base_url:
model_cfg["base_url"] = result.base_url
from hermes_cli.config import save_config
save_config(cfg)
except Exception as e:
logger.warning("Failed to persist model switch: %s", e)
# Build confirmation message with full metadata
provider_label = result.provider_label or result.target_provider
lines = [t("gateway.model.switched", model=result.new_model)]
lines.append(t("gateway.model.provider_label", provider=provider_label))
# Context: always resolve via the provider-aware chain so Codex OAuth,
# Copilot, and Nous-enforced caps win over the raw models.dev entry.
mi = result.model_info
from hermes_cli.model_switch import resolve_display_context_length
_sw2_config_ctx = None
# Expensive-model confirmation gate (typed /model <name> path).
# The pickers (Telegram/Discord inline keyboards, TUI, dashboard)
# already confirm via their own UI affordances; this covers the
# direct text command, which previously bypassed the guard.
# expensive_model_warning() may hit models.dev or a /models endpoint
# on a cache miss, so run it off the event loop.
_cost_warning = None
try:
_sw2_cfg = _load_gateway_config()
_sw2_model_cfg = _sw2_cfg.get("model", {})
if isinstance(_sw2_model_cfg, dict):
_sw2_raw = _sw2_model_cfg.get("context_length")
if _sw2_raw is not None:
_sw2_config_ctx = int(_sw2_raw)
from hermes_cli.model_cost_guard import expensive_model_warning
_cost_warning = await asyncio.to_thread(
expensive_model_warning,
result.new_model,
provider=result.target_provider,
base_url=result.base_url or current_base_url or "",
api_key=result.api_key or current_api_key or "",
model_info=result.model_info,
)
except Exception:
pass
ctx = resolve_display_context_length(
result.new_model,
result.target_provider,
base_url=result.base_url or current_base_url or "",
api_key=result.api_key or current_api_key or "",
model_info=mi,
custom_providers=custom_provs,
config_context_length=_sw2_config_ctx,
)
if ctx:
lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}"))
if mi:
if mi.max_output:
lines.append(t("gateway.model.max_output_label", tokens=f"{mi.max_output:,}"))
if mi.has_cost_data():
lines.append(t("gateway.model.cost_label", cost=mi.format_cost()))
lines.append(t("gateway.model.capabilities_label", capabilities=mi.format_capabilities()))
_cost_warning = None
if _cost_warning is not None:
async def _on_cost_confirm(choice: str) -> str:
if choice == "cancel":
return (
f"🟡 Model switch cancelled. Current model unchanged "
f"({current_model or 'unknown'})."
)
# "once" and "always" both proceed — there is no persistent
# opt-out for the cost guard (each expensive switch should be
# an explicit decision).
return await _finish_switch()
# Cache notice
cache_enabled = (
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
or result.api_mode == "anthropic_messages"
)
if cache_enabled:
lines.append(t("gateway.model.prompt_caching_enabled"))
_p = self._typed_command_prefix_for(event.source.platform)
return await self._request_slash_confirm(
event=event,
command="model",
title="Expensive Model Warning",
message=(
f"⚠️ **Expensive Model Warning**\n\n{_cost_warning.message}\n\n"
f"_Text fallback: reply `{_p}approve` to switch or `{_p}cancel` to keep "
"the current model._"
),
handler=_on_cost_confirm,
)
if result.warning_message:
lines.append(t("gateway.model.warning_prefix", warning=result.warning_message))
if persist_global:
lines.append(t("gateway.model.saved_global"))
else:
lines.append(t("gateway.model.session_only_hint"))
return "\n".join(lines)
return await _finish_switch()
async def _handle_codex_runtime_command(self, event: MessageEvent) -> str:
"""Handle /codex-runtime command in the gateway.
@@ -1955,12 +2017,12 @@ class GatewaySlashCommandsMixin:
return t("gateway.reasoning.set_session", effort=effort)
async def _handle_memory_command(self, event: MessageEvent) -> str:
"""Handle /memory — review pending memory writes + set write mode.
"""Handle /memory — review pending memory writes + toggle the approval gate.
Memory entries are small enough to review inline in a chat bubble, so
the full pending/approve/reject/mode flow works on every platform.
Mode changes persist to config.yaml and evict the cached agent so the
new write_mode takes effect on the next message.
the full pending/approve/reject/approval flow works on every platform.
Gate changes persist to config.yaml and evict the cached agent so the
new setting takes effect on the next message.
"""
from gateway.run import _hermes_home
from hermes_cli.write_approval_commands import handle_pending_subcommand
@@ -1972,15 +2034,15 @@ class GatewaySlashCommandsMixin:
session_key = self._session_key_for_source(event.source)
config_path = _hermes_home / "config.yaml"
def _set_mode(mode: str):
def _set_approval(enabled: bool):
import yaml
user_config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
user_config.setdefault("memory", {})["write_mode"] = mode
user_config.setdefault("memory", {})["write_approval"] = bool(enabled)
atomic_yaml_write(config_path, user_config)
# New write_mode must take effect next message → drop cached agent.
# New setting must take effect next message → drop cached agent.
self._evict_cached_agent(session_key)
# Apply approved writes against a fresh on-disk store (the gateway has
@@ -1989,11 +2051,69 @@ class GatewaySlashCommandsMixin:
store.load_from_disk()
out = handle_pending_subcommand(
wa.MEMORY, args, memory_store=store, set_mode_fn=_set_mode,
wa.MEMORY, args, memory_store=store, set_mode_fn=_set_approval,
)
if out is None:
out = ("Unknown /memory subcommand. Use: pending, approve <id>, "
"reject <id>, mode <on|off|approve>.")
"reject <id>, approval <on|off>.")
return out
async def _handle_skills_command(self, event: MessageEvent) -> str:
"""Handle /skills on the gateway — pending skill-write review only.
The full skills hub (search/browse/install) stays CLI-only; this
handler covers the write-approval review surface (pending / approve /
reject / diff / approval) so a skill staged from a gateway session can
be reviewed from that same session. Gated by ``skills.write_approval``
via the CommandDef's ``gateway_config_gate``; also answers when staged
writes still exist after the gate was turned off (so they are never
stranded).
``diff`` output is truncated for chat bubbles the full diff lives in
the CLI (``/skills diff <id>``) and the pending JSON file.
"""
from gateway.run import _hermes_home
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
raw_args = event.get_command_args().strip()
args = raw_args.split() if raw_args else []
session_key = self._session_key_for_source(event.source)
config_path = _hermes_home / "config.yaml"
gate_on = wa.write_approval_enabled(wa.SKILLS)
wants_toggle = bool(args) and args[0].lower() in {"approval", "mode"}
if not gate_on and not wants_toggle and wa.pending_count(wa.SKILLS) == 0:
return ("Skill write approval is off (skills.write_approval). "
"Enable it with /skills approval on, then review staged "
"writes here with /skills pending.")
def _set_approval(enabled: bool):
import yaml
user_config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
user_config.setdefault("skills", {})["write_approval"] = bool(enabled)
atomic_yaml_write(config_path, user_config)
# New setting must take effect next message → drop cached agent.
self._evict_cached_agent(session_key)
out = handle_pending_subcommand(
wa.SKILLS, args, set_mode_fn=_set_approval,
)
if out is None:
return ("Unknown /skills subcommand on this platform. Use: pending, "
"approve <id>, reject <id>, diff <id>, approval <on|off>. "
"(Search/install are CLI-only.)")
# Chat bubbles can't hold a full skill diff — truncate and point at
# the real review surfaces.
if args and args[0].lower() == "diff" and len(out) > 3000:
pending_id = args[1] if len(args) > 1 else "<id>"
out = (out[:3000]
+ f"\n… (truncated — full diff: `/skills diff {pending_id}` "
f"on the CLI, or ~/.hermes/pending/skills/{pending_id}.json)")
return out
async def _handle_fast_command(self, event: MessageEvent) -> str:
@@ -3311,7 +3431,8 @@ class GatewaySlashCommandsMixin:
if is_managed():
return f"{format_managed_message('update Hermes Agent')}"
project_root = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
project_root = get_hermes_source_root()
git_dir = project_root / '.git'
if not git_dir.exists():
+60 -15
View File
@@ -19,29 +19,74 @@ __release_date__ = "2026.6.5"
def _ensure_utf8():
"""Force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError.
"""Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes.
Windows services and terminals default to cp1252, which cannot encode
box-drawing characters used in CLI output. This causes unhandled
UnicodeEncodeError crashes on gateway startup.
Several environments select a legacy, non-UTF-8 encoding for the standard
streams:
- Windows services and terminals default to cp1252.
- Linux hosts with a latin-1 / C / POSIX locale (common on minimal Debian
installs and Raspberry Pi) select latin-1 or ASCII.
The CLI prints box-drawing characters () and the glyph in the setup
wizard, doctor, and status banners. Encoding those under a non-UTF-8 codec
raises an unhandled UnicodeEncodeError that crashes the command before it
can even start e.g. `hermes setup` on a fresh Pi.
This runs at import time so it protects every CLI subcommand, on any
platform. It re-wraps stdout/stderr as UTF-8 when their encoding is not
already UTF-8, preferring TextIOWrapper.reconfigure() so the existing
stream object is fixed in place (cached `sys.stdout` references keep
working) and falling back to reopening the file descriptor with
closefd=False (the CPython-recommended safe variant).
No-op when the streams are already UTF-8: a healthy UTF-8 system sees no
stream change and no environment mutation.
Note: this is intentionally the earliest, platform-agnostic guard.
hermes_cli/stdio.py::configure_windows_stdio() runs later from the entry
points and layers on the Windows-only extras (console code-page flip,
EDITOR default, PATH augmentation); its stream reconfiguration is a
harmless idempotent no-op once we have already repaired the streams here.
"""
if sys.platform != "win32":
return
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
repaired = False
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if stream is None:
continue
try:
if getattr(stream, "encoding", "").lower().replace("-", "") != "utf8":
new_stream = open(
stream.fileno(), "w", encoding="utf-8",
buffering=1, closefd=False,
)
setattr(sys, stream_name, new_stream)
except (AttributeError, OSError):
encoding = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
if encoding == "utf8":
continue
# Preferred: reconfigure the existing TextIOWrapper in place. This
# preserves object identity so any code already holding a reference
# to the old sys.stdout benefits from the repair too.
reconfigure = getattr(stream, "reconfigure", None)
if callable(reconfigure):
reconfigure(encoding="utf-8", errors="replace")
repaired = True
continue
# Fallback: reopen the underlying file descriptor as UTF-8. Used
# for streams that don't expose reconfigure() (e.g. some wrapped
# or replaced streams). closefd=False keeps the original fd open.
new_stream = open(
stream.fileno(), "w", encoding="utf-8",
errors="replace", buffering=1, closefd=False,
)
setattr(sys, stream_name, new_stream)
repaired = True
except (AttributeError, OSError, ValueError):
pass
# Only nudge child processes toward UTF-8 when we actually detected a
# non-UTF-8 locale. On a healthy UTF-8 host children inherit UTF-8 from the
# locale already, so leave the environment untouched (minimal footprint).
if repaired:
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
_ensure_utf8()
+91 -7
View File
@@ -2665,12 +2665,23 @@ def _xai_wait_for_callback(
result: dict[str, Any],
*,
timeout_seconds: float = 180.0,
manual_paste_redirect_uri: Optional[str] = None,
) -> dict[str, Any]:
deadline = time.monotonic() + max(5.0, timeout_seconds)
if manual_paste_redirect_uri and sys.stdin.isatty():
print()
print("If xAI shows a Grok Build code instead of redirecting,")
print("paste that code here and press Enter.")
try:
while time.monotonic() < deadline:
if result["code"] or result["error"]:
return result
if manual_paste_redirect_uri:
raw_paste = _read_ready_stdin_line()
if raw_paste and raw_paste.strip():
pasted = _parse_pasted_callback(raw_paste)
pasted["_manual_paste"] = True
return pasted
time.sleep(0.1)
finally:
server.shutdown()
@@ -2694,6 +2705,21 @@ def _xai_wait_for_callback(
)
def _read_ready_stdin_line() -> Optional[str]:
"""Return one pending stdin line without blocking, if the terminal has one."""
try:
if not sys.stdin.isatty():
return None
import select
ready, _, _ = select.select([sys.stdin], [], [], 0)
if not ready:
return None
return sys.stdin.readline()
except Exception:
return None
def _spotify_token_payload_to_state(
token_payload: Dict[str, Any],
*,
@@ -6149,6 +6175,40 @@ def _reset_config_provider() -> Path:
return config_path
def _confirm_expensive_model_selection(
model_id: str,
*,
provider: str = "",
base_url: str = "",
api_key: str = "",
) -> bool:
"""Prompt before saving a model whose known pricing exceeds guardrails."""
try:
from hermes_cli.model_cost_guard import expensive_model_warning
warning = expensive_model_warning(
model_id,
provider=provider,
base_url=base_url,
api_key=api_key,
)
except Exception:
warning = None
if warning is None:
return True
print()
print("=" * 72)
print(warning.message)
print("=" * 72)
try:
response = input("Switch anyway? [y/N]: ").strip().lower()
except (KeyboardInterrupt, EOFError):
print()
return False
return response in {"y", "yes"}
def _prompt_model_selection(
model_ids: List[str],
current_model: str = "",
@@ -6156,6 +6216,9 @@ def _prompt_model_selection(
unavailable_models: Optional[List[str]] = None,
portal_url: str = "",
unavailable_message: str = "",
confirm_provider: str = "",
confirm_base_url: str = "",
confirm_api_key: str = "",
) -> Optional[str]:
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
@@ -6169,6 +6232,18 @@ def _prompt_model_selection(
_unavailable = unavailable_models or []
def _confirmed_selection(mid: str) -> Optional[str]:
if not mid:
return None
if confirm_provider and not _confirm_expensive_model_selection(
mid,
provider=confirm_provider,
base_url=confirm_base_url,
api_key=confirm_api_key,
):
return None
return mid
# Reorder: current model first, then the rest (deduplicated)
ordered = []
if current_model and current_model in model_ids:
@@ -6284,13 +6359,13 @@ def _prompt_model_selection(
return None
print()
if idx < len(ordered):
return ordered[idx]
return _confirmed_selection(ordered[idx])
elif idx == len(ordered):
try:
custom = input("Enter model name: ").strip()
except (EOFError, KeyboardInterrupt):
return None
return custom if custom else None
return _confirmed_selection(custom) if custom else None
return None
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
pass
@@ -6322,10 +6397,10 @@ def _prompt_model_selection(
return None
idx = int(choice)
if 1 <= idx <= n:
return ordered[idx - 1]
return _confirmed_selection(ordered[idx - 1])
elif idx == n + 1:
custom = input("Enter model name: ").strip()
return custom if custom else None
return _confirmed_selection(custom) if custom else None
elif idx == n + 2:
return None
print(f"Please enter 1-{n + 2}")
@@ -6669,6 +6744,7 @@ def _xai_oauth_loopback_login(
authorization_endpoint = discovery["authorization_endpoint"]
token_endpoint = discovery["token_endpoint"]
allow_missing_state = False
if manual_paste:
# No HTTP listener — synthesize a redirect_uri matching what
# the server would have bound to so the authorize URL the user
@@ -6695,6 +6771,7 @@ def _xai_oauth_loopback_login(
print("Open this URL to authorize Hermes with xAI:")
print(authorize_url)
callback = _prompt_manual_callback_paste(redirect_uri)
allow_missing_state = True
else:
server, thread, callback_result, redirect_uri = _xai_start_callback_server()
try:
@@ -6734,6 +6811,7 @@ def _xai_oauth_loopback_login(
thread,
callback_result,
timeout_seconds=max(30.0, timeout_seconds * 9),
manual_paste_redirect_uri=redirect_uri,
)
except AuthError as exc:
if (
@@ -6750,6 +6828,7 @@ def _xai_oauth_loopback_login(
callback = _prompt_manual_callback_paste(redirect_uri)
if callback.get("code") is None and callback.get("error") is None:
raise exc
allow_missing_state = True
except Exception:
try:
server.shutdown()
@@ -6770,7 +6849,7 @@ def _xai_oauth_loopback_login(
code="xai_authorization_failed",
)
callback_state = callback.get("state")
# Manual-paste bare-code path: when a user pastes only the opaque
# Manual bare-code paths: when a user pastes only the opaque
# authorization code (no ``code=``/``state=`` query parameters),
# ``_parse_pasted_callback`` returns ``state=None``. xAI's consent
# page renders the code in-page rather than redirecting through the
@@ -6778,10 +6857,12 @@ def _xai_oauth_loopback_login(
# VPS, container consoles) the bare code is the only thing the user
# can obtain. PKCE (code_verifier) still binds the exchange to this
# client, so the local state-equality check is redundant on the
# bare-code path — we substitute the locally generated state to keep
# bare-code paths — we substitute the locally generated state to keep
# the rest of the validation chain (and the token exchange) unchanged.
# See #26923 (AccursedGalaxy comment, 2026-05-20).
if callback_state is None and manual_paste:
if callback.get("_manual_paste"):
allow_missing_state = True
if callback_state is None and (manual_paste or allow_missing_state):
callback_state = state
if callback_state != state:
raise AuthError(
@@ -7698,6 +7779,9 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
unavailable_models=unavailable_models,
portal_url=_portal,
unavailable_message=unavailable_message,
confirm_provider="nous",
confirm_base_url=inference_base_url,
confirm_api_key=runtime_key,
)
elif unavailable_models:
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
+9 -9
View File
@@ -268,7 +268,8 @@ def check_for_updates() -> Optional[int]:
# Prefer the running code's location over the profile-scoped path.
# $HERMES_HOME/hermes-agent/ may be a stale copy from --clone-all;
# Path(__file__) always resolves to the actual installed checkout.
repo_dir = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
repo_dir = get_hermes_source_root()
if not (repo_dir / ".git").exists():
repo_dir = hermes_home / "hermes-agent"
if not (repo_dir / ".git").exists():
@@ -293,7 +294,8 @@ def _resolve_repo_dir() -> Optional[Path]:
because ``$HERMES_HOME/hermes-agent/`` may be a stale copy carried
over by ``--clone-all``.
"""
repo_dir = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
repo_dir = get_hermes_source_root()
if not (repo_dir / ".git").exists():
hermes_home = get_hermes_home()
repo_dir = hermes_home / "hermes-agent"
@@ -728,17 +730,15 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
except Exception:
pass # Never break the banner over an update check
# Pip-install warning — `pip install hermes-agent` is not the supported
# install path (it exists on PyPI for internal/CI reasons, not end users).
# Such installs miss the git checkout + installer-managed deps, so updates,
# self-update, and issue triage don't behave correctly. Warn, don't block.
# PyPI install warning — `pip install hermes-agent` is not a supported
# install path. Direct users to the official installer.
try:
from hermes_cli.config import detect_install_method
if detect_install_method() == "pip":
right_lines.append(
"[bold yellow]⚠ pip install not officially supported[/]"
"[dim yellow] — exists for reasons other than user install; "
"expect instability and an inability to support issues[/]"
"[bold yellow]⚠ the hermes-agent python package is no longer supported[/]"
"[dim yellow] please reinstall via our official installer "
"curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash[/]"
)
except Exception:
pass # Never break the banner over the install-method check
+2 -1
View File
@@ -27,10 +27,11 @@ from __future__ import annotations
from pathlib import Path
from typing import Optional
from hermes_constants import get_hermes_source_root
# Path is resolved relative to this module so it works regardless of cwd —
# matches the pattern used by ``banner._resolve_repo_dir``.
_BUILD_SHA_FILE = Path(__file__).parent.parent / ".hermes_build_sha"
_BUILD_SHA_FILE = get_hermes_source_root() / ".hermes_build_sha"
def get_build_sha(short: int = 8) -> Optional[str]:
+2 -1
View File
@@ -32,7 +32,8 @@ from hermes_cli.setup import (
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
_OPENCLAW_SCRIPT = (
get_optional_skills_dir(PROJECT_ROOT / "optional-skills")
+8 -8
View File
@@ -1306,12 +1306,12 @@ class CLICommandsMixin:
parts = cmd.strip().split()
args = parts[1:] if len(parts) > 1 else []
if args and args[0].lower() in {"pending", "approve", "apply", "reject",
"deny", "drop", "diff", "mode"}:
"deny", "drop", "diff", "approval", "mode"}:
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
out = handle_pending_subcommand(
wa.SKILLS, args,
set_mode_fn=lambda m: self._save_write_mode("skills", m),
set_mode_fn=lambda enabled: self._save_write_approval("skills", enabled),
)
if out is not None:
print(out)
@@ -1320,7 +1320,7 @@ class CLICommandsMixin:
handle_skills_slash(cmd, ChatConsole())
def _handle_memory_command(self, cmd: str):
"""Handle /memory slash command — pending review + write-mode control."""
"""Handle /memory slash command — pending review + approval-gate toggle."""
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
parts = cmd.strip().split()
@@ -1329,17 +1329,17 @@ class CLICommandsMixin:
out = handle_pending_subcommand(
wa.MEMORY, args,
memory_store=store,
set_mode_fn=lambda m: self._save_write_mode("memory", m),
set_mode_fn=lambda enabled: self._save_write_approval("memory", enabled),
)
if out is None:
out = ("Unknown /memory subcommand. "
"Use: pending, approve <id>, reject <id>, mode <on|off|approve>.")
"Use: pending, approve <id>, reject <id>, approval <on|off>.")
print(out)
def _save_write_mode(self, subsystem: str, mode: str):
"""Persist <subsystem>.write_mode to config (for /memory|/skills mode)."""
def _save_write_approval(self, subsystem: str, enabled: bool):
"""Persist <subsystem>.write_approval to config (for /memory|/skills approval)."""
from cli import save_config_value
save_config_value(f"{subsystem}.write_mode", mode)
save_config_value(f"{subsystem}.write_approval", bool(enabled))
def _handle_background_command(self, cmd: str):
"""Handle /background <prompt> — run a prompt in a separate background session.
+5 -4
View File
@@ -167,12 +167,13 @@ COMMAND_REGISTRY: list[CommandDef] = [
cli_only=True),
CommandDef("skills", "Search, install, inspect, or manage skills",
"Tools & Skills", cli_only=True,
gateway_config_gate="skills.write_approval",
subcommands=("search", "browse", "inspect", "install", "audit",
"pending", "approve", "reject", "diff", "mode")),
CommandDef("memory", "Review pending memory writes / set write mode",
"pending", "approve", "reject", "diff", "approval")),
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
"Tools & Skills",
args_hint="[pending|approve|reject|mode] [id|on|off|approve]",
subcommands=("pending", "approve", "reject", "mode")),
args_hint="[pending|approve|reject|approval] [id|on|off]",
subcommands=("pending", "approve", "reject", "approval")),
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
"Tools & Skills"),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
+92 -34
View File
@@ -379,7 +379,8 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
if managed:
return managed.lower().replace(" ", "-")
if project_root is None:
project_root = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
project_root = get_hermes_source_root()
if (project_root / ".git").is_dir():
return "git"
return "pip"
@@ -435,9 +436,10 @@ def recommended_update_command_for_method(method: str) -> str:
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"
if shutil.which("pipx") and "pipx" in __import__("sys").prefix.split(__import__("os").sep):
return "pipx upgrade hermes-agent"
# PyPI-based installs are no longer supported — direct to the installer.
return "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
return "hermes update"
@@ -600,9 +602,6 @@ 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.
@@ -1290,6 +1289,14 @@ DEFAULT_CONFIG = {
"timeout": 30,
"extra_body": {},
},
"tts_audio_tags": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
},
# 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
@@ -1556,7 +1563,7 @@ DEFAULT_CONFIG = {
# 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 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
# Gemini 32000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
"tts": {
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local)
"edge": {
@@ -1572,6 +1579,19 @@ DEFAULT_CONFIG = {
"voice": "alloy",
# Voices: 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",
@@ -1653,18 +1673,19 @@ DEFAULT_CONFIG = {
"memory": {
"memory_enabled": True,
"user_profile_enabled": True,
# Write gate for the memory tool (add/replace/remove), applied to BOTH
# 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):
# on — write freely (default, current behaviour)
# off — never write; the memory tool returns a clean disabled result
# approve — foreground writes block on an inline approve/deny prompt
# (entries are small enough to review in a chat bubble);
# background-review writes are staged for review instead of
# committed (a daemon thread cannot block on a prompt).
# Pending entries: /memory pending, /memory approve <id>,
# /memory reject <id>.
"write_mode": "on",
# (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).
@@ -1769,17 +1790,18 @@ DEFAULT_CONFIG = {
# External hub installs (trusted/community sources) are always
# scanned regardless of this setting.
"guard_agent_created": False,
# Write gate for skill_manage (create/edit/patch/write_file/delete/
# 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:
# on — write freely (default, current behaviour)
# off — never write; skill_manage returns a clean disabled result
# approve — stage the write for review instead of committing.
# Pending skills are listed with /skills pending, reviewed
# with /skills diff <id> (full diff — CLI/dashboard/file,
# never crammed into a chat bubble), and applied with
# /skills approve <id> or dropped with /skills reject <id>.
"write_mode": "on",
# 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.
@@ -2463,7 +2485,7 @@ DEFAULT_CONFIG = {
# Config schema version - bump this when adding new required fields
"_config_version": 28,
"_config_version": 29,
}
# =============================================================================
@@ -4734,6 +4756,34 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
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:
save_config(config)
if not quiet:
print(" ✓ Renamed write_mode → write_approval (boolean gate)")
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")
@@ -5719,19 +5769,21 @@ def save_env_value(key: str, value: str):
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, env_path)
# Restore original permissions before _secure_file may tighten them.
# 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
_secure_file(env_path)
os.environ[key] = value
invalidate_env_cache()
@@ -5776,18 +5828,22 @@ def remove_env_value(key: str) -> bool:
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
_secure_file(env_path)
os.environ.pop(key, None)
invalidate_env_cache()
@@ -5874,6 +5930,8 @@ def redact_key(key: str) -> str:
def show_config():
"""Display current configuration."""
from hermes_constants import get_hermes_source_root
config = load_config()
print()
@@ -5886,7 +5944,7 @@ def show_config():
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()}")
print(f" Install: {get_hermes_source_root()}")
# API Keys
print()
+2 -1
View File
@@ -11,7 +11,8 @@ import sys
from pathlib import Path
from typing import Iterable, List, Optional
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
sys.path.insert(0, str(PROJECT_ROOT))
from hermes_cli.colors import Colors, color
+6 -8
View File
@@ -14,12 +14,14 @@ automatically.
from __future__ import annotations
import os
import subprocess
import sys
import time
import logging
from typing import Optional, Tuple
import requests
from hermes_cli.managed_uv import pip_install
logger = logging.getLogger(__name__)
@@ -163,17 +165,13 @@ def _ensure_qrcode_installed() -> bool:
import subprocess
# Try uv first (Hermes convention), then pip
for cmd in (
[sys.executable, "-m", "uv", "pip", "install", "qrcode"],
[sys.executable, "-m", "pip", "install", "-q", "qrcode"],
):
result = pip_install(["qrcode"], quiet=True)
if result.returncode == 0:
try:
subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
import qrcode # noqa: F401,F811
return True
except (subprocess.CalledProcessError, ImportError, FileNotFoundError):
continue
except ImportError:
pass
return False
-2243
View File
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
"""hermes_cli.doctor — diagnostic checks for Hermes Agent setup.
This module is the public face of the doctor package. It exposes the same
names the old flat doctor.py did so all existing imports and monkeypatches
in tests keep working without change:
from hermes_cli.doctor import run_doctor, HERMES_HOME, PROJECT_ROOT, _DHH
from hermes_cli.doctor import _has_provider_env_config, _PROVIDER_ENV_HINTS
from hermes_cli.doctor import _apply_doctor_tool_availability_overrides
from hermes_cli.doctor import _honcho_is_configured_for_doctor
from hermes_cli.doctor import _doctor_tool_availability_detail
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
from hermes_cli.doctor import _build_apikey_providers_list
from hermes_cli.doctor import shutil # tests patch shutil.which via doctor_mod.shutil
import hermes_cli.doctor as doctor_mod # monkeypatching PROJECT_ROOT, HERMES_HOME etc.
Internal layout
---------------
hermes_cli/doctor/
__init__.py you are here
_output.py ANSI rendering helpers (no external deps)
_registry.py register() decorator, DiagnosticReport, run_checks()
checks/
__init__.py imports all check modules (registers side-effects)
_helpers.py shared utils (safe_which, is_termux, )
python_env.py
security.py
dep_mgmt.py
config_files.py
xai_retirement.py
auth_providers.py
directory_structure.py
gateway_service.py
command_install.py
external_tools.py
api_connectivity.py
tool_availability.py
skills_hub.py
memory_provider.py
profiles.py
"""
from __future__ import annotations
import os
import shutil # noqa: F401 — tests monkeypatch hermes_cli.doctor.shutil
import sys
from pathlib import Path
# ── Module-level globals (monkeypatched in tests) ─────────────────────────
# These are the same names the flat doctor.py exposed.
from hermes_cli.config import get_hermes_home, get_env_path
from hermes_constants import display_hermes_home, get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
HERMES_HOME = get_hermes_home()
_DHH = display_hermes_home()
# ── Lazy env bootstrap — same as old doctor.py top-level code ─────────────
_env_path = get_env_path()
try:
from hermes_cli.env_loader import load_hermes_dotenv
load_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / ".env")
except Exception:
pass
# ── Backward-compat re-exports from sub-modules ───────────────────────────
# Import these after the globals so sub-modules can `from hermes_cli.doctor import HERMES_HOME`
from hermes_cli.doctor.checks.config_files import ( # noqa: F401
_PROVIDER_ENV_HINTS,
_has_provider_env_config,
)
from hermes_cli.doctor.checks.tool_availability import ( # noqa: F401
_apply_doctor_tool_availability_overrides,
_honcho_is_configured_for_doctor,
_doctor_tool_availability_detail,
)
from hermes_cli.doctor.checks.api_connectivity import ( # noqa: F401
_has_healthy_oauth_fallback as _has_healthy_oauth_fallback_for_apikey_provider,
_build_apikey_providers_list,
_APIKEY_PROVIDERS_CACHE,
)
# Termux helpers (some tests import directly)
from hermes_cli.doctor.checks._helpers import ( # noqa: F401
is_termux as _is_termux,
python_install_cmd as _python_install_cmd,
system_package_install_cmd as _system_package_install_cmd,
)
# ── Platform check ────────────────────────────────────────────────────────
def _check_unsupported_platform() -> None:
if sys.platform == "darwin" and os.uname().machine == "x86_64":
from hermes_cli.doctor._output import color, _Ansi
print(color(
"⚠️ WARNING: macOS x86_64 (Intel) is explicitly unsupported.\n"
"We no longer accept PRs or provide fixes for this platform.\n"
"Consider migrating to a supported platform (macOS arm64 / Apple Silicon).",
_Ansi.YELLOW,
))
print()
# ── run_doctor entry point ────────────────────────────────────────────────
def run_doctor(args) -> None:
"""Run all registered diagnostic checks.
Called by ``hermes doctor`` (via ``hermes_cli/main.py``) and also
directly by tests. ``args`` is an ``argparse.Namespace`` with at least:
args.fix bool, whether to attempt auto-fixes
args.ack str | None, advisory ID to acknowledge
"""
should_fix = getattr(args, "fix", False)
ack_target = getattr(args, "ack", None)
os.environ.setdefault("HERMES_INTERACTIVE", "1")
# Fast path: `hermes doctor --ack <id>`
if ack_target:
_handle_ack(ack_target)
return
# Trigger all @register() decorators by importing the checks package
import hermes_cli.doctor.checks # noqa: F401
from hermes_cli.doctor._output import print_banner, color, _Ansi
from hermes_cli.doctor._registry import DiagnosticReport, run_checks
print_banner()
_check_unsupported_platform()
report = DiagnosticReport(should_fix=should_fix)
run_checks(report)
report.print_summary()
def _handle_ack(ack_target: str) -> None:
from hermes_cli.doctor._output import color, _Ansi
from hermes_cli.security_advisories import ADVISORIES, ack_advisory
valid_ids = {a.id for a in ADVISORIES}
if ack_target not in valid_ids:
print(color(
f"Unknown advisory ID: {ack_target!r}. Known IDs: "
f"{', '.join(sorted(valid_ids)) or '(none)'}",
_Ansi.RED,
))
sys.exit(2)
if ack_advisory(ack_target):
print(color(
f" ✓ Acknowledged advisory {ack_target}. "
f"It will no longer trigger startup banners.",
_Ansi.GREEN,
))
else:
print(color(
f" ✗ Failed to persist ack for {ack_target}. "
f"Check ~/.hermes/config.yaml is writable.",
_Ansi.RED,
))
sys.exit(1)
+94
View File
@@ -0,0 +1,94 @@
"""Terminal output rendering for doctor diagnostics.
Pure stdlib uses inline ANSI codes instead of importing hermes_cli.colors.
"""
from __future__ import annotations
import os
import sys
def _should_use_color() -> bool:
"""Return True when colored output is appropriate.
Respects NO_COLOR (https://no-color.org/) and TERM=dumb.
"""
if os.environ.get("NO_COLOR") is not None:
return False
if os.environ.get("TERM") == "dumb":
return False
try:
if not sys.stdout.isatty():
return False
except (AttributeError, ValueError):
return False
return True
class _Ansi:
"""ANSI escape code constants."""
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
def color(text: str, *codes: str) -> str:
"""Apply ANSI color codes to text (only when color output is appropriate)."""
if not _should_use_color():
return text
return "".join(codes) + text + _Ansi.RESET
def check_ok(text: str, detail: str = "") -> None:
"""Print an OK check line."""
glyph = color("", _Ansi.GREEN)
line = f" {glyph} {text}"
if detail:
line += f" {color(detail, _Ansi.DIM)}"
print(line)
def check_warn(text: str, detail: str = "") -> None:
"""Print a warning check line."""
glyph = color("", _Ansi.YELLOW)
line = f" {glyph} {text}"
if detail:
line += f" {color(detail, _Ansi.DIM)}"
print(line)
def check_fail(text: str, detail: str = "") -> None:
"""Print a failure check line."""
glyph = color("", _Ansi.RED)
line = f" {glyph} {text}"
if detail:
line += f" {color(detail, _Ansi.DIM)}"
print(line)
def check_info(text: str) -> None:
"""Print an informational check line."""
glyph = color("", _Ansi.CYAN)
print(f" {glyph} {text}")
def section(title: str) -> None:
"""Print a section banner: blank line + bold cyan ◆ title."""
print()
print(color(f"{title}", _Ansi.CYAN, _Ansi.BOLD))
def print_banner() -> None:
"""Print the doctor header banner."""
print()
print(color("┌─────────────────────────────────────────────────────────┐", _Ansi.CYAN))
print(color("│ 🩺 Hermes Doctor │", _Ansi.CYAN))
print(color("└─────────────────────────────────────────────────────────┘", _Ansi.CYAN))
print()
+356
View File
@@ -0,0 +1,356 @@
"""Check registration and diagnostic reporting framework.
Pure stdlib no external dependencies.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from typing import Callable
from hermes_cli.doctor._output import (
check_ok,
check_warn,
check_fail,
check_info,
section as _print_section,
color,
_Ansi,
)
# ── Registry ──────────────────────────────────────────────────────────────
@dataclass
class RegisteredCheck:
section: str
name: str
fn: Callable
priority: int = 0
_CHECKS: list[RegisteredCheck] = []
def register(section: str, name: str = "", priority: int = 0) -> Callable:
"""Decorator to register a diagnostic check.
Args:
section: Section heading this check appears under.
name: Short identifier for this specific sub-check.
Used in the auto-caught exception message.
Defaults to the function name.
priority: Run order within the section lower runs first (default 0).
The decorated function receives a single ``DiagnosticReport`` argument.
Any uncaught exception is caught by the runner, which emits a warning
and continues checks don't need defensive blanket try/except.
Example::
@register("Python Environment", "python-version")
def check_python_version(report):
py = sys.version_info
if py >= (3, 11):
report.ok(f"Python {py.major}.{py.minor}.{py.micro}")
else:
report.fail(
"Python too old",
detail="(3.11+ required)",
fix="Upgrade Python to 3.10+",
)
For auto-fixable issues pass a ``fix_fn`` callable to ``report.fail`` or
``report.add_issue``::
@register("Config Files", "env-file")
def check_env_file(report):
if not env_path.exists():
def _fix(report):
env_path.touch()
report.ok("Created empty .env")
report.fail(".env missing", fix="run hermes setup", fix_fn=_fix)
"""
def decorator(fn: Callable) -> Callable:
_CHECKS.append(RegisteredCheck(
section=section,
name=name or fn.__name__,
fn=fn,
priority=priority,
))
return fn
return decorator
def get_registered_checks() -> list[RegisteredCheck]:
"""Return all registered checks in run order."""
seen: dict[str, int] = {}
order = 0
for c in _CHECKS:
if c.section not in seen:
seen[c.section] = order
order += 1
return sorted(_CHECKS, key=lambda c: (seen[c.section], c.priority, c.name))
# ── Issue record ──────────────────────────────────────────────────────────
@dataclass
class _Issue:
"""An issue collected during a check run."""
text: str
fix_fn: Callable | None = None # None → manual-only
section: str = ""
check: str = ""
@dataclass
class _Warning:
"""A warning collected during a check run."""
text: str
section: str = ""
check: str = ""
# ── Diagnostic Report ────────────────────────────────────────────────────
class DiagnosticReport:
"""Passed to every check function.
Section headers are deferred: a header only prints when the first
finding (ok/warn/fail/info) is emitted under it, so checks that
return early without output never print stray banners.
Fix model
---------
Every ``fail()`` and ``add_issue()`` call accepts an optional
``fix_fn`` and an optional human-readable ``fix`` string.
* ``fix_fn`` zero-arg callable executed when ``--fix`` is active.
It receives the report so it can emit ok/warn/fail/info
lines describing what it did.
* ``fix`` short instruction shown in the issue summary when
``--fix`` is NOT active or when no ``fix_fn`` was given.
In ``--fix`` mode:
- issues *with* a ``fix_fn`` are executed immediately; the issue
is removed from the summary if the fn succeeds.
- issues *without* a ``fix_fn`` remain in the summary as manual.
In normal mode:
- issues *with* a ``fix_fn`` are shown as "✦ fixable — re-run with --fix"
- issues *without* a ``fix_fn`` are shown as "✗ manual"
"""
def __init__(self, should_fix: bool = False) -> None:
self._should_fix = should_fix
self._issues: list[_Issue] = []
self._fixed_issues: list[_Issue] = []
self._warnings: list[_Warning] = []
self._fixed: int = 0
self._pending_section: str | None = None
self._printed_section: str | None = None
# Set by the runner before each check so findings know their origin
self._current_section: str = ""
self._current_check: str = ""
# ── Section management ────────────────────────────────────────────────
def section(self, title: str) -> None:
"""Declare the current section (header is deferred until first output)."""
self._pending_section = title
self._current_section = title
def _flush_section(self) -> None:
if self._pending_section and self._pending_section != self._printed_section:
_print_section(self._pending_section)
self._printed_section = self._pending_section
# ── Finding emitters ─────────────────────────────────────────────────
def ok(self, text: str, detail: str = "") -> None:
self._flush_section()
check_ok(text, detail)
def warn(self, text: str, detail: str = "") -> None:
self._flush_section()
check_warn(text, detail)
label = text + (f" {detail}" if detail else "")
self._warnings.append(_Warning(label, self._current_section, self._current_check))
def fail(
self,
text: str,
detail: str = "",
*,
fix: str = "",
fix_fn: Callable | None = None,
) -> None:
"""Emit a ✗ failure line and record the issue.
Args:
text: Primary description of the problem.
detail: Optional dim detail suffix on the same line.
fix: Short human-readable instruction shown in the summary.
fix_fn: Zero-arg callable that auto-fixes the problem when
called. It receives this report for output. If
``--fix`` is active it is called immediately;
otherwise the issue is annotated as auto-fixable.
"""
self._flush_section()
check_fail(text, detail)
self._record_issue(fix or text, fix_fn)
def info(self, text: str) -> None:
self._flush_section()
check_info(text)
def add_issue(
self,
text: str,
*,
fix_fn: Callable | None = None,
) -> None:
"""Add an issue to the summary without printing a fail line.
Use when a check wants to register a problem that was already
surfaced via warn() but still deserves a summary entry.
Args:
text: Issue description for the summary.
fix_fn: Optional auto-fix callable (same semantics as fail).
"""
self._record_issue(text, fix_fn)
# ── Internal ─────────────────────────────────────────────────────────
def _record_issue(self, text: str, fix_fn: Callable | None) -> None:
issue = _Issue(text, fix_fn, self._current_section, self._current_check)
if self._should_fix and fix_fn is not None:
try:
fix_fn(self)
self._fixed += 1
self._fixed_issues.append(issue)
return
except Exception as exc:
check_warn(f"Auto-fix failed", f"({type(exc).__name__}: {exc})")
# Fall through and add to remaining issues
self._issues.append(issue)
# ── Colour helpers ────────────────────────────────────────────────────
def color(self, text: str, *codes: str) -> str:
return color(text, *codes)
GREEN = _Ansi.GREEN
YELLOW = _Ansi.YELLOW
RED = _Ansi.RED
CYAN = _Ansi.CYAN
DIM = _Ansi.DIM
BOLD = _Ansi.BOLD
def raw_print(self, text: str = "") -> None:
self._flush_section()
print(text)
# ── Summary ───────────────────────────────────────────────────────────
def print_summary(self) -> None:
fixable = [i for i in self._issues if i.fix_fn is not None]
manual = [i for i in self._issues if i.fix_fn is None]
print()
# ── --fix mode: report what was done, then leftover sections ─────
if self._should_fix and self._fixed > 0:
if not self._issues:
print(color("" * 60, _Ansi.GREEN))
print(color(
f" ✓ Fixed {self._fixed} issue(s). All checks passed! 🎉",
_Ansi.GREEN, _Ansi.BOLD,
))
print()
_render_grouped(self._fixed_issues, bullet="")
print()
return
print(color("" * 60, _Ansi.YELLOW))
print(
color(f" ✓ Fixed {self._fixed} issue(s).", _Ansi.GREEN, _Ansi.BOLD)
+ color(f" {len(self._issues)} still require attention.", _Ansi.YELLOW)
)
print()
_render_grouped(self._fixed_issues, bullet="")
print()
# ── All-clear ────────────────────────────────────────────────────
elif not self._issues:
print(color("" * 60, _Ansi.GREEN))
print(color(" All checks passed! 🎉", _Ansi.GREEN, _Ansi.BOLD))
print()
return
# ── Header (no-fix mode) ─────────────────────────────────────────
else:
print(color("" * 60, _Ansi.YELLOW))
# ── Auto-fixable issues ──────────────────────────────────────────
if fixable:
print(color(
f"{len(fixable)} auto-fixable issue{"" if len(fixable) == 1 else "s"}"
+ (" — run `hermes doctor --fix` to resolve:" if not self._should_fix else ":"),
_Ansi.CYAN, _Ansi.BOLD,
))
_render_grouped(fixable)
print()
# ── Manual issues ────────────────────────────────────────────────
if manual:
print(color(f"{len(manual)} issue{"" if len(manual) == 1 else "s"} require manual attention:", _Ansi.RED, _Ansi.BOLD))
_render_grouped(manual)
print()
# ── Warnings (informational, not blocking) ───────────────────────
if self._warnings:
print(color(f"{len(self._warnings)} warning{"" if len(self._warnings) == 1 else "s"}:", _Ansi.YELLOW, _Ansi.BOLD))
_render_grouped(self._warnings)
print()
def _fmt_label(section: str, check: str) -> str:
"""Unused — kept for any external callers; grouping is now done in print_summary."""
return ""
def _render_grouped(items: list, bullet: str = "") -> None:
"""Render a list of _Issue or _Warning grouped by section, with sub-headers."""
# Preserve insertion order of sections
seen: dict[str, list] = {}
for item in items:
key = item.section or ""
seen.setdefault(key, []).append(item)
for section_name, group in seen.items():
if section_name:
print(f" {color(section_name, _Ansi.DIM)}")
for item in group:
text = item.text if hasattr(item, "text") else item
print(f" {bullet} {text}")
# ── Runner ────────────────────────────────────────────────────────────────
def run_checks(report: DiagnosticReport) -> None:
"""Run all registered checks, catching any unexpected exceptions."""
for check in get_registered_checks():
report.section(check.section)
report._current_section = check.section
report._current_check = check.name
try:
check.fn(report)
except Exception as exc:
report.warn(
f"Check '{check.name}' failed unexpectedly",
f"({type(exc).__name__}: {exc})",
)
+65
View File
@@ -0,0 +1,65 @@
"""Core types for the doctor diagnostic framework.
Pure stdlib no external dependencies.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable
class Severity(Enum):
"""Diagnostic severity levels."""
OK = "ok"
INFO = "info"
WARN = "warn"
FAIL = "fail"
@dataclass
class Finding:
"""A single diagnostic finding from a check."""
severity: Severity
text: str
detail: str = ""
fix: str = "" # Fix instruction for the summary
auto_fixable: bool = False
# Convenience constructors
def ok(text: str, detail: str = "") -> Finding:
"""Create an OK finding."""
return Finding(Severity.OK, text, detail)
def info(text: str) -> Finding:
"""Create an informational finding."""
return Finding(Severity.INFO, text)
def warn(text: str, detail: str = "") -> Finding:
"""Create a warning finding."""
return Finding(Severity.WARN, text, detail)
def fail(text: str, detail: str = "", fix: str = "") -> Finding:
"""Create a failure finding with optional fix instruction."""
return Finding(Severity.FAIL, text, detail, fix)
@dataclass
class CheckResult:
"""Result from running a single check function."""
section: str
findings: list[Finding] = field(default_factory=list)
@dataclass
class Check:
"""A registered diagnostic check."""
name: str
section: str
fn: Callable # (ctx: DoctorContext, report: DiagnosticReport) -> None
priority: int = 0 # Ordering within section (lower = earlier)
+19
View File
@@ -0,0 +1,19 @@
"""Doctor checks package — importing this registers all checks."""
from hermes_cli.doctor.checks import ( # noqa: F401 — side-effect imports
python_env,
security,
dep_mgmt,
config_files,
xai_retirement,
auth_providers,
directory_structure,
gateway_service,
command_install,
external_tools,
api_connectivity,
tool_availability,
skills_hub,
memory_provider,
profiles,
)
+123
View File
@@ -0,0 +1,123 @@
"""Shared utility functions for doctor checks.
Pure stdlib no external dependencies.
"""
from __future__ import annotations
import os
import shutil
import sys
from pathlib import Path
def _safe_which(cmd: str) -> str | None:
"""shutil.which wrapper resilient to platform monkeypatching in tests."""
try:
return shutil.which(cmd)
except Exception:
return None
def is_termux() -> bool:
"""Return True when running inside Termux on Android."""
return bool(
os.environ.get("TERMUX_VERSION")
or "com.termux/files" in os.environ.get("PREFIX", "")
)
# Re-export as the private name tests expect
_is_termux = is_termux
def python_install_cmd() -> str:
"""Return the pip install command appropriate for the platform."""
return "python -m pip install" if is_termux() else "uv pip install"
def system_package_install_cmd(pkg: str) -> str:
"""Return the package manager install command for the given package."""
if is_termux():
return f"pkg install {pkg}"
if sys.platform == "darwin":
return f"brew install {pkg}"
return f"sudo apt install {pkg}"
def termux_browser_setup_steps(node_installed: bool) -> list[str]:
"""Return ordered setup steps for browser tools on Termux."""
steps: list[str] = []
step = 1
if not node_installed:
steps.append(f"{step}) pkg install nodejs")
step += 1
steps.append(f"{step}) npm install -g agent-browser")
steps.append(f"{step + 1}) agent-browser install")
return steps
def termux_install_all_fallback_notes() -> list[str]:
"""Return informational notes for Termux compatibility."""
return [
"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).",
"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).",
"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).",
"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).",
]
def resolve_project_root() -> Path:
"""Resolve the hermes-agent project root directory.
Lightweight stdlib-only resolution: walks up from this file to find
pyproject.toml, which marks the project root.
"""
# This file is at hermes_cli/doctor/checks/_helpers.py
# Project root is 4 levels up
here = Path(__file__).resolve()
candidate = here.parent.parent.parent.parent # hermes_cli/doctor/checks/_helpers.py -> hermes_cli -> project root
# Verify by checking pyproject.toml or setup.py exists
if (candidate / "pyproject.toml").exists() or (candidate / "setup.py").exists():
return candidate
# Fallback: try parent.parent.parent (if the file moved)
for parent in here.parents:
if (parent / "pyproject.toml").exists():
return parent
# Last resort
return candidate
def resolve_hermes_home() -> Path:
"""Resolve the Hermes home directory.
Reads HERMES_HOME env var, falls back to platform-native default.
Lightweight stdlib-only implementation.
"""
val = os.environ.get("HERMES_HOME", "").strip()
if val:
return Path(val)
if sys.platform == "win32":
local_appdata = os.environ.get("LOCALAPPDATA", "").strip()
base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local"
return base / "hermes"
# Check for active profile
default_home = Path.home() / ".hermes"
try:
active_path = default_home / "active_profile"
active = active_path.read_text().strip() if active_path.exists() else ""
except (OSError, UnicodeDecodeError):
active = ""
if active and active != "default":
return default_home / "profiles" / active
return default_home
def resolve_display_hermes_home() -> str:
"""Return a user-friendly display path for HERMES_HOME."""
home = resolve_hermes_home()
try:
rel = home.relative_to(Path.home())
return f"~/{rel}"
except ValueError:
return str(home)
@@ -0,0 +1,304 @@
"""API connectivity checks — run in parallel."""
from __future__ import annotations
import concurrent.futures
import os
from hermes_cli.doctor._registry import register
from hermes_cli.doctor._output import color, _Ansi
# ── Individual probes ─────────────────────────────────────────────────────
# Each returns (label, lines, issues) where lines = list of (glyph, label, detail).
def _probe_openrouter():
from hermes_constants import OPENROUTER_MODELS_URL
from hermes_cli.models import _HERMES_USER_AGENT
key = os.getenv("OPENROUTER_API_KEY")
if not key:
return ("OpenRouter API", [(color("", _Ansi.YELLOW), "OpenRouter API", color("(not configured)", _Ansi.DIM))], [])
import httpx
r = httpx.get(OPENROUTER_MODELS_URL, headers={"Authorization": f"Bearer {key}"}, timeout=10)
if r.status_code == 200:
return ("OpenRouter API", [(color("", _Ansi.GREEN), "OpenRouter API", "")], [])
if r.status_code == 401:
return ("OpenRouter API", [(color("", _Ansi.RED), "OpenRouter API", color("(invalid API key)", _Ansi.DIM))], ["Check OPENROUTER_API_KEY in .env"])
if r.status_code == 402:
return ("OpenRouter API", [(color("", _Ansi.RED), "OpenRouter API", color("(out of credits — payment required)", _Ansi.DIM))],
["OpenRouter account has insufficient credits. Fix: run 'hermes config set model.provider <provider>' to switch providers, or fund your OpenRouter account at https://openrouter.ai/settings/credits"])
if r.status_code == 429:
return ("OpenRouter API", [(color("", _Ansi.RED), "OpenRouter API", color("(rate limited)", _Ansi.DIM))],
["OpenRouter rate limit hit — consider switching to a different provider or waiting"])
return ("OpenRouter API", [(color("", _Ansi.RED), "OpenRouter API", color(f"(HTTP {r.status_code})", _Ansi.DIM))], [])
def _probe_anthropic():
from hermes_cli.auth import get_anthropic_key
key = get_anthropic_key()
if not key:
return ("Anthropic API", [], [])
import httpx
from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA
headers = {"anthropic-version": "2023-06-01"}
is_oauth = _is_oauth_token(key)
if is_oauth:
headers["Authorization"] = f"Bearer {key}"
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
else:
headers["x-api-key"] = key
r = httpx.get("https://api.anthropic.com/v1/models", headers=headers, timeout=10)
if is_oauth and r.status_code == 400 and "long context beta" in r.text.lower() and "not yet available" in r.text.lower():
headers["anthropic-beta"] = ",".join(
[b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)
)
r = httpx.get("https://api.anthropic.com/v1/models", headers=headers, timeout=10)
if r.status_code == 200:
return ("Anthropic API", [(color("", _Ansi.GREEN), "Anthropic API", "")], [])
if r.status_code == 401:
return ("Anthropic API", [(color("", _Ansi.RED), "Anthropic API", color("(invalid API key)", _Ansi.DIM))], [])
return ("Anthropic API", [(color("", _Ansi.YELLOW), "Anthropic API", color("(couldn't verify)", _Ansi.DIM))], [])
def _probe_apikey_provider(pname, env_vars, default_url, base_env, supports_hc):
key = ""
for ev in env_vars:
key = os.getenv(ev, "")
if key:
break
if not key:
return (pname, [], [])
label = pname.ljust(20)
if not supports_hc:
return (pname, [(color("", _Ansi.GREEN), label, color("(key configured)", _Ansi.DIM))], [])
import httpx
from hermes_cli.models import _HERMES_USER_AGENT
from utils import base_url_host_matches
base = os.getenv(base_env, "") if base_env else ""
if not base and key.startswith("sk-kimi-"):
base = "https://api.kimi.com/coding/v1"
if base and base.rstrip("/").endswith("/anthropic"):
from agent.auxiliary_client import _to_openai_base_url
base = _to_openai_base_url(base)
if base_url_host_matches(base, "api.kimi.com") and base.rstrip("/").endswith("/coding"):
base = base.rstrip("/") + "/v1"
url = (base.rstrip("/") + "/models") if base else default_url
headers = {"Authorization": f"Bearer {key}", "User-Agent": _HERMES_USER_AGENT}
if base_url_host_matches(base, "api.kimi.com"):
headers["User-Agent"] = "claude-code/0.1.0"
if url and base_url_host_matches(url, "generativelanguage.googleapis.com"):
headers.pop("Authorization", None)
headers["x-goog-api-key"] = key
r = httpx.get(url, headers=headers, timeout=10)
if pname == "Alibaba/DashScope" and not base and r.status_code == 401:
r = httpx.get("https://dashscope.aliyuncs.com/compatible-mode/v1/models", headers=headers, timeout=10)
if r.status_code == 200:
return (pname, [(color("", _Ansi.GREEN), label, "")], [])
if r.status_code == 401:
return (pname, [(color("", _Ansi.RED), label, color("(invalid API key)", _Ansi.DIM))], [f"Check {env_vars[0]} in .env"])
return (pname, [(color("", _Ansi.YELLOW), label, color(f"(HTTP {r.status_code})", _Ansi.DIM))], [])
def _probe_bedrock():
from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
if not has_aws_credentials():
return ("AWS Bedrock", [], [])
import boto3
from botocore.config import Config as _BotoConfig
auth_var = resolve_aws_auth_env_var()
region = resolve_bedrock_region()
label = "AWS Bedrock".ljust(20)
cfg = _BotoConfig(connect_timeout=5, read_timeout=10, retries={"max_attempts": 1})
client = boto3.client("bedrock", region_name=region, config=cfg)
resp = client.list_foundation_models()
n = len(resp.get("modelSummaries", []))
return ("AWS Bedrock", [(color("", _Ansi.GREEN), label, color(f"({auth_var}, {region}, {n} models)", _Ansi.DIM))], [])
def _probe_azure_entra():
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model") if isinstance(cfg, dict) else {}
if not isinstance(model_cfg, dict):
return ("Azure Foundry (Entra ID)", [], [])
if str(model_cfg.get("provider") or "").strip().lower() != "azure-foundry":
return ("Azure Foundry (Entra ID)", [], [])
if str(model_cfg.get("auth_mode") or "").strip().lower() != "entra_id":
return ("Azure Foundry (Entra ID)", [], [])
label = "Azure Foundry (Entra ID)".ljust(28)
from agent.azure_identity_adapter import (
EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT,
describe_active_credential, has_azure_identity_installed,
)
if not has_azure_identity_installed():
return ("Azure Foundry (Entra ID)",
[(color("", _Ansi.YELLOW), label, color("(azure-identity not installed)", _Ansi.DIM))],
["Install azure-identity: uv pip install azure-identity"])
entra_cfg = model_cfg.get("entra") or {}
if not isinstance(entra_cfg, dict):
entra_cfg = {}
scope = str(entra_cfg.get("scope") or "").strip() or SCOPE_AI_AZURE_DEFAULT
info = describe_active_credential(config=EntraIdentityConfig(scope=scope), timeout_seconds=10.0)
if info.get("ok"):
env_sources = info.get("env_sources") or []
tag = ", ".join(env_sources) if env_sources else "default credential chain"
return ("Azure Foundry (Entra ID)",
[(color("", _Ansi.GREEN), label, color(f"({tag}, scope={scope})", _Ansi.DIM))], [])
err = info.get("error") or "credential chain exhausted"
hint = info.get("hint") or (
"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET, "
"or attach a managed identity to this VM."
)
return ("Azure Foundry (Entra ID)",
[(color("", _Ansi.YELLOW), label, color(f"({err})", _Ansi.DIM))],
[f"Azure Foundry Entra: {err}. {hint}"])
def _has_healthy_oauth_fallback(provider_label: str) -> bool:
normalized = (provider_label or "").strip().lower()
if normalized in {"google / gemini", "gemini"}:
try:
from hermes_cli.auth import get_gemini_oauth_auth_status
return bool((get_gemini_oauth_auth_status() or {}).get("logged_in"))
except Exception:
return False
if normalized == "minimax":
try:
from hermes_cli.auth import get_minimax_oauth_auth_status
return bool((get_minimax_oauth_auth_status() or {}).get("logged_in"))
except Exception:
return False
if normalized == "xai":
try:
from hermes_cli.auth import get_xai_oauth_auth_status
return bool((get_xai_oauth_auth_status() or {}).get("logged_in"))
except Exception:
return False
return False
# Cache for the expensive provider list build
_APIKEY_PROVIDERS_CACHE: list | None = None
def _build_apikey_providers_list() -> list:
"""Build the API-key provider health-check list and cache it."""
_static = [
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
("StepFun Step Plan", ("STEPFUN_API_KEY",), "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True),
("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True),
("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
("GMI Cloud", ("GMI_API_KEY",), "https://api.gmi-serving.com/v1/models", "GMI_BASE_URL", True),
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
("NVIDIA NIM", ("NVIDIA_API_KEY",), "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True),
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", False),
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
("OpenCode Go", ("OPENCODE_GO_API_KEY",), None, "OPENCODE_GO_BASE_URL", False),
]
_known_names = {t[0] for t in _static}
_name_to_canonical = {
"Z.AI / GLM": "zai", "Kimi / Moonshot": "kimi-coding",
"StepFun Step Plan": "stepfun", "Kimi / Moonshot (China)": "kimi-coding-cn",
"Arcee AI": "arcee", "GMI Cloud": "gmi", "DeepSeek": "deepseek",
"Hugging Face": "huggingface", "NVIDIA NIM": "nvidia",
"Alibaba/DashScope": "alibaba", "MiniMax": "minimax",
"MiniMax (China)": "minimax-cn",
"Kilo Code": "kilocode", "OpenCode Zen": "opencode-zen",
"OpenCode Go": "opencode-go",
}
_known_canonical = set(_name_to_canonical.values())
_dedicated = {"anthropic", "openrouter", "bedrock"}
_known_canonical.update(_dedicated)
try:
from providers import list_providers
from providers.base import ProviderProfile as _PP
try:
from hermes_cli.providers import normalize_provider as _nrm
except Exception:
def _nrm(n): return (n or "").strip().lower()
for pp in list_providers():
if not isinstance(pp, _PP) or pp.auth_type != "api_key" or not pp.env_vars:
continue
label = pp.display_name or pp.name
if label in _known_names or pp.name in _known_canonical:
continue
candidates = {_nrm(pp.name)} | {_nrm(a) for a in (pp.aliases or ())}
if candidates & _dedicated:
continue
key_vars = tuple(v for v in pp.env_vars if not v.endswith(("_BASE_URL", "_URL")))
base_var = next((v for v in pp.env_vars if v.endswith(("_BASE_URL", "_URL"))), None)
if not key_vars:
continue
models_url = (
(pp.models_url or (pp.base_url.rstrip("/") + "/models")) if pp.base_url else None
)
hc = getattr(pp, "supports_health_check", True)
_static.append((label, key_vars, models_url, base_var, hc))
except Exception:
pass
return _static
@register("API Connectivity", "api-connectivity", priority=10)
def check_api_connectivity(report):
global _APIKEY_PROVIDERS_CACHE
if _APIKEY_PROVIDERS_CACHE is None:
_APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()
probes = [("OpenRouter API", _probe_openrouter), ("Anthropic API", _probe_anthropic)]
for pname, env_vars, default_url, base_env, supports in _APIKEY_PROVIDERS_CACHE:
probes.append((pname, lambda p=pname, e=env_vars, u=default_url, b=base_env, s=supports:
_probe_apikey_provider(p, e, u, b, s)))
probes.append(("AWS Bedrock", _probe_bedrock))
probes.append(("Azure Foundry (Entra ID)", _probe_azure_entra))
print(
f" {color(f'Running {len(probes)} connectivity checks in parallel…', _Ansi.DIM)}",
end="", flush=True,
)
prev_imds = os.environ.get("AWS_EC2_METADATA_DISABLED")
os.environ["AWS_EC2_METADATA_DISABLED"] = "true"
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="doctor-probe") as ex:
futures = [ex.submit(fn) for _, fn in probes]
results = []
for f in futures:
try:
results.append(f.result())
except Exception as exc:
results.append((None, [(color("", _Ansi.YELLOW), "probe", color(f"({exc})", _Ansi.DIM))], []))
finally:
if prev_imds is None:
os.environ.pop("AWS_EC2_METADATA_DISABLED", None)
else:
os.environ["AWS_EC2_METADATA_DISABLED"] = prev_imds
print("\r" + " " * 70 + "\r", end="")
for label, lines, issues in results:
for glyph, lbl, detail in lines:
if detail:
print(f" {glyph} {lbl} {detail}")
else:
print(f" {glyph} {lbl}")
if issues and not _has_healthy_oauth_fallback(label or ""):
for issue in issues:
report.add_issue(issue)
@@ -0,0 +1,67 @@
"""Auth provider checks."""
from __future__ import annotations
from hermes_cli.doctor._registry import register
from hermes_cli.doctor.checks._helpers import _safe_which
@register("Auth Providers", "nous-auth", priority=10)
def check_nous_auth(report):
from hermes_cli.auth import get_nous_auth_status
status = get_nous_auth_status()
if status.get("logged_in"):
report.ok("Nous Portal auth", "(logged in)")
else:
report.warn("Nous Portal auth", "(not logged in)")
@register("Auth Providers", "codex-auth", priority=20)
def check_codex_auth(report):
from hermes_cli.auth import get_codex_auth_status
status = get_codex_auth_status()
if status.get("logged_in"):
report.ok("OpenAI Codex auth", "(logged in)")
else:
report.warn("OpenAI Codex auth", "(not logged in)")
if status.get("error"):
report.info(status["error"])
if not _safe_which("codex"):
report.info(
"codex CLI not installed "
"(optional — only required to import tokens from an existing Codex CLI login)"
)
@register("Auth Providers", "gemini-oauth", priority=30)
def check_gemini_oauth(report):
from hermes_cli.auth import get_gemini_oauth_auth_status
status = get_gemini_oauth_auth_status()
if status.get("logged_in"):
pieces = [x for x in [status.get("email"), status.get("project_id") and f"project={status['project_id']}"] if x]
suffix = f" ({', '.join(pieces)})" if pieces else ""
report.ok("Google Gemini OAuth", f"(logged in{suffix})")
else:
report.warn("Google Gemini OAuth", "(not logged in)")
@register("Auth Providers", "minimax-oauth", priority=40)
def check_minimax_oauth(report):
from hermes_cli.auth import get_minimax_oauth_auth_status
status = get_minimax_oauth_auth_status()
if status.get("logged_in"):
report.ok("MiniMax OAuth", f"(logged in, region={status.get('region', 'global')})")
else:
report.warn("MiniMax OAuth", "(not logged in)")
@register("Auth Providers", "xai-oauth", priority=50)
def check_xai_oauth(report):
from hermes_cli.auth import get_xai_oauth_auth_status
status = get_xai_oauth_auth_status() or {}
if status.get("logged_in"):
report.ok("xAI OAuth", "(logged in)")
else:
report.warn("xAI OAuth", "(not logged in)")
if status.get("error"):
report.info(status["error"])
@@ -0,0 +1,84 @@
"""Command installation checks."""
from __future__ import annotations
import os
import sys
from pathlib import Path
from hermes_cli.doctor._registry import register
@register("Command Installation", "symlink-check", priority=10)
def check_command_installation(report):
if sys.platform == "win32":
return
from hermes_cli.doctor import PROJECT_ROOT
venv_bin = None
for venv_name in ("venv", ".venv"):
c = PROJECT_ROOT / venv_name / "bin" / "hermes"
if c.exists():
venv_bin = c
break
prefix = os.environ.get("PREFIX", "")
is_termux = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in prefix
if is_termux and prefix:
cmd_dir = Path(prefix) / "bin"
cmd_display = "$PREFIX/bin"
else:
cmd_dir = Path.home() / ".local" / "bin"
cmd_display = "~/.local/bin"
cmd_link = cmd_dir / "hermes"
if venv_bin is None:
report.warn(
"Venv entry point not found",
"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')",
)
report.add_issue(
f"reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'"
)
return
report.ok(f"Venv entry point exists ({venv_bin.relative_to(PROJECT_ROOT)})")
if cmd_link.is_symlink():
target = cmd_link.resolve()
expected = venv_bin.resolve()
if target == expected:
report.ok(f"{cmd_display}/hermes → correct target")
else:
def _fix(r):
cmd_link.unlink()
cmd_link.symlink_to(venv_bin)
r.ok(f"Fixed symlink: {cmd_display}/hermes → {venv_bin}")
report.warn(
f"{cmd_display}/hermes points to wrong target",
f"(→ {target}, expected → {expected})",
)
report.add_issue(f"broken symlink at {cmd_display}/hermes", fix_fn=_fix)
elif cmd_link.exists():
report.ok(f"{cmd_display}/hermes exists (non-symlink)")
else:
def _fix(r):
cmd_dir.mkdir(parents=True, exist_ok=True)
cmd_link.symlink_to(venv_bin)
r.ok(f"Created symlink: {cmd_display}/hermes → {venv_bin}")
path_dirs = os.environ.get("PATH", "").split(os.pathsep)
if str(cmd_dir) not in path_dirs:
r.warn(
f"{cmd_display} is not on your PATH",
'(add it to your shell config: export PATH="$HOME/.local/bin:$PATH")',
)
r.add_issue(f"add {cmd_display} to your PATH")
report.fail(
f"{cmd_display}/hermes not found",
"(hermes command may not work outside the venv)",
fix=f"run `hermes doctor --fix` to create symlink",
fix_fn=_fix,
)
+334
View File
@@ -0,0 +1,334 @@
"""Configuration file checks."""
from __future__ import annotations
import os
import shutil
from hermes_cli.doctor._registry import register
_PROVIDER_ENV_HINTS = (
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
"OPENAI_BASE_URL", "NOUS_API_KEY", "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY",
"KIMI_API_KEY", "KIMI_CN_API_KEY", "GMI_API_KEY", "MINIMAX_API_KEY",
"MINIMAX_CN_API_KEY", "KILOCODE_API_KEY", "DEEPSEEK_API_KEY", "DASHSCOPE_API_KEY",
"HF_TOKEN", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY", "XIAOMI_API_KEY",
"TOKENHUB_API_KEY",
)
def _has_provider_env_config(content: str) -> bool:
return any(key in content for key in _PROVIDER_ENV_HINTS)
@register("Configuration Files", "env-file", priority=10)
def check_env_file(report):
from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT, _DHH
env_path = HERMES_HOME / ".env"
if env_path.exists():
report.ok(f"{_DHH}/.env file exists")
content = env_path.read_text(encoding="utf-8")
if _has_provider_env_config(content):
report.ok("API key or custom endpoint configured")
else:
report.warn(f"No API key found in {_DHH}/.env")
report.add_issue("run 'hermes setup' to configure API keys")
elif (PROJECT_ROOT / ".env").exists():
report.ok(".env file exists (in project directory)")
else:
def _fix(r):
env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.touch()
try:
os.chmod(str(env_path), 0o600)
except OSError:
pass
r.ok(f"Created empty {_DHH}/.env")
r.info("run 'hermes setup' to configure API keys")
report.fail(
f"{_DHH}/.env file missing",
fix="run 'hermes setup' to create one",
fix_fn=_fix,
)
@register("Configuration Files", "config-yaml", priority=20)
def check_config_yaml(report):
from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT, _DHH
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
fallback = PROJECT_ROOT / "cli-config.yaml"
if fallback.exists():
report.ok("cli-config.yaml exists (in project directory)")
return
def _fix(r):
config_path.parent.mkdir(parents=True, exist_ok=True)
example = PROJECT_ROOT / "cli-config.yaml.example"
if example.exists():
shutil.copy2(str(example), str(config_path))
r.ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example")
else:
from hermes_cli.config import DEFAULT_CONFIG, save_config
save_config(DEFAULT_CONFIG)
r.ok(f"Created {_DHH}/config.yaml from defaults")
report.warn(
"config.yaml not found",
"(using defaults)",
)
report.add_issue(
f"{_DHH}/config.yaml missing",
fix_fn=_fix,
)
return
report.ok(f"{_DHH}/config.yaml exists")
_check_model_provider_config(report, config_path)
def _check_model_provider_config(report, config_path):
import yaml as _yaml
from hermes_cli.doctor import _DHH
cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
model_section = cfg.get("model") or {}
provider_raw = (model_section.get("provider") or "").strip()
provider = provider_raw.lower()
default_model = (model_section.get("default") or model_section.get("model") or "").strip()
known_providers: set[str] = set()
_resolve_auth_provider = None
_normalize_catalog_provider = None
_resolve_provider_full = None
try:
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider as _rap
known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto"}
_resolve_auth_provider = _rap
except Exception:
pass
try:
from hermes_cli.providers import normalize_provider as _ncp, resolve_provider_full as _rpf
_normalize_catalog_provider = _ncp
_resolve_provider_full = _rpf
except Exception:
pass
custom_providers = []
try:
from hermes_cli.config import get_compatible_custom_providers
custom_providers = get_compatible_custom_providers(cfg) or []
except Exception:
pass
user_providers = cfg.get("providers")
if isinstance(user_providers, dict):
known_providers.update(str(n).strip().lower() for n in user_providers if str(n).strip())
for entry in custom_providers:
if isinstance(entry, dict):
n = str(entry.get("name") or "").strip()
if n:
known_providers.add("custom:" + n.lower().replace(" ", "-"))
valid_ids = set(known_providers)
if _normalize_catalog_provider:
for kp in known_providers:
try:
valid_ids.add(_normalize_catalog_provider(kp))
except Exception:
pass
accepted = {provider} if provider else set()
runtime_provider = provider
if provider and _resolve_auth_provider and provider not in {"auto", "custom"}:
try:
runtime_provider = _resolve_auth_provider(provider)
accepted.add(runtime_provider)
except Exception:
pass
catalog_provider = provider
if provider and _resolve_provider_full and provider not in {"auto", "custom"}:
pdef = _resolve_provider_full(provider, user_providers, custom_providers)
catalog_provider = pdef.id if pdef is not None else None
if catalog_provider:
accepted.add(catalog_provider)
if provider and provider != "auto":
if catalog_provider is None or (known_providers and not (accepted & valid_ids)):
known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)"
report.fail(
f"model.provider '{provider_raw}' is not a recognised provider",
f"(known: {known_list})",
fix=f"run 'hermes config set model.provider <valid_provider>' — valid: {known_list}",
)
policy_id = str(runtime_provider or catalog_provider or "").strip().lower()
slug_ok_providers = {"openrouter", "auto", "kilocode", "opencode-zen", "huggingface", "lmstudio", "nous"}
slug_ok = policy_id in slug_ok_providers or policy_id == "custom" or policy_id.startswith("custom:")
if default_model and "/" in default_model and policy_id and not slug_ok:
report.warn(
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
"(vendor-prefixed slugs belong to aggregators like openrouter)",
)
report.add_issue(
f"model.default '{default_model}' is vendor-prefixed for provider '{provider_raw}'"
"set model.provider to 'openrouter', or drop the vendor prefix"
)
if runtime_provider and runtime_provider not in ("auto", "custom"):
if runtime_provider == "openrouter":
from hermes_cli.config import get_env_value
configured = bool(
str(get_env_value("OPENROUTER_API_KEY") or "").strip()
or str(get_env_value("OPENAI_API_KEY") or "").strip()
)
else:
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
pconfig = PROVIDER_REGISTRY.get(runtime_provider)
configured = True
if pconfig and getattr(pconfig, "auth_type", "") == "api_key":
status = get_auth_status(runtime_provider) or {}
configured = bool(status.get("configured") or status.get("logged_in") or status.get("api_key"))
if not configured:
report.fail(
f"model.provider '{runtime_provider}' is set but no API key is configured",
"(check ~/.hermes/.env or run 'hermes setup')",
fix=f"run 'hermes setup' or set the API key in {_DHH}/.env",
)
@register("Configuration Files", "config-version", priority=30)
def check_config_version(report):
from hermes_cli.doctor import HERMES_HOME
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
return
from hermes_cli.config import check_config_version as _ccv, migrate_config
current_ver, latest_ver = _ccv()
if current_ver < latest_ver:
def _fix(r):
migrate_config(interactive=False, quiet=False)
r.ok("Config migrated to latest version")
report.warn(f"Config version outdated (v{current_ver} → v{latest_ver})", "(new settings available)")
report.add_issue(
"config.yaml is outdated — run 'hermes setup' to migrate",
fix_fn=_fix,
)
else:
report.ok(f"Config version up to date (v{current_ver})")
@register("Configuration Files", "stale-root-keys", priority=40)
def check_stale_root_keys(report):
from hermes_cli.doctor import HERMES_HOME
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
return
import yaml
with open(config_path, encoding="utf-8") as f:
raw_config = yaml.safe_load(f) or {}
stale = [k for k in ("provider", "base_url") if k in raw_config and isinstance(raw_config[k], str)]
if not stale:
return
def _fix(r):
raw_model = raw_config.get("model")
if isinstance(raw_model, dict):
model_section = raw_model
elif isinstance(raw_model, str) and raw_model.strip():
model_section = {"default": raw_model.strip()}
raw_config["model"] = model_section
else:
model_section = {}
raw_config["model"] = model_section
for k in stale:
if not model_section.get(k):
model_section[k] = raw_config.pop(k)
else:
raw_config.pop(k)
from utils import atomic_yaml_write
atomic_yaml_write(config_path, raw_config)
r.ok("Migrated stale root-level keys into model section")
report.warn(
f"Stale root-level config keys: {', '.join(stale)}",
"(should be under 'model:' section)",
)
report.add_issue(
f"stale root-level keys {stale} in config.yaml",
fix_fn=_fix,
)
@register("Configuration Files", "max-iterations-ghost", priority=50)
def check_max_iterations_ghost(report):
from hermes_cli.doctor import HERMES_HOME, _DHH
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
return
import yaml
from hermes_cli.config import load_env, remove_env_value
with open(config_path, encoding="utf-8") as f:
raw_config = yaml.safe_load(f) or {}
agent_cfg = raw_config.get("agent")
cfg_max_turns = agent_cfg.get("max_turns") if isinstance(agent_cfg, dict) else None
if cfg_max_turns is None:
cfg_max_turns = raw_config.get("max_turns")
env_ghost = load_env().get("HERMES_MAX_ITERATIONS")
if not (cfg_max_turns is not None and env_ghost is not None
and str(cfg_max_turns).strip() != str(env_ghost).strip()):
return
def _fix(r):
if remove_env_value("HERMES_MAX_ITERATIONS"):
r.ok(
"Removed stale HERMES_MAX_ITERATIONS from .env "
f"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)"
)
else:
raise RuntimeError(
f"could not remove HERMES_MAX_ITERATIONS from {_DHH}/.env — edit manually"
)
report.warn(
f"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows agent.max_turns={cfg_max_turns} in config.yaml",
"(stale ghost from an earlier `hermes setup` run)",
)
report.add_issue(
"stale HERMES_MAX_ITERATIONS in .env shadows config.yaml",
fix_fn=_fix,
)
@register("Config Structure", "config-structure-validation", priority=10)
def check_config_structure(report):
from hermes_cli.config import validate_config_structure
config_issues = validate_config_structure()
if not config_issues:
return
for ci in config_issues:
if ci.severity == "error":
report.fail(ci.message)
else:
report.warn(ci.message)
for hint_line in ci.hint.splitlines():
report.info(hint_line)
report.add_issue(ci.message)
+85
View File
@@ -0,0 +1,85 @@
"""Dependency management checks: venv integrity, uv, required packages."""
from __future__ import annotations
import shutil
from hermes_cli.doctor._registry import register
from hermes_cli.doctor.checks._helpers import python_install_cmd
@register("Virtual Environment Integrity", "venv-structure", priority=10)
def check_venv_integrity(report):
from hermes_cli.managed_uv import get_venv_path, resolve_uv
venv_path = get_venv_path()
if not venv_path.exists():
report.warn("Venv directory missing", "(will be recreated on next install/update)")
return
has_uv = bool(resolve_uv() or shutil.which("uv"))
if not has_uv:
def _fix(r):
r.raw_print(" -> Attempting atomic venv recreation...")
from hermes_cli.managed_uv import recreate_venv_atomically, get_venv_path as _gvp
if recreate_venv_atomically(_gvp().parent, group="all"):
r.ok("Venv successfully recreated and swapped to uv-native state")
else:
raise RuntimeError("Recreation failed — please run the Hermes installer")
report.fail(
"Legacy pip venv detected (uv missing)",
"(dependency management will fail)",
fix="run `hermes doctor --fix` to recreate",
fix_fn=_fix,
)
else:
report.ok("Venv structure valid and uv-native")
@register("Dependency Management", "uv-available", priority=10)
def check_uv_available(report):
from hermes_cli.managed_uv import resolve_uv
uv_bin = resolve_uv()
if uv_bin:
report.ok(f"Managed uv available ({uv_bin})")
else:
path_uv = shutil.which("uv")
if path_uv:
report.ok(f"System uv available ({path_uv})")
else:
report.fail(
"uv is missing",
"(dependency installation will fail. Install uv via the Hermes installer, or `pkg install uv` on Termux)",
)
@register("Required Packages", "required-packages", priority=10)
def check_required_packages(report):
required = [
("openai", "OpenAI SDK"),
("rich", "Rich (terminal UI)"),
("dotenv", "python-dotenv"),
("yaml", "PyYAML"),
("httpx", "HTTPX"),
]
optional = [
("croniter", "Croniter (cron expressions)"),
("telegram", "python-telegram-bot"),
("discord", "discord.py"),
]
install_cmd = python_install_cmd()
for module, name in required:
try:
__import__(module)
report.ok(name)
except ImportError:
report.fail(name, "(missing)", fix=f"Install: {install_cmd} {module}")
for module, name in optional:
try:
__import__(module)
report.ok(name, "(optional)")
except ImportError:
report.warn(name, "(optional, not installed)")
@@ -0,0 +1,155 @@
"""Directory structure and state file checks."""
from __future__ import annotations
import sqlite3
from pathlib import Path
from hermes_cli.doctor._registry import register
@register("Directory Structure", "hermes-home", priority=10)
def check_hermes_home_dir(report):
from hermes_cli.doctor import HERMES_HOME, _DHH
if not HERMES_HOME.exists():
def _fix(r):
HERMES_HOME.mkdir(parents=True, exist_ok=True)
r.ok(f"Created {_DHH} directory")
report.warn(f"{_DHH} not found", "(will be created on first use)")
report.add_issue(f"{_DHH} directory missing", fix_fn=_fix)
else:
report.ok(f"{_DHH} directory exists")
for subdir in ("cron", "sessions", "logs", "skills", "memories"):
p = HERMES_HOME / subdir
if not p.exists():
def _fix(r, _p=p, _s=subdir):
_p.mkdir(parents=True, exist_ok=True)
r.ok(f"Created {_DHH}/{_s}/")
report.warn(f"{_DHH}/{subdir}/ not found", "(will be created on first use)")
report.add_issue(f"{_DHH}/{subdir}/ missing", fix_fn=_fix)
else:
report.ok(f"{_DHH}/{subdir}/ exists")
@register("Directory Structure", "soul-md", priority=20)
def check_soul_md(report):
from hermes_cli.doctor import HERMES_HOME, _DHH
soul_path = HERMES_HOME / "SOUL.md"
if soul_path.exists():
content = soul_path.read_text(encoding="utf-8").strip()
lines = [l for l in content.splitlines()
if l.strip() and not l.strip().startswith(("<!--", "-->", "#"))]
if lines:
report.ok(f"{_DHH}/SOUL.md exists (persona configured)")
else:
report.info(f"{_DHH}/SOUL.md exists but is empty — edit it to customize personality")
else:
def _fix(r):
soul_path.parent.mkdir(parents=True, exist_ok=True)
soul_path.write_text(
"# Hermes Agent Persona\n\n"
"<!-- Edit this file to customize how Hermes communicates. -->\n\n"
"You are Hermes, a helpful AI assistant.\n",
encoding="utf-8",
)
r.ok(f"Created {_DHH}/SOUL.md with basic template")
report.warn(f"{_DHH}/SOUL.md not found", "(create it to give Hermes a custom personality)")
report.add_issue(f"{_DHH}/SOUL.md missing", fix_fn=_fix)
@register("Directory Structure", "memories-dir", priority=30)
def check_memories_dir(report):
from hermes_cli.doctor import HERMES_HOME, _DHH
memories_dir = HERMES_HOME / "memories"
if not memories_dir.exists():
def _fix(r):
memories_dir.mkdir(parents=True, exist_ok=True)
r.ok(f"Created {_DHH}/memories/")
report.warn(f"{_DHH}/memories/ not found", "(will be created on first use)")
report.add_issue(f"{_DHH}/memories/ missing", fix_fn=_fix)
return
report.ok(f"{_DHH}/memories/ directory exists")
for fname in ("MEMORY.md", "USER.md"):
fpath = memories_dir / fname
if fpath.exists():
size = len(fpath.read_text(encoding="utf-8").strip())
report.ok(f"{fname} exists ({size} chars)")
else:
report.info(f"{fname} not created yet (will be created when the agent first writes a memory)")
@register("Directory Structure", "state-db", priority=40)
def check_state_db(report):
from hermes_cli.doctor import HERMES_HOME, _DHH
db_path = HERMES_HOME / "state.db"
if not db_path.exists():
report.info(f"{_DHH}/state.db not created yet (will be created on first session)")
return
try:
conn = sqlite3.connect(str(db_path))
count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
conn.close()
report.ok(f"{_DHH}/state.db exists ({count} sessions)")
except Exception as e:
from hermes_state import is_malformed_db_error, repair_state_db_schema
if is_malformed_db_error(e):
def _fix(r):
db_report = repair_state_db_schema(db_path)
if not db_report.get("repaired"):
raise RuntimeError(
f"{db_report.get('error')}; backup at {db_report.get('backup_path')}"
)
try:
conn = sqlite3.connect(str(db_path))
count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
conn.close()
except Exception:
count = "?"
backup = Path(db_report["backup_path"]).name if db_report.get("backup_path") else "n/a"
r.ok(
f"Repaired state.db schema ({count} sessions recovered)",
f"(strategy: {db_report.get('strategy')}; backup: {backup})",
)
report.warn(
f"{_DHH}/state.db schema is malformed (sessions hidden until repaired)",
f"({e})",
)
report.add_issue("state.db schema malformed", fix_fn=_fix)
else:
report.warn(f"{_DHH}/state.db exists but has issues: {e}")
@register("Directory Structure", "wal-file", priority=50)
def check_wal_file(report):
from hermes_cli.doctor import HERMES_HOME, _DHH
db_path = HERMES_HOME / "state.db"
wal_path = HERMES_HOME / "state.db-wal"
if not wal_path.exists() or not db_path.exists():
return
wal_size = wal_path.stat().st_size
if wal_size > 50 * 1024 * 1024:
def _fix(r):
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
conn.close()
new_size = wal_path.stat().st_size if wal_path.exists() else 0
r.ok(f"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)")
report.warn(
f"WAL file is large ({wal_size // (1024 * 1024)} MB)",
"(may indicate missed checkpoints)",
)
report.add_issue("large WAL file — checkpoint needed", fix_fn=_fix)
elif wal_size > 10 * 1024 * 1024:
report.info(f"WAL file is {wal_size // (1024 * 1024)} MB (normal for active sessions)")
+274
View File
@@ -0,0 +1,274 @@
"""External tool checks: git, ripgrep, docker, ssh, daytona, node, npm audit."""
from __future__ import annotations
import os
import subprocess
import sys
from hermes_cli.doctor._registry import register
from hermes_cli.doctor.checks._helpers import (
_safe_which,
is_termux,
system_package_install_cmd,
termux_browser_setup_steps,
termux_install_all_fallback_notes,
)
@register("External Tools", "git", priority=10)
def check_git(report):
if _safe_which("git"):
report.ok("git")
else:
report.warn("git not found", "(hermes update cannot work)")
@register("External Tools", "ripgrep", priority=20)
def check_ripgrep(report):
if _safe_which("rg"):
report.ok("ripgrep (rg)", "(faster file search)")
else:
report.warn("ripgrep (rg) not found", "(file search uses grep fallback)")
report.info(f"Install for faster search: {system_package_install_cmd('ripgrep')}")
@register("External Tools", "docker", priority=30)
def check_docker(report):
from hermes_cli.doctor import PROJECT_ROOT
terminal_env = os.getenv("TERMINAL_ENV", "local")
running_in_container = False
try:
from hermes_constants import is_container as _is_container
running_in_container = _is_container()
except Exception:
pass
if running_in_container and terminal_env != "docker":
report.info(
"Running inside a container — using local terminal backend "
"(docker-in-docker is not configured by default)"
)
return
if terminal_env == "docker":
if not _safe_which("docker"):
report.fail(
"docker not found",
"(required for TERMINAL_ENV=docker)",
fix="Install Docker or change TERMINAL_ENV",
)
return
try:
res = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
except subprocess.TimeoutExpired:
res = None
if res is not None and res.returncode == 0:
report.ok("docker", "(daemon running)")
else:
report.fail("docker daemon not running", "", fix="Start Docker daemon")
elif _safe_which("docker"):
report.ok("docker", "(optional)")
elif is_termux():
report.info("Docker backend is not available inside Termux (expected on Android)")
else:
report.warn("docker not found", "(optional)")
@register("External Tools", "ssh-backend", priority=40)
def check_ssh_backend(report):
terminal_env = os.getenv("TERMINAL_ENV", "local")
if terminal_env != "ssh":
return
ssh_host = os.getenv("TERMINAL_SSH_HOST")
if not ssh_host:
report.fail(
"TERMINAL_SSH_HOST not set",
"(required for TERMINAL_ENV=ssh)",
fix="Set TERMINAL_SSH_HOST in .env",
)
return
ssh_user = os.getenv("TERMINAL_SSH_USER")
ssh_port = os.getenv("TERMINAL_SSH_PORT")
ssh_key = os.getenv("TERMINAL_SSH_KEY")
target = f"{ssh_user}@{ssh_host}" if ssh_user else ssh_host
cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"]
if ssh_port:
cmd += ["-p", ssh_port]
if ssh_key:
cmd += ["-i", os.path.expanduser(ssh_key)]
cmd += [target, "echo ok"]
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except subprocess.TimeoutExpired:
res = None
if res is not None and res.returncode == 0:
report.ok(f"SSH connection to {ssh_host}")
else:
report.fail(
f"SSH connection to {ssh_host}", "",
fix=f"Check SSH configuration for {ssh_host}",
)
@register("External Tools", "daytona-backend", priority=50)
def check_daytona_backend(report):
if os.getenv("TERMINAL_ENV", "local") != "daytona":
return
if not os.getenv("DAYTONA_API_KEY"):
report.fail(
"DAYTONA_API_KEY not set",
"(required for TERMINAL_ENV=daytona)",
fix="Set DAYTONA_API_KEY environment variable",
)
else:
report.ok("Daytona API key", "(configured)")
try:
from daytona import Daytona # noqa: F401
report.ok("daytona SDK", "(installed)")
except ImportError:
report.fail(
"daytona SDK not installed",
"(pip install daytona)",
fix="Install daytona SDK: pip install daytona",
)
@register("External Tools", "node-and-browser", priority=60)
def check_node_and_browser(report):
from hermes_cli.doctor import PROJECT_ROOT
if not _safe_which("node"):
if is_termux():
report.info("Node.js not found (browser tools are optional in the tested Termux path)")
report.info("Install Node.js on Termux with: pkg install nodejs")
report.info("Termux browser setup:")
for step in termux_browser_setup_steps(node_installed=False):
report.info(step)
else:
report.warn("Node.js not found", "(optional, needed for browser tools)")
return
report.ok("Node.js")
agent_browser_ok = False
if (PROJECT_ROOT / "node_modules" / "agent-browser").exists():
report.ok("agent-browser (Node.js)", "(browser automation)")
agent_browser_ok = True
elif _safe_which("agent-browser"):
report.ok("agent-browser", "(browser automation)")
agent_browser_ok = True
elif is_termux():
report.info("agent-browser is not installed (expected in the tested Termux path)")
report.info("Install it manually later with: npm install -g agent-browser && agent-browser install")
report.info("Termux browser setup:")
for step in termux_browser_setup_steps(node_installed=True):
report.info(step)
else:
report.warn("agent-browser not installed", "(run: npm install)")
if agent_browser_ok and not is_termux():
from tools.browser_tool import (
_chromium_installed,
_is_camofox_mode,
_get_cloud_provider,
_get_cdp_override,
_using_lightpanda_engine,
)
skip = (
_is_camofox_mode()
or bool(_get_cdp_override())
or _get_cloud_provider() is not None
or _using_lightpanda_engine()
)
if not skip:
if _chromium_installed():
report.ok("Playwright Chromium", "(browser engine)")
else:
report.warn(
"Playwright Chromium not installed",
"(browser_* tools will be hidden from the agent)",
)
install_cmd = (
f"cd {PROJECT_ROOT} && npx playwright install chromium"
if sys.platform == "win32"
else f"cd {PROJECT_ROOT} && npx playwright install --with-deps chromium"
)
report.info(f"Install with: {install_cmd}")
@register("External Tools", "npm-audit", priority=70)
def check_npm_audit(report):
import json
from hermes_cli.doctor import PROJECT_ROOT
npm_bin = _safe_which("npm")
if not npm_bin:
return
audit_targets = [
(PROJECT_ROOT, "Browser tools (agent-browser)", ["--workspaces=false"]),
(PROJECT_ROOT, "web workspace", ["--workspace", "web"]),
(PROJECT_ROOT, "ui-tui workspace", ["--workspace", "ui-tui"]),
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge", []),
]
for npm_dir, label, extra in audit_targets:
check_dir = PROJECT_ROOT if extra else npm_dir
if not (check_dir / "node_modules").exists():
continue
# best-effort: failures are silently ignored
try:
res = subprocess.run(
[npm_bin, "audit", "--json", *extra],
cwd=str(npm_dir),
capture_output=True, text=True, timeout=30,
)
data = json.loads(res.stdout) if res.stdout.strip() else {}
except Exception:
continue
vc = data.get("metadata", {}).get("vulnerabilities", {})
critical = vc.get("critical", 0)
high = vc.get("high", 0)
moderate = vc.get("moderate", 0)
total = critical + high + moderate
if extra and extra[0] == "--workspace":
fix_cmd = f"cd {npm_dir} && npm audit fix {' '.join(extra)}"
elif extra == ["--workspaces=false"]:
fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false"
else:
fix_cmd = f"cd {npm_dir} && npm audit fix"
if total == 0:
report.ok(f"{label} deps", "(no known vulnerabilities)")
elif critical > 0 or high > 0:
report.warn(
f"{label} deps",
f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})",
)
report.add_issue(
f"{label} has {total} npm "
f"{'vulnerability' if total == 1 else 'vulnerabilities'}"
)
else:
report.ok(
f"{label} deps",
f"({moderate} moderate {'vulnerability' if moderate == 1 else 'vulnerabilities'})",
)
@register("External Tools", "termux-notes", priority=80)
def check_termux_notes(report):
if not is_termux():
return
report.info("Termux compatibility fallbacks:")
for note in termux_install_all_fallback_notes():
report.info(note)
@@ -0,0 +1,54 @@
"""Gateway service checks."""
from __future__ import annotations
import os
from hermes_cli.doctor._registry import register
@register("Gateway Service", "linger-check", priority=10)
def check_gateway_service_linger(report):
from hermes_cli.gateway import get_systemd_linger_status, get_systemd_unit_path, is_linux
from hermes_cli.service_manager import detect_service_manager
if not is_linux() or detect_service_manager() == "s6":
return
unit_path = get_systemd_unit_path()
if not unit_path.exists():
return
linger_enabled, linger_detail = get_systemd_linger_status()
if linger_enabled is True:
report.ok("Systemd linger enabled", "(gateway service survives logout)")
elif linger_enabled is False:
report.warn("Systemd linger disabled", "(gateway may stop after logout)")
report.info("Run: sudo loginctl enable-linger $USER")
report.add_issue("Enable linger for the gateway user service: sudo loginctl enable-linger $USER")
else:
report.warn("Could not verify systemd linger", f"({linger_detail})")
@register("s6 Supervision", "s6-supervision", priority=10)
def check_s6_supervision(report):
from hermes_cli.service_manager import S6ServiceManager, detect_service_manager
if detect_service_manager() != "s6":
return
mgr = S6ServiceManager()
for static in ("main-hermes", "dashboard"):
if mgr.is_running(static):
report.ok(f"{static}: up")
else:
report.info(f"{static}: down (expected if not enabled via env)")
profiles = mgr.list_profile_gateways()
if not profiles:
report.info("No per-profile gateways registered yet — create one with `hermes profile create <name>`")
return
up = sum(1 for p in profiles if mgr.is_running(f"gateway-{p}"))
suffix = f" ({', '.join(sorted(profiles))})" if len(profiles) <= 8 else ""
report.ok(f"Per-profile gateways: {up}/{len(profiles)} supervised up{suffix}")
+36
View File
@@ -0,0 +1,36 @@
"""GitHub auth check."""
from __future__ import annotations
import subprocess
from hermes_cli.doctor._registry import register
@register("Skills Hub", priority=20)
def check_github_auth(report):
"""Check GitHub authentication status."""
from hermes_cli.doctor import _DHH
try:
from hermes_cli.config import get_env_value
except Exception:
return
def _gh_authenticated() -> bool:
try:
result = subprocess.run(
["gh", "auth", "status", "--json", "authenticated"],
capture_output=True, timeout=10,
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN")
if github_token:
report.ok("GitHub token configured (authenticated API access)")
elif _gh_authenticated():
report.ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)")
else:
report.warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)")
@@ -0,0 +1,73 @@
"""Memory provider checks."""
from __future__ import annotations
from hermes_cli.doctor._registry import register
@register("Memory Provider", "memory-provider", priority=10)
def check_memory_provider(report):
from hermes_cli.doctor import HERMES_HOME
provider = ""
import yaml as _yaml
cfg_path = HERMES_HOME / "config.yaml"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
raw = _yaml.safe_load(f) or {}
provider = (raw.get("memory") or {}).get("provider", "")
if not provider:
report.ok("Built-in memory active", "(no external provider configured — this is fine)")
elif provider == "honcho":
_check_honcho(report)
elif provider == "mem0":
_check_mem0(report)
else:
_check_generic(report, provider)
def _check_honcho(report):
from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
hcfg = HonchoClientConfig.from_global_config()
cfg_path = resolve_config_path()
if not cfg_path.exists():
if hcfg.api_key or hcfg.base_url:
report.ok("Honcho configured via environment variables",
f"config file {cfg_path} not found, using HONCHO_API_KEY env var")
else:
report.warn("Honcho config not found", "run: hermes memory setup")
elif not hcfg.enabled:
report.info(f"Honcho disabled (set enabled: true in {cfg_path} to activate)")
elif not (hcfg.api_key or hcfg.base_url):
report.fail("Honcho API key or base URL not set", "run: hermes memory setup",
fix="No Honcho API key — run 'hermes memory setup'")
else:
from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client
reset_honcho_client()
get_honcho_client(hcfg)
report.ok("Honcho connected",
f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}")
def _check_mem0(report):
from plugins.memory.mem0 import _load_config as _lc
cfg = _lc()
if cfg.get("api_key"):
report.ok("Mem0 API key configured")
report.info(f"user_id={cfg.get('user_id', '?')} agent_id={cfg.get('agent_id', '?')}")
else:
report.fail("Mem0 API key not set", "(set MEM0_API_KEY in .env or run hermes memory setup)",
fix="Mem0 is set as memory provider but API key is missing")
def _check_generic(report, provider_name):
from plugins.memory import load_memory_provider
p = load_memory_provider(provider_name)
if p and p.is_available():
report.ok(f"{provider_name} provider active")
elif p:
report.warn(f"{provider_name} configured but not available", "run: hermes memory status")
else:
report.warn(f"{provider_name} plugin not found", "run: hermes memory setup")
+42
View File
@@ -0,0 +1,42 @@
"""Named profiles check."""
from __future__ import annotations
import re
from hermes_cli.doctor._registry import register
@register("Profiles", "named-profiles", priority=10)
def check_profiles(report):
from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists
named = [p for p in list_profiles() if not p.is_default]
if not named:
return
report.ok(f"{len(named)} profile(s) found")
wrapper_dir = _get_wrapper_dir()
for p in named:
parts = []
if p.gateway_running:
parts.append("gateway running")
if p.model:
parts.append(p.model[:30])
if not (p.path / "config.yaml").exists():
parts.append("⚠ missing config")
if not (p.path / ".env").exists():
parts.append("no .env")
if not (wrapper_dir / p.name).exists():
parts.append("no alias")
report.ok(f" {p.name}: {', '.join(parts) if parts else 'configured'}")
if wrapper_dir.is_dir():
for wrapper in wrapper_dir.iterdir():
if not wrapper.is_file():
continue
content = wrapper.read_text()
if "hermes -p" in content:
m = re.search(r"hermes -p (\S+)", content)
if m and not profile_exists(m.group(1)):
report.warn(f"Orphan alias: {wrapper.name} → profile '{m.group(1)}' no longer exists")
+71
View File
@@ -0,0 +1,71 @@
"""Python environment checks."""
from __future__ import annotations
import sys
from hermes_cli.doctor._registry import register
@register("Python Environment", "python-version", priority=10)
def check_python_version(report):
py = sys.version_info
if py >= (3, 11):
report.ok(f"Python {py.major}.{py.minor}.{py.micro}")
elif py >= (3, 10):
report.ok(f"Python {py.major}.{py.minor}.{py.micro}")
report.warn("Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)")
else:
report.fail(
f"Python {py.major}.{py.minor}.{py.micro}",
"(3.10+ required)",
fix="Upgrade Python to 3.10+",
)
@register("Python Environment", "venv-active", priority=20)
def check_venv_active(report):
if sys.prefix != sys.base_prefix:
report.ok("Virtual environment active")
else:
report.warn("Not in virtual environment", "(recommended)")
@register("Python Environment", "version-consistency", priority=30)
def check_version_consistency(report):
"""Verify pyproject.toml version matches hermes_cli.__version__."""
from hermes_cli.doctor import PROJECT_ROOT
from hermes_cli import __version__ as init_version
pyproject = PROJECT_ROOT / "pyproject.toml"
try:
text = pyproject.read_text(encoding="utf-8")
except OSError:
return # Installed wheel — nothing to cross-check
in_project = False
pyproject_version = None
for raw in text.splitlines():
line = raw.strip()
if line.startswith("[") and line.endswith("]"):
in_project = line == "[project]"
continue
if in_project and line.startswith("version") and "=" in line:
value = line.split("=", 1)[1].split("#", 1)[0].strip().strip("\"\'")
pyproject_version = value or None
break
if pyproject_version is None:
return
if pyproject_version == init_version:
report.ok("Version files consistent", f"({init_version})")
else:
report.fail(
"Version mismatch between source files",
f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})",
fix=(
"Re-sync version files (e.g. run 'hermes update', or set "
"hermes_cli/__init__.py __version__ to match pyproject.toml)"
),
)
+47
View File
@@ -0,0 +1,47 @@
"""Security advisory checks."""
from __future__ import annotations
from hermes_cli.doctor._registry import register
@register("Security Advisories", "security-advisories", priority=10)
def check_security_advisories(report):
from hermes_cli.security_advisories import (
detect_compromised,
filter_unacked,
full_remediation_text,
get_acked_ids,
)
all_hits = detect_compromised()
fresh_hits = filter_unacked(all_hits)
if not fresh_hits:
report.ok("No active security advisories")
return
for hit in fresh_hits:
report.fail(
f"{hit.advisory.title}",
f"({hit.package}=={hit.installed_version})",
)
for line in full_remediation_text(hit):
if line:
report.raw_print(f" {report.color(line, report.YELLOW)}")
else:
report.raw_print()
report.add_issue(
f"Resolve security advisory {hit.advisory.id}: "
f"uninstall {hit.package}=={hit.installed_version} and "
f"rotate credentials, then run "
f"`hermes doctor --ack {hit.advisory.id}`."
)
acked_ids = get_acked_ids()
for h in all_hits:
if h.advisory.id in acked_ids:
report.warn(
f"{h.package}=={h.installed_version} still installed "
f"(advisory {h.advisory.id} acknowledged)",
)
+56
View File
@@ -0,0 +1,56 @@
"""Skills Hub and GitHub auth checks."""
from __future__ import annotations
import json
import subprocess
from hermes_cli.doctor._registry import register
@register("Skills Hub", "skills-hub-dir", priority=10)
def check_skills_hub(report):
from hermes_cli.doctor import HERMES_HOME
hub_dir = HERMES_HOME / "skills" / ".hub"
if not hub_dir.exists():
report.warn("Skills Hub directory not initialized", "(run: hermes skills list)")
return
report.ok("Skills Hub directory exists")
lock_file = hub_dir / "lock.json"
if lock_file.exists():
lock_data = json.loads(lock_file.read_text())
count = len(lock_data.get("installed", {}))
report.ok(f"Lock file OK ({count} hub-installed skill(s))")
else:
report.warn("Lock file", "(corrupted or unreadable)")
quarantine = hub_dir / "quarantine"
q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0
if q_count > 0:
report.warn(f"{q_count} skill(s) in quarantine", "(pending review)")
@register("Skills Hub", "github-auth", priority=20)
def check_github_auth(report):
from hermes_cli.doctor import _DHH
from hermes_cli.config import get_env_value
def _gh_authenticated() -> bool:
try:
res = subprocess.run(
["gh", "auth", "status", "--json", "authenticated"],
capture_output=True, timeout=10,
)
return res.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN")
if github_token:
report.ok("GitHub token configured (authenticated API access)")
elif _gh_authenticated():
report.ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)")
else:
report.warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)")
@@ -0,0 +1,78 @@
"""Tool availability checks using model_tools."""
from __future__ import annotations
import os
from hermes_cli.doctor._registry import register
def _is_kanban_worker_env_gate(item: dict) -> bool:
if item.get("name") != "kanban":
return False
if os.environ.get("HERMES_KANBAN_TASK"):
return False
tools = item.get("tools") or []
return bool(tools) and all(str(t).startswith("kanban_") for t in tools)
def _honcho_is_configured_for_doctor() -> bool:
try:
from plugins.memory.honcho.client import HonchoClientConfig
cfg = HonchoClientConfig.from_global_config()
return bool(cfg.enabled and (cfg.api_key or cfg.base_url))
except Exception:
return False
def _doctor_tool_availability_detail(toolset: str) -> str:
if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"):
return "(runtime-gated; loaded only for dispatcher-spawned workers)"
return ""
def _apply_doctor_tool_availability_overrides(available, unavailable):
"""Adjust runtime-gated tool availability for doctor diagnostics.
Indirects through the hermes_cli.doctor module so that monkeypatching
doctor._honcho_is_configured_for_doctor in tests works as expected.
"""
import hermes_cli.doctor as _doctor_mod
updated = list(available)
remaining = []
for item in unavailable:
name = item.get("name")
if _is_kanban_worker_env_gate(item):
if "kanban" not in updated:
updated.append("kanban")
continue
if name == "honcho" and _doctor_mod._honcho_is_configured_for_doctor():
if "honcho" not in updated:
updated.append("honcho")
continue
remaining.append(item)
return updated, remaining
@register("Tool Availability", "tool-availability", priority=10)
def check_tool_availability(report):
from hermes_cli.doctor import PROJECT_ROOT
import sys
sys.path.insert(0, str(PROJECT_ROOT))
from model_tools import check_tool_availability as _cta, TOOLSET_REQUIREMENTS
available, unavailable = _apply_doctor_tool_availability_overrides(*_cta())
for tid in available:
info = TOOLSET_REQUIREMENTS.get(tid, {})
report.ok(info.get("name", tid), _doctor_tool_availability_detail(tid))
for item in unavailable:
env_vars = item.get("missing_vars") or item.get("env_vars") or []
if env_vars:
report.warn(item["name"], f"(missing {', '.join(env_vars)})")
else:
report.warn(item["name"], "(system dependency not met)")
if any(u.get("missing_vars") or u.get("env_vars") for u in unavailable):
report.add_issue("Run 'hermes setup' to configure missing API keys for full tool access")
@@ -0,0 +1,24 @@
"""xAI model retirement check."""
from __future__ import annotations
from hermes_cli.doctor._registry import register
@register("xAI Model Retirement (May 15, 2026)", "xai-retirement", priority=10)
def check_xai_retirement(report):
from hermes_cli.config import load_config
from hermes_cli.xai_retirement import MIGRATION_GUIDE_URL, find_retired_xai_refs, format_issue
cfg = load_config()
retired_refs = find_retired_xai_refs(cfg)
if not retired_refs:
report.ok("No retired xAI models in config")
return
for ref in retired_refs:
report.warn(format_issue(ref))
report.info(f"Migration guide: {MIGRATION_GUIDE_URL}")
report.add_issue(
f"Update {len(retired_refs)} retired xAI model reference(s) "
f"in config.yaml — see {MIGRATION_GUIDE_URL}"
)
+4 -4
View File
@@ -13,9 +13,9 @@ import subprocess
import sys
from pathlib import Path
from hermes_cli.config import get_hermes_home, get_env_path, get_project_root, load_config
from hermes_cli.config import get_hermes_home, get_env_path, load_config
from hermes_cli.env_loader import load_hermes_dotenv
from hermes_constants import display_hermes_home
from hermes_constants import display_hermes_home, get_hermes_source_root
from agent.skill_utils import is_excluded_skill_path
@@ -224,10 +224,10 @@ def run_dump(args):
env_path = get_env_path()
load_hermes_dotenv(
hermes_home=env_path.parent,
project_env=get_project_root() / ".env",
project_env=get_hermes_source_root() / ".env",
)
project_root = get_project_root()
project_root = get_hermes_source_root()
hermes_home = get_hermes_home()
try:
+13 -29
View File
@@ -15,7 +15,8 @@ import textwrap
from dataclasses import dataclass
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
from gateway.status import terminate_pid
from gateway.restart import (
@@ -2139,34 +2140,15 @@ def get_launchd_plist_path() -> Path:
def _detect_venv_dir() -> Path | None:
"""Detect the active virtualenv directory.
Checks ``sys.prefix`` first (works regardless of the directory name),
then ``VIRTUAL_ENV`` env var (covers uv-managed environments where
sys.prefix == sys.base_prefix), then falls back to probing common
directory names under PROJECT_ROOT.
Returns ``None`` when no virtualenv can be found.
Delegates to ``get_venv_path()`` from ``hermes_cli.managed_uv``,
which is the single source of truth for locating the project venv.
Returns ``None`` only when the resolved path doesn't exist on disk
(pre-install state).
"""
# If we're running inside a virtualenv, sys.prefix points to it.
if sys.prefix != sys.base_prefix:
venv = Path(sys.prefix)
if venv.is_dir():
return venv
from hermes_cli.managed_uv import get_venv_path
p = get_venv_path()
return p if p.is_dir() else None
# uv and some other tools set VIRTUAL_ENV without changing sys.prefix.
# This catches `uv run` where sys.prefix == sys.base_prefix but the
# environment IS a venv. (#8620)
_virtual_env = os.environ.get("VIRTUAL_ENV")
if _virtual_env:
venv = Path(_virtual_env)
if venv.is_dir():
return venv
# Fallback: check common virtualenv directory names under the project root.
for candidate in (".venv", "venv"):
venv = PROJECT_ROOT / candidate
if venv.is_dir():
return venv
return None
def get_python_path() -> str:
@@ -2355,7 +2337,8 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
python_path = get_python_path()
working_dir = _stable_service_working_dir()
detected_venv = _detect_venv_dir()
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
from hermes_cli.managed_uv import get_venv_path as _gvp
venv_dir = str(detected_venv) if detected_venv else str(_gvp())
path_entries = _build_service_path_dirs()
resolved_node = shutil.which("node")
@@ -3193,7 +3176,8 @@ def generate_launchd_plist() -> str:
# the systemd unit), then capture the user's full shell PATH so every
# user-installed tool (node, ffmpeg, …) is reachable.
detected_venv = _detect_venv_dir()
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
from hermes_cli.managed_uv import get_venv_path as _gvp
venv_dir = str(detected_venv) if detected_venv else str(_gvp())
# Resolve the directory containing the node binary (e.g. Homebrew, nvm)
# so it's explicitly in PATH even if the user's shell PATH changes later.
priority_dirs = _build_service_path_dirs()
+2 -1
View File
@@ -183,7 +183,8 @@ def _launch_elevated_gateway_command(command: str, extra_args: list[str] | None
if extra_args:
args.extend(extra_args)
params = subprocess.list2cmdline(args)
cwd = str(Path(__file__).resolve().parent.parent)
from hermes_constants import get_hermes_source_root
cwd = str(get_hermes_source_root())
elevated_python = _derive_venv_pythonw(sys.executable)
try:
result = ctypes.windll.shell32.ShellExecuteW(
+256 -109
View File
@@ -320,7 +320,8 @@ def _require_tty(command_name: str) -> None:
# Add project root to path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
sys.path.insert(0, str(PROJECT_ROOT))
@@ -499,6 +500,7 @@ from hermes_cli import __version__, __release_date__
# (god-file decomposition Phase 2). Re-imported here so select_provider_and_model and
# existing test monkeypatches (hermes_cli.main._model_flow_*) keep resolving unchanged.
from hermes_cli.model_setup_flows import (
_prompt_auth_credentials_choice,
_model_flow_openrouter,
_model_flow_nous,
_model_flow_openai_codex,
@@ -2830,7 +2832,12 @@ def select_provider_and_model(args=None):
member_labels = [
provider_labels.get(m, m) for m in selected_members
]
member_idx = _prompt_provider_choice(member_labels, default=member_default)
group_label = ordered[provider_idx][1].split("", 1)[0]
member_idx = _prompt_provider_choice(
member_labels,
default=member_default,
title=f"Select {group_label} provider:",
)
if member_idx is None:
print("No change.")
return
@@ -2974,6 +2981,7 @@ _AUX_TASKS: list[tuple[str, str, str]] = [
("approval", "Approval", "smart command approval"),
("mcp", "MCP", "MCP tool reasoning"),
("title_generation", "Title generation", "session titles"),
("tts_audio_tags", "TTS audio tags", "Gemini TTS tag insertion"),
("skills_hub", "Skills hub", "skills search/install"),
("triage_specifier", "Triage specifier", "kanban spec fleshing"),
("kanban_decomposer", "Kanban decomposer", "task decomposition"),
@@ -3263,6 +3271,7 @@ def _aux_flow_provider_model(
model_list,
current_model=current_model,
pricing=pricing,
confirm_provider=provider_slug,
)
if selected is None:
print("No change.")
@@ -3331,7 +3340,7 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else ""))
def _prompt_provider_choice(choices, *, default=0):
def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
"""Show provider selection menu with curses arrow-key navigation.
Falls back to a numbered list when curses is unavailable (e.g. piped
@@ -3341,7 +3350,7 @@ def _prompt_provider_choice(choices, *, default=0):
try:
from hermes_cli.setup import _curses_prompt_choice
idx = _curses_prompt_choice("Select provider:", choices, default)
idx = _curses_prompt_choice(title, choices, default)
if idx >= 0:
print()
return idx
@@ -3349,7 +3358,7 @@ def _prompt_provider_choice(choices, *, default=0):
pass
# Fallback: numbered list
print("Select provider:")
print(title)
for i, c in enumerate(choices, 1):
marker = "" if i - 1 == default else " "
print(f" {marker} {i}. {c}")
@@ -4508,10 +4517,10 @@ def _nixos_build_env() -> dict[str, str] | None:
return None
# Tier 1: fast path — hermes venv python3, no nix-shell overhead
for venv_name in ("venv", ".venv"):
venv_python = PROJECT_ROOT / venv_name / "bin" / "python3"
if venv_python.exists():
return {**os.environ, "PYTHON": str(venv_python)}
from hermes_cli.managed_uv import get_venv_path as _gvp
venv_python = _gvp() / "bin" / "python3"
if venv_python.exists():
return {**os.environ, "PYTHON": str(venv_python)}
# Tier 2: nix-shell fallback — resolves the absolute python3 path once.
# Slower (~25 s for the nix-shell eval) but always works, even without
@@ -5776,35 +5785,14 @@ def _update_via_zip(args):
update_managed_uv()
uv_bin = ensure_uv()
pip_cmd = [sys.executable, "-m", "pip"]
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
uv_bin = _ensure_uv_for_termux()
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(uv_env):
uv_env.pop("PYTHONPATH", None)
uv_env.pop("PYTHONHOME", None)
_install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env)
_install_python_dependencies_with_optional_fallback()
else:
# Use sys.executable to explicitly call the venv's pip module,
# avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu.
# Some environments lose pip inside the venv; bootstrap it back with
# ensurepip before trying the editable install.
try:
subprocess.run(
pip_cmd + ["--version"],
cwd=PROJECT_ROOT,
check=True,
capture_output=True,
)
except subprocess.CalledProcessError:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
cwd=PROJECT_ROOT,
check=True,
)
_install_python_dependencies_with_optional_fallback(pip_cmd)
# Degenerate fallback: managed uv failed to install.
_install_python_dependencies_with_optional_fallback(group="termux-all" if _is_termux_env() else "all")
_update_node_dependencies()
_build_web_ui(PROJECT_ROOT / "web")
@@ -6385,6 +6373,150 @@ def _load_installable_optional_extras(group: str = "all") -> list[str]:
return referenced
# Install-scoped breadcrumb dropped right before ``hermes update`` mutates the
# venv and cleared only after the dependency install verifies clean. If a user
# kills the update mid-install (Ctrl-C, terminal close, WSL OOM), the marker
# survives and the next ``hermes`` launch finishes the install instead of
# limping along on a half-built venv (e.g. pip wiped, a core dep like Pillow
# never landed). Lives next to the venv (not under $HERMES_HOME) because the
# venv is shared across all profiles, so a single marker covers every profile.
def _update_marker_path() -> Path:
return PROJECT_ROOT / ".update-incomplete"
def _write_update_incomplete_marker() -> None:
"""Drop the interrupted-install breadcrumb. Never raises."""
try:
_update_marker_path().write_text(
f"started={_time.time()}\npid={os.getpid()}\n", encoding="utf-8"
)
except OSError as exc:
logger.debug("Could not write update-incomplete marker: %s", exc)
def _clear_update_incomplete_marker() -> None:
"""Remove the interrupted-install breadcrumb. Never raises."""
try:
_update_marker_path().unlink()
except FileNotFoundError:
pass
except OSError as exc:
logger.debug("Could not clear update-incomplete marker: %s", exc)
def _recover_from_interrupted_install() -> None:
"""Finish a dependency install that a prior ``hermes update`` left half-done.
Triggered on launch when ``.update-incomplete`` is present meaning the
code was pulled but the dep install was killed before it verified clean.
Re-runs the editable ``.[all]`` install via the managed ``uv`` binary
(guaranteed by ``ensure_uv()``), falls back to plain pip if degraded,
runs core-dependency verification, then clears the marker.
Never raises: a recovery failure must not block launch. If it can't
self-heal it prints the one-line manual command and leaves the marker so
the next launch tries again.
Concurrency: the marker lives next to the shared venv, so a gateway start
plus a CLI launch (or two profiles starting at once) can both see it. An
``O_EXCL`` lockfile ensures only one process runs the reinstall; the
others skip and let the winner clear the marker.
Output: everything our status lines AND the streamed pip/uv install
(which inherits fd 1) is routed to stderr. Launches whose stdout is a
protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get
install noise on stdout.
"""
if not _update_marker_path().exists():
return
# Skip in managed/Docker installs and on PyPI installs with no git checkout:
# those don't run the source-tree update path, so a stray marker is not ours
# to act on. Just clear it.
if not (PROJECT_ROOT / "pyproject.toml").is_file():
_clear_update_incomplete_marker()
return
# Single-flight guard: atomically claim the recovery lock. If another
# process holds it, skip — it is running the same reinstall into the same
# shared venv right now. A crashed holder leaves a stale lock; break it
# after an hour (well past any realistic install) so recovery can't be
# wedged forever.
lock_path = PROJECT_ROOT / ".update-incomplete.lock"
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, f"{os.getpid()}\n".encode())
os.close(fd)
except FileExistsError:
try:
if _time.time() - lock_path.stat().st_mtime > 3600:
lock_path.unlink()
except OSError:
pass
return
except OSError as exc:
# Couldn't create the lock (read-only fs, perms). Proceed unlocked —
# the install itself will surface the real problem.
logger.debug("Could not create install-recovery lock: %s", exc)
saved_stdout_fd = None
saved_sys_stdout = sys.stdout
try:
# Route Python-level prints AND subprocess-inherited fd 1 to stderr
# for the duration of recovery (see docstring: ACP stdout safety).
try:
saved_stdout_fd = os.dup(1)
os.dup2(2, 1)
except OSError:
saved_stdout_fd = None
sys.stdout = sys.stderr
print(
"⚠ A previous `hermes update` was interrupted mid-install — "
"finishing dependency installation now..."
)
try:
from hermes_cli.managed_uv import ensure_uv
# ensure_uv() guarantees the managed uv binary is present, bootstrapping
# it via the official installer if a killed install removed it.
uv_bin = ensure_uv()
if uv_bin:
_install_python_dependencies_with_optional_fallback(
group="termux-all" if _is_termux_env() else "all",
)
else:
# Degenerate fallback: managed uv failed to install.
_install_python_dependencies_with_optional_fallback(
group="termux-all" if _is_termux_env() else "all",
)
_clear_update_incomplete_marker()
print("✓ Dependency installation recovered — your install is healthy again.")
except Exception as exc:
# Leave the marker in place so the next launch retries. Give the user
# the exact manual recovery command in the meantime.
logger.debug("Interrupted-install recovery failed: %s", exc)
print("✗ Could not auto-recover the interrupted install.")
print(" Recover manually by ensuring uv is installed, then run:")
print(f" cd {PROJECT_ROOT}")
print(" uv pip install -e '.[all]'")
print(" (Or re-run the Hermes installer: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash)")
finally:
sys.stdout = saved_sys_stdout
if saved_stdout_fd is not None:
try:
os.dup2(saved_stdout_fd, 1)
os.close(saved_stdout_fd)
except OSError:
pass
try:
lock_path.unlink()
except OSError:
pass
def _run_install_with_heartbeat(
cmd: list[str],
*,
@@ -6869,7 +7001,6 @@ def _refresh_active_lazy_features() -> None:
def _install_python_dependencies_with_optional_fallback(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
group: str = "all",
@@ -6883,7 +7014,14 @@ def _install_python_dependencies_with_optional_fallback(
in the venv Scripts dir before each install attempt so uv can write fresh
copies (Windows blocks REPLACE on a running .exe but allows RENAME). See
``_quarantine_running_hermes_exe`` for the rationale.
Uses ``get_pip_cmd()`` for dependency installation this function builds
non-standard install commands (``-e .[group]``, ``--user``, etc.) that
don't fit the ``pip_install()`` helper's signature.
"""
from hermes_cli.managed_uv import get_pip_cmd
install_cmd_prefix = get_pip_cmd()
scripts_dir = _venv_scripts_dir() if _is_windows() else None
def _install(args: list[str]) -> None:
@@ -7152,7 +7290,6 @@ def _is_android_python() -> bool:
def _install_psutil_android_compat(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
) -> None:
@@ -7181,13 +7318,14 @@ def _install_psutil_android_compat(
urllib.request.urlretrieve(PSUTIL_URL, archive)
src_root = prepare_patched_psutil_sdist(archive, tmp_path)
from hermes_cli.managed_uv import get_pip_cmd as _gpc
_run_install_with_heartbeat(
install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)],
_gpc() + ["install", "--no-build-isolation", str(src_root)],
env=env,
)
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
def _ensure_uv_for_termux() -> str | None:
"""Best-effort uv bootstrap on Termux for faster update installs.
The normal path (``ensure_uv()`` in managed_uv) installs the managed
@@ -7204,7 +7342,8 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
return None
try:
print(" → Termux detected: trying to install uv for faster dependency updates...")
subprocess.run(pip_cmd + ["install", "uv"], cwd=PROJECT_ROOT, check=False)
from hermes_cli.managed_uv import get_pip_cmd as _gpc
subprocess.run(_gpc() + ["install", "uv"], cwd=PROJECT_ROOT, check=False)
except Exception:
pass
# After pip install, check managed path first, then PATH
@@ -7858,12 +7997,19 @@ def cmd_update(args):
def _cmd_update_pip(args):
"""Update Hermes via pip (for PyPI installs)."""
"""Update Hermes via its managed install path.
- ``uv tool install`` / ``uv tool upgrade`` path: use ``uv tool upgrade hermes-agent``.
- ``pipx`` path: use ``pipx upgrade hermes-agent``.
- Direct git-checkout (standard install.sh path): delegate to the git-based
``_cmd_update_impl`` which pulls, reinstalls deps, etc.
- Plain ``pip install hermes-agent`` from PyPI is no longer supported.
Users on that path should reinstall via the official installer.
"""
from hermes_cli import __version__
from hermes_cli.config import is_uv_tool_install
print(f"→ Current version: {__version__}")
print("→ Checking PyPI for updates...")
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
@@ -7871,46 +8017,33 @@ def _cmd_update_pip(args):
update_managed_uv()
uv = ensure_uv()
in_venv = sys.prefix != sys.base_prefix
# pipx-managed installs live under .../pipx/venvs/<name>/...
pipx_managed = "pipx" in sys.prefix.split(os.sep)
pipx = shutil.which("pipx") if pipx_managed else None
# Only the ``uv pip install`` path inside a venv needs VIRTUAL_ENV
# exported (uv refuses to install without it when the launcher shim
# didn't activate the venv). ``uv tool upgrade`` / ``pipx upgrade``
# operate on a named environment and ignore VIRTUAL_ENV, so we don't
# set it for them.
export_virtualenv = False
if is_uv_tool_install():
if not uv:
print("✗ Detected a uv-tool install but managed uv install failed.")
print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/")
sys.exit(1)
cmd = [uv, "tool", "upgrade", "hermes-agent"]
print(f"→ Running: {' '.join(cmd)}")
result = subprocess.run(cmd)
elif pipx_managed and pipx:
# pipx owns its own venv; ``pipx upgrade`` is the only correct path.
# Matches scripts/auto-update.sh, which already uses pipx upgrade.
cmd = [pipx, "upgrade", "hermes-agent"]
elif uv:
cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"]
if in_venv:
# Launcher shim runs the venv interpreter but doesn't export
# VIRTUAL_ENV; without it uv errors "No virtual environment found".
export_virtualenv = True
else:
# Outside any venv, ``--system`` lets uv target the active
# interpreter, matching pip's default behaviour.
cmd.insert(3, "--system")
print(f"→ Running: {' '.join(cmd)}")
result = subprocess.run(cmd)
else:
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"]
# Not a uv-tool or pipx install. The supported path is the git
# checkout created by install.sh — it has a .git dir and uses
# ``hermes update`` (the git-pull + reinstall path) not pip.
# A raw ``pip install hermes-agent`` from PyPI is no longer supported.
print("✗ Cannot update: PyPI-based installs (pip install hermes-agent) are no longer supported.")
print()
print(" Please reinstall using the official installer, then use `hermes update`:")
print(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash")
sys.exit(1)
print(f"→ Running: {' '.join(cmd)}")
run_kwargs = {}
if export_virtualenv:
run_kwargs["env"] = {**os.environ, "VIRTUAL_ENV": sys.prefix}
result = subprocess.run(cmd, **run_kwargs)
if result.returncode != 0:
print("✗ Update failed")
sys.exit(1)
@@ -7918,6 +8051,7 @@ def _cmd_update_pip(args):
print("✓ Update complete! Restart hermes to use the new version.")
def _cmd_update_impl(args, gateway_mode: bool):
"""Body of ``cmd_update`` — kept separate so the wrapper can always
restore stdio even on ``sys.exit``."""
@@ -8316,6 +8450,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
# Reinstall Python dependencies. Prefer .[all], but if one optional extra
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
#
# Drop the interrupted-install breadcrumb BEFORE touching the venv. If
# the install is killed mid-flight (Ctrl-C, terminal close, WSL OOM),
# the marker survives and the next ``hermes`` launch finishes the
# install via ``_recover_from_interrupted_install``. Cleared only after
# the install + core-dependency verification completes below.
_write_update_incomplete_marker()
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
@@ -8323,51 +8464,42 @@ def _cmd_update_impl(args, gateway_mode: bool):
update_managed_uv()
uv_bin = ensure_uv()
pip_cmd = [sys.executable, "-m", "pip"]
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
install_group = "all"
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(uv_env):
uv_env.pop("PYTHONPATH", None)
uv_env.pop("PYTHONHOME", None)
install_group = "termux-all"
print(" → Termux detected: using uv + curated termux-all optional profile...")
if _is_termux_env(uv_env) and _is_android_python():
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
_install_psutil_android_compat([uv_bin, "pip"], env=uv_env)
_install_python_dependencies_with_optional_fallback(
[uv_bin, "pip"], env=uv_env, group=install_group
)
else:
# Use sys.executable to explicitly call the venv's pip module,
# avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu.
# Some environments lose pip inside the venv; bootstrap it back with
# ensurepip before trying the editable install.
pip_cmd = [sys.executable, "-m", "pip"]
try:
subprocess.run(
pip_cmd + ["--version"],
cwd=PROJECT_ROOT,
check=True,
capture_output=True,
)
except subprocess.CalledProcessError:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
cwd=PROJECT_ROOT,
check=True,
)
if _is_termux_env():
install_group = "termux-all"
print(" → Termux detected: using curated termux-all optional profile...")
print(" → Termux detected: using uv + curated termux-all optional profile...")
if _is_termux_env() and _is_android_python():
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
_install_psutil_android_compat(pip_cmd)
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
_install_psutil_android_compat()
_install_python_dependencies_with_optional_fallback(group=install_group)
else:
# Degenerate fallback: managed uv failed to install.
uv_bin = ensure_uv()
if uv_bin:
if _is_termux_env():
install_group = "termux-all"
print(" → Termux detected: using curated termux-all optional profile...")
if _is_termux_env() and _is_android_python():
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
_install_psutil_android_compat()
_install_python_dependencies_with_optional_fallback(group=install_group)
else:
# Ultimate degenerate fallback: no uv at all.
if _is_termux_env():
install_group = "termux-all"
print(" → Termux detected: using curated termux-all optional profile...")
if _is_termux_env() and _is_android_python():
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
_install_psutil_android_compat()
_install_python_dependencies_with_optional_fallback(group=install_group)
# Core Python deps installed AND verified (the fallback helper runs
# _verify_core_dependencies_installed). Clear the interrupted-install
# breadcrumb now — the remaining steps (lazy refresh, node deps, web
# UI, desktop rebuild) are non-core and can't brick the venv.
_clear_update_incomplete_marker()
_refresh_active_lazy_features()
@@ -10066,10 +10198,9 @@ def cmd_dashboard(args):
except ImportError as e:
print("Web UI dependencies not installed (need fastapi + uvicorn).")
print(
f"Re-install the package into this interpreter so metadata updates apply:\n"
f"Re-install the package using uv so metadata updates apply:\n"
f" cd {PROJECT_ROOT}\n"
f" {sys.executable} -m pip install -e .\n"
"If `pip` is missing in this venv, use: uv pip install -e ."
f" uv pip install -e .\n"
)
print(f"Import error: {e}")
sys.exit(1)
@@ -10683,6 +10814,22 @@ def main():
except Exception:
pass
# Self-heal a venv left half-built by an interrupted ``hermes update``
# (Ctrl-C, terminal close, WSL OOM mid-install). Skip when the user is
# *running* update — that flow writes and clears its own marker, and we
# don't want a recovery install racing the real one. Never raises.
#
# The substring match is deliberately loose: argv isn't parsed yet at this
# point, and the failure modes are asymmetric. Over-matching (e.g.
# ``hermes skills install update``) merely defers recovery one launch;
# under-matching (missing ``hermes -p work update``) would race a recovery
# install against the real one. Loose wins.
try:
if "update" not in sys.argv[1:]:
_recover_from_interrupted_install()
except Exception:
pass
if _try_termux_fast_tui_launch():
return
if _try_termux_fast_cli_launch():
+209
View File
@@ -15,6 +15,7 @@ import os
import platform
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Optional
@@ -250,5 +251,213 @@ def _install_uv_windows(env: dict[str, str]) -> None:
capture_output=True,
)
def get_pip_cmd() -> list[str]:
"""Return the authoritative pip command prefix.
Hermes strictly requires `uv` for dependency management.
Fallback hierarchy:
1. Managed uv at `$HERMES_HOME/bin/uv` (guaranteed by `ensure_uv()`).
2. System/PATH `uv` (e.g., Termux `pkg install uv`, Homebrew, etc.).
If neither is found, this raises a RuntimeError. We NEVER fall back to
raw `[sys.executable, "-m", "pip"]`, as that re-introduces the
ensurepip/PEP-668/venv-contamination bugs this architecture was built to eliminate.
"""
uv_bin = resolve_uv()
if uv_bin:
return [uv_bin, "pip"]
# Secondary fallback: check PATH for uv (critical for Termux `pkg install uv` support)
path_uv = shutil.which("uv")
if path_uv:
return [path_uv, "pip"]
# HARD FAIL: uv is a strict requirement. Do not silently degrade to raw pip.
raise RuntimeError(
"uv is not installed or not found in PATH. "
"Hermes strictly requires uv for dependency management. "
"Please run `hermes doctor` to diagnose and fix your environment, "
"or install uv manually (e.g., `pkg install uv` on Termux, or via the Hermes installer)."
)
def get_venv_root() -> Path:
"""Return the root path of the active virtual environment.
Prefers `sys.prefix` (the standard Python way to identify a venv).
Falls back to the parent of the parent of `sys.executable`
(e.g., `/path/to/venv/bin/python` -> `/path/to/venv`).
"""
if sys.prefix != sys.base_prefix:
return Path(sys.prefix)
return Path(sys.executable).parent.parent
def get_venv_path() -> Path:
"""Return the project's virtual environment directory.
This is the single authoritative way to locate the Hermes venv on disk.
Previously scattered as ``PROJECT_ROOT / "venv"`` or ``PROJECT_ROOT / ".venv"``
throughout the codebase.
Resolution order:
1. The active venv (``sys.prefix``) when hermes is running inside one.
This covers every production layout:
- dev checkout: <checkout>/.venv or <checkout>/venv
- normal user install: ~/.hermes/hermes-agent/venv
- root/system install: /usr/local/lib/hermes-agent/venv
- docker: /opt/hermes/.venv
- nix package install: /nix/store/<hash>-hermes-agent-python3.12-env
- nix devshell: <checkout>/.venv
Hermes is always invoked through its own venv wrapper, so sys.prefix
reliably points to the right place.
2. ``<source_root>/.venv`` if it exists on disk (pre-activation fallback).
3. ``<source_root>/venv`` if it exists on disk.
4. ``<source_root>/venv`` as the canonical default (may not exist yet,
e.g. before the first install run).
"""
# we're unning inside a venv
if sys.prefix != sys.base_prefix:
return Path(sys.prefix).resolve()
# we're not in a venv, probe conventional names under the source root
from hermes_constants import get_hermes_source_root
source_root = get_hermes_source_root()
for name in (".venv", "venv"):
candidate = source_root / name
if candidate.exists():
return candidate
return source_root / "venv"
def pip_install(
packages: list[str],
*,
venv_root: Optional[Path] = None,
timeout: int = 300,
capture_output: bool = True,
quiet: bool = False,
upgrade: bool = False,
) -> subprocess.CompletedProcess:
"""Install packages using the managed uv binary (with degenerate pip fallback).
This is the single, authoritative way to install Python dependencies in Hermes.
It automatically:
1. Resolves the correct venv root.
2. Sets `VIRTUAL_ENV` and prepends the venv `bin` to `PATH`.
3. Strips `PYTHONPATH` and `PYTHONHOME` to prevent venv contamination
(critical for Termux/Android compatibility).
4. Uses `get_pip_cmd()` to guarantee the managed uv binary is used.
"""
if venv_root is None:
venv_root = get_venv_root()
cmd = get_pip_cmd() + ["install"]
if upgrade:
cmd.append("--upgrade")
if quiet:
cmd.append("--quiet")
cmd.extend(packages)
env = {**os.environ}
env["VIRTUAL_ENV"] = str(venv_root)
# Ensure venv bin is first in PATH
venv_bin = str(venv_root / "bin")
env["PATH"] = f"{venv_bin}{os.pathsep}{env.get('PATH', '')}"
# Clean up PYTHONPATH/PYTHONHOME to avoid venv contamination
env.pop("PYTHONPATH", None)
env.pop("PYTHONHOME", None)
try:
return subprocess.run(
cmd,
capture_output=capture_output,
text=True,
timeout=timeout,
env=env,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
# Synthesize a failure result so callers can handle it uniformly
return subprocess.CompletedProcess(
args=cmd,
returncode=1,
stdout="",
stderr=str(e),
)
def recreate_venv_atomically(project_root: Path, group: str = "all") -> bool:
"""Atomically recreate the venv to ensure a clean, uv-native state.
This is the safest way to migrate from a legacy pip-created venv or
repair a corrupted venv. It builds a fresh `venv.new`, installs dependencies,
and then atomically swaps `venv` -> `venv.bak` and `venv.new` -> `venv`.
This guarantees we never accidentally strip dependencies or leave legacy
pip cruft behind, as we are building a pristine environment from scratch.
Returns True on success, False on failure.
"""
target_venv = project_root / "venv"
new_venv = project_root / "venv.new"
backup_venv = project_root / "venv.bak"
uv_bin = resolve_uv() or shutil.which("uv")
if not uv_bin:
logger.error("Cannot recreate venv: uv is not installed or found in PATH.")
return False
print(f" → Creating fresh venv at {new_venv}...")
# 1. Create fresh venv
res = subprocess.run(
[uv_bin, "venv", str(new_venv)],
capture_output=True, text=True, timeout=120
)
if res.returncode != 0:
logger.error("Failed to create new venv: %s", res.stderr)
return False
print(f" → Installing dependencies into new venv ({group})...")
# 2. Install dependencies into the new venv
env = {**os.environ, "VIRTUAL_ENV": str(new_venv)}
env["PATH"] = f"{new_venv / 'bin'}{os.pathsep}{env.get('PATH', '')}"
env.pop("PYTHONPATH", None)
env.pop("PYTHONHOME", None)
res = subprocess.run(
[uv_bin, "pip", "install", "-e", f".[{group}]"],
cwd=project_root,
capture_output=True, text=True, timeout=600,
env=env, stdin=subprocess.DEVNULL
)
if res.returncode != 0:
logger.error("Failed to install dependencies in new venv: %s", res.stderr)
# Clean up failed new venv
shutil.rmtree(new_venv, ignore_errors=True)
return False
print(" -> Dependencies installed successfully. Performing atomic swap...")
# 3. Atomic swap
try:
if target_venv.exists():
if backup_venv.exists():
shutil.rmtree(backup_venv, ignore_errors=True)
target_venv.rename(backup_venv)
new_venv.rename(target_venv)
print(" OK Venv successfully recreated and swapped.")
print(" -> (Old venv backed up to venv.bak. You can safely delete it if everything works.)")
return True
except Exception as e:
logger.error("Failed to atomically swap venvs: %s", e)
# Attempt to restore if swap failed mid-way
if not target_venv.exists() and backup_venv.exists():
backup_venv.rename(target_venv)
return False
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
True # dont remove me. ask ethernet
+2 -1
View File
@@ -129,7 +129,8 @@ def _catalog_root() -> Path:
"""Return the optional-mcps/ directory shipped with this Hermes install."""
# Prefer the env-var override / packaged location; fall back to the repo's
# optional-mcps/ next to the package (source checkout).
return get_optional_mcps_dir(Path(__file__).parent.parent / "optional-mcps")
from hermes_constants import get_hermes_source_root
return get_optional_mcps_dir(get_hermes_source_root() / "optional-mcps")
def _parse_env_spec(raw: Any) -> EnvVarSpec:
+9 -24
View File
@@ -8,10 +8,13 @@ the provider's config schema. Writes config to config.yaml + .env.
from __future__ import annotations
import os
import sys
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
from hermes_cli.managed_uv import pip_install
from hermes_constants import get_hermes_home
from hermes_cli.secret_prompt import masked_secret_prompt
@@ -96,33 +99,15 @@ def _install_dependencies(provider_name: str) -> None:
print(f"\n Installing dependencies: {', '.join(missing)}")
import shutil
uv_path = shutil.which("uv")
if uv_path:
install_cmd = [uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing
manual_cmd = f"uv pip install --python {sys.executable} {' '.join(missing)}"
else:
pip_cmd = shutil.which("pip3") or shutil.which("pip")
if not pip_cmd:
print(f" ⚠ uv not found — cannot install dependencies")
print(f" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh")
print(f" Then re-run: hermes memory setup")
return
print(f" ⚠ uv not found. Falling back to standard pip...")
install_cmd = [sys.executable, "-m", "pip", "install", "--quiet"] + missing
manual_cmd = f"{sys.executable} -m pip install {' '.join(missing)}"
manual_cmd = f"uv pip install {' '.join(missing)}"
try:
subprocess.run(
install_cmd,
check=True, timeout=120,
capture_output=True,
)
result = pip_install(missing, quiet=True, timeout=120)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr)
print(f" ✓ Installed {', '.join(missing)}")
except subprocess.CalledProcessError as e:
print(f" ⚠ Failed to install {', '.join(missing)}")
stderr = (e.stderr or b"").decode()[:200]
stderr = (e.stderr or b"").decode()[:200] if isinstance(e.stderr, bytes) else (e.stderr or "")[:200]
if stderr:
print(f" {stderr}")
print(f" Run manually: {manual_cmd}")
+134
View File
@@ -0,0 +1,134 @@
"""Expensive-model confirmation helpers for model selection surfaces."""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Optional
from agent.models_dev import ModelInfo
INPUT_COST_WARNING_THRESHOLD = Decimal("20")
OUTPUT_COST_WARNING_THRESHOLD = Decimal("100")
GPT55_PRO_OPENROUTER_ID = "openai/gpt-5.5-pro"
GPT55_SUGGESTION = "did you mean to select openai/gpt-5.5?"
@dataclass(frozen=True)
class ExpensiveModelWarning:
"""Confirmation payload for models above Hermes' cost guardrail."""
model: str
provider: str
input_cost_per_million: Optional[Decimal]
output_cost_per_million: Optional[Decimal]
source: str
message: str
def _to_decimal(value: object) -> Optional[Decimal]:
if value is None:
return None
try:
return Decimal(str(value))
except (InvalidOperation, ValueError):
return None
def _format_money(value: Optional[Decimal]) -> str:
if value is None:
return "unknown"
return f"${value:.2f}/M"
def _pricing_from_model_info(
model_info: Optional[ModelInfo],
) -> tuple[Optional[Decimal], Optional[Decimal], str]:
if model_info is None or not model_info.has_cost_data():
return None, None, ""
return (
_to_decimal(model_info.cost_input),
_to_decimal(model_info.cost_output),
"models.dev",
)
def expensive_model_warning(
model_name: str,
*,
provider: Optional[str] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
model_info: Optional[ModelInfo] = None,
) -> Optional[ExpensiveModelWarning]:
"""Return a warning payload when known pricing exceeds safety thresholds.
The guard only triggers when pricing is known. Callers should use this after
model resolution so aliases and provider-specific model IDs have settled.
"""
model = (model_name or "").strip()
if not model:
return None
input_cost, output_cost, source = _pricing_from_model_info(model_info)
if input_cost is None and output_cost is None and provider:
try:
from agent.models_dev import get_model_info
input_cost, output_cost, source = _pricing_from_model_info(
get_model_info(provider, model)
)
except Exception:
pass
if input_cost is None and output_cost is None:
try:
from agent.usage_pricing import get_pricing_entry
entry = get_pricing_entry(
model,
provider=provider,
base_url=base_url,
api_key=api_key,
)
except Exception:
entry = None
if entry is not None:
input_cost = entry.input_cost_per_million
output_cost = entry.output_cost_per_million
source = entry.source
over_input = (
input_cost is not None and input_cost > INPUT_COST_WARNING_THRESHOLD
)
over_output = (
output_cost is not None and output_cost > OUTPUT_COST_WARNING_THRESHOLD
)
if not over_input and not over_output:
return None
lines = [
"!!! EXPENSIVE MODEL WARNING !!!",
"",
f"{model} has known pricing above Hermes' safety threshold.",
f"Input tokens: {_format_money(input_cost)}",
f"Output tokens: {_format_money(output_cost)}",
(
"Threshold: more than $20/M input tokens or more than "
"$100/M output tokens."
),
]
if source:
lines.append(f"Pricing source: {source}.")
if model.lower() == GPT55_PRO_OPENROUTER_ID:
lines.append(GPT55_SUGGESTION)
lines.append("Confirm only if you intend to use this model.")
return ExpensiveModelWarning(
model=model,
provider=(provider or "").strip(),
input_cost_per_million=input_cost,
output_cost_per_million=output_cost,
source=source or "unknown",
message="\n".join(lines),
)
+131 -43
View File
@@ -25,6 +25,44 @@ import os
import subprocess
def _prompt_auth_credentials_choice(title: str) -> str:
"""Prompt for reuse / reauthenticate / cancel with the standard radio UI.
Returns one of ``"use"``, ``"reauth"``, ``"cancel"``. Falls back to a
numbered prompt when curses is unavailable (piped stdin, non-TTY).
"""
choices = [
"Use existing credentials",
"Reauthenticate (new OAuth login)",
"Cancel",
]
try:
from hermes_cli.setup import _curses_prompt_choice
idx = _curses_prompt_choice(title, choices, 0)
if idx >= 0:
print()
return ("use", "reauth", "cancel")[idx]
except Exception:
pass
print(title)
for i, label in enumerate(choices, 1):
marker = "" if i == 1 else " "
print(f" {marker} {i}. {label}")
print()
try:
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
if choice == "2":
return "reauth"
if choice == "3":
return "cancel"
return "use"
def _model_flow_openrouter(config, current_model=""):
"""OpenRouter provider: ensure API key, then pick model."""
from hermes_cli.main import _prompt_api_key
@@ -64,7 +102,12 @@ def _model_flow_openrouter(config, current_model=""):
pricing = get_pricing_for_provider("openrouter", force_refresh=True)
selected = _prompt_model_selection(
openrouter_models, current_model=current_model, pricing=pricing
openrouter_models,
current_model=current_model,
pricing=pricing,
confirm_provider="openrouter",
confirm_base_url=OPENROUTER_BASE_URL,
confirm_api_key=_resolved or existing_key,
)
if selected:
_save_model_choice(selected)
@@ -273,6 +316,9 @@ def _model_flow_nous(config, current_model="", args=None):
unavailable_models=unavailable_models,
portal_url=_nous_portal_url,
unavailable_message=unavailable_message,
confirm_provider="nous",
confirm_base_url=creds.get("base_url", ""),
confirm_api_key=creds.get("api_key", ""),
)
if selected:
_save_model_choice(selected)
@@ -321,16 +367,9 @@ def _model_flow_openai_codex(config, current_model=""):
if status.get("logged_in"):
print(" OpenAI Codex credentials: ✓")
print()
print(" 1. Use existing credentials")
print(" 2. Reauthenticate (new OAuth login)")
print(" 3. Cancel")
print()
try:
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
choice = _prompt_auth_credentials_choice("OpenAI Codex credentials:")
if choice == "2":
if choice == "reauth":
print("Starting a fresh OpenAI Codex login...")
print()
try:
@@ -350,7 +389,7 @@ def _model_flow_openai_codex(config, current_model=""):
if not status.get("logged_in"):
print("Login failed.")
return
elif choice == "3":
elif choice == "cancel":
return
else:
print("Not logged into OpenAI Codex. Starting login...")
@@ -385,7 +424,13 @@ def _model_flow_openai_codex(config, current_model=""):
codex_models = get_codex_model_ids(access_token=_codex_token)
selected = _prompt_model_selection(codex_models, current_model=current_model)
selected = _prompt_model_selection(
codex_models,
current_model=current_model,
confirm_provider="openai-codex",
confirm_base_url=DEFAULT_CODEX_BASE_URL,
confirm_api_key=_codex_token or "",
)
if selected:
_save_model_choice(selected)
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
@@ -411,16 +456,11 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
if status.get("logged_in"):
print(" xAI Grok OAuth (SuperGrok / Premium+) credentials: ✓")
print()
print(" 1. Use existing credentials")
print(" 2. Reauthenticate (new OAuth login)")
print(" 3. Cancel")
print()
try:
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
choice = _prompt_auth_credentials_choice(
"xAI Grok OAuth (SuperGrok / Premium+) credentials:"
)
if choice == "2":
if choice == "reauth":
print("Starting a fresh xAI OAuth login...")
print()
try:
@@ -444,7 +484,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
except Exception as exc:
print(f"Login failed: {exc}")
return
elif choice == "3":
elif choice == "cancel":
return
else:
print("Not logged into xAI Grok OAuth (SuperGrok / Premium+). Starting login...")
@@ -520,7 +560,12 @@ def _model_flow_qwen_oauth(_config, current_model=""):
models = list(_DEFAULT_QWEN_PORTAL_MODELS)
default = current_model or (models[0] if models else "qwen3-coder-plus")
selected = _prompt_model_selection(models, current_model=default)
selected = _prompt_model_selection(
models,
current_model=default,
confirm_provider="qwen-oauth",
confirm_base_url=DEFAULT_QWEN_BASE_URL,
)
if selected:
_save_model_choice(selected)
_update_config_for_provider("qwen-oauth", DEFAULT_QWEN_BASE_URL)
@@ -569,7 +614,12 @@ def _model_flow_minimax_oauth(config, current_model="", args=None):
from hermes_cli.models import _PROVIDER_MODELS
model_ids = _PROVIDER_MODELS.get("minimax-oauth", [])
selected = _prompt_model_selection(model_ids, current_model)
selected = _prompt_model_selection(
model_ids,
current_model,
confirm_provider="minimax-oauth",
confirm_base_url=creds["base_url"],
)
if not selected:
return
_save_model_choice(selected)
@@ -638,7 +688,12 @@ def _model_flow_google_gemini_cli(_config, current_model=""):
models = list(_PROVIDER_MODELS.get("google-gemini-cli") or [])
default = current_model or (models[0] if models else "gemini-3-flash-preview")
selected = _prompt_model_selection(models, current_model=default)
selected = _prompt_model_selection(
models,
current_model=default,
confirm_provider="google-gemini-cli",
confirm_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,
)
if selected:
_save_model_choice(selected)
_update_config_for_provider(
@@ -1563,7 +1618,11 @@ def _model_flow_copilot(config, current_model=""):
if model_list:
selected = _prompt_model_selection(
model_list, current_model=normalized_current_model
model_list,
current_model=normalized_current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=api_key,
)
else:
try:
@@ -1701,6 +1760,9 @@ def _model_flow_copilot_acp(config, current_model=""):
selected = _prompt_model_selection(
model_list,
current_model=normalized_current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=catalog_api_key,
)
else:
try:
@@ -1805,7 +1867,13 @@ def _model_flow_kimi(config, current_model=""):
model_list = _PROVIDER_MODELS.get("moonshot", [])
if model_list:
selected = _prompt_model_selection(model_list, current_model=current_model)
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=existing_key,
)
else:
try:
selected = input("Enter model name: ").strip()
@@ -1913,7 +1981,13 @@ def _model_flow_stepfun(config, current_model=""):
)
if model_list:
selected = _prompt_model_selection(model_list, current_model=current_model)
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=existing_key,
)
else:
try:
selected = input("Model name: ").strip()
@@ -1989,7 +2063,13 @@ def _model_flow_bedrock_api_key(config, region, current_model=""):
print(f" Showing {len(model_list)} curated models")
if model_list:
selected = _prompt_model_selection(model_list, current_model=current_model)
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider="custom",
confirm_base_url=mantle_base_url,
confirm_api_key=existing_key,
)
else:
try:
selected = input(" Model ID: ").strip()
@@ -2178,7 +2258,12 @@ def _model_flow_bedrock(config, current_model=""):
# 4. Model selection
if model_list:
selected = _prompt_model_selection(model_list, current_model=current_model)
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider="bedrock",
confirm_base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
)
else:
try:
selected = input(" Model ID: ").strip()
@@ -2462,7 +2547,13 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
model_list = list(dict.fromkeys(mid for mid in model_list if mid))
if model_list:
selected = _prompt_model_selection(model_list, current_model=current_model)
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=existing_key,
)
else:
try:
selected = input("Model name: ").strip()
@@ -2560,20 +2651,13 @@ def _model_flow_anthropic(config, current_model=""):
elif cc_available:
print(" Claude Code credentials: ✓ (auto-detected)")
print()
print(" 1. Use existing credentials")
print(" 2. Reauthenticate (new OAuth login)")
print(" 3. Cancel")
print()
try:
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
choice = _prompt_auth_credentials_choice("Anthropic credentials:")
if choice == "2":
if choice == "reauth":
needs_auth = True
elif choice == "3":
elif choice == "cancel":
return
# choice == "1" or default: use existing, proceed to model selection
# choice == "use" or default: use existing, proceed to model selection
if needs_auth:
# Show auth method choice
@@ -2619,7 +2703,11 @@ def _model_flow_anthropic(config, current_model=""):
# Model selection
model_list = _PROVIDER_MODELS.get("anthropic", [])
if model_list:
selected = _prompt_model_selection(model_list, current_model=current_model)
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider="anthropic",
)
else:
try:
selected = input("Model name (e.g., claude-sonnet-4-20250514): ").strip()
+2 -1
View File
@@ -151,10 +151,11 @@ def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool:
def _has_agent_browser() -> bool:
import shutil
from hermes_constants import get_hermes_source_root
agent_browser_bin = shutil.which("agent-browser")
local_bin = (
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
get_hermes_source_root() / "node_modules" / ".bin" / "agent-browser"
)
return bool(agent_browser_bin or local_bin.exists())
+2 -1
View File
@@ -62,7 +62,8 @@ def get_bundled_plugins_dir() -> Path:
env_override = os.getenv("HERMES_BUNDLED_PLUGINS")
if env_override:
return Path(env_override)
return Path(__file__).resolve().parent.parent / "plugins"
from hermes_constants import get_hermes_source_root
return get_hermes_source_root() / "plugins"
try:
import yaml
+2 -1
View File
@@ -900,7 +900,8 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict
"user_modified": [],
"skipped_opt_out": True,
}
project_root = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
project_root = get_hermes_source_root()
try:
result = subprocess.run(
[sys.executable, "-c",
+1 -1
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
def _print_aiohttp_missing() -> None:
print(
"hermes proxy requires aiohttp. Install one of:\n"
" pip install 'hermes-agent[messaging]'\n"
" uv pip install -e '.[messaging]' # from the hermes-agent checkout\n"
" pip install aiohttp",
file=sys.stderr,
)
+2 -2
View File
@@ -86,7 +86,7 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application":
if not AIOHTTP_AVAILABLE:
raise RuntimeError(
"aiohttp is required for `hermes proxy`. Install with: "
"pip install 'hermes-agent[messaging]' or `pip install aiohttp`."
"uv pip install -e '.[messaging]' # from the hermes-agent checkout, or: pip install aiohttp."
)
app = web.Application()
@@ -253,7 +253,7 @@ async def run_server(
if not AIOHTTP_AVAILABLE:
raise RuntimeError(
"aiohttp is required for `hermes proxy`. Install with: "
"pip install 'hermes-agent[messaging]' or `pip install aiohttp`."
"uv pip install -e '.[messaging]' # from the hermes-agent checkout, or: pip install aiohttp."
)
app = create_app(adapter)
+11 -23
View File
@@ -16,11 +16,13 @@ import logging
import os
import re
import shutil
import subprocess
import sys
import copy
from pathlib import Path
from typing import Optional, Dict, Any
from hermes_cli.managed_uv import pip_install
from hermes_cli.nous_subscription import get_nous_subscription_features
from tools.tool_backend_helpers import managed_nous_tools_enabled
from utils import base_url_hostname
@@ -28,7 +30,8 @@ from hermes_constants import get_optional_skills_dir
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
_DOCS_BASE = "https://hermes-agent.nousresearch.com/docs"
@@ -796,10 +799,8 @@ def _install_neutts_deps() -> bool:
print_info("This will also download the TTS model (~300MB) on first use.")
print()
try:
subprocess.run(
[sys.executable, "-m", "pip", "install", "-U", "neutts[all]", "--quiet"],
check=True, timeout=300,
)
result = pip_install(["neutts[all]"], quiet=True, upgrade=True, timeout=300)
result.check_returncode()
print_success("neutts installed successfully")
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
@@ -821,10 +822,8 @@ def _install_kittentts_deps() -> bool:
print_info("Installing kittentts Python package (~25-80MB model downloaded on first use)...")
print()
try:
subprocess.run(
[sys.executable, "-m", "pip", "install", "-U", wheel_url, "soundfile", "--quiet"],
check=True, timeout=300,
)
result = pip_install([wheel_url, "soundfile"], quiet=True, upgrade=True, timeout=300)
result.check_returncode()
print_success("kittentts installed successfully")
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
@@ -1285,11 +1284,7 @@ def setup_terminal_backend(config: dict):
text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "modal"],
capture_output=True,
text=True,
)
result = pip_install(["modal"])
if result.returncode == 0:
print_success("modal SDK installed")
else:
@@ -1338,11 +1333,7 @@ def setup_terminal_backend(config: dict):
text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "daytona"],
capture_output=True,
text=True,
)
result = pip_install(["daytona"])
if result.returncode == 0:
print_success("daytona SDK installed")
else:
@@ -1988,10 +1979,7 @@ def _setup_matrix():
capture_output=True, text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", matrix_pkg],
capture_output=True, text=True,
)
result = pip_install([matrix_pkg])
if result.returncode == 0:
print_success(f"{matrix_pkg} installed")
else:
+17 -1
View File
@@ -351,13 +351,29 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
"lobehub": 500, "browse-sh": 500,
}
with c.status("[bold]Fetching skills from registries..."):
with c.status("[bold]Fetching skills from registries...") as status:
# Live progress: tick off each source as it resolves so the wait is
# visible instead of a frozen spinner. parallel_search_sources invokes
# this callback from the collecting thread as each source completes;
# the page itself is still rendered once, after the correctly-merged
# and trust-sorted result set is final (browse's ordering contract is
# computed over the whole set, so we never render a half-sorted page).
_done: List[str] = []
def _on_source_done(sid: str, count: int) -> None:
_done.append(f"{sid} ({count})")
status.update(
"[bold]Fetching skills from registries...[/] "
f"[dim]done: {', '.join(_done)}[/]"
)
all_results, source_counts, timed_out = parallel_search_sources(
sources,
query="",
per_source_limits=_PER_SOURCE_LIMIT,
source_filter=source,
overall_timeout=30,
on_source_done=_on_source_done,
)
if not all_results:
+2 -1
View File
@@ -9,7 +9,8 @@ import sys
import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
from hermes_cli.auth import AuthError, resolve_provider
from hermes_cli.colors import Colors, color
+13 -75
View File
@@ -24,6 +24,7 @@ from hermes_cli.config import (
load_config, save_config, get_env_value, save_env_value,
)
from hermes_cli.colors import Colors, color
from hermes_cli.managed_uv import pip_install
from hermes_cli.nous_subscription import (
apply_nous_managed_defaults,
get_nous_subscription_features,
@@ -34,7 +35,8 @@ from utils import base_url_hostname, is_truthy_value
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
# ─── UI Helpers (shared with setup.py) ────────────────────────────────────────
@@ -580,73 +582,10 @@ def _cua_driver_cmd() -> str:
return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver"
def _pip_install(
args: List[str],
*,
timeout: int = 300,
capture_output: bool = True,
):
"""Install Python packages from a post-setup hook.
Strategy (in order):
1. ``uv pip install`` if uv is on PATH fast, doesn't need pip in the venv.
2. ``python -m pip install`` works on stdlib venvs.
3. ``python -m ensurepip --upgrade`` then retry pip covers ``uv venv``
which creates a venv WITHOUT pip.
Why this exists: the Windows installer creates the venv via ``uv venv``,
which doesn't seed pip. Post-setup hooks that shelled out to
``[sys.executable, '-m', 'pip', 'install', ...]`` failed with
``No module named pip`` on every fresh install. uv-first sidesteps that.
Returns the ``subprocess.CompletedProcess`` from whichever tier succeeded
(or the last failure for the caller to inspect).
"""
venv_root = Path(sys.executable).parent.parent
uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)}
uv_bin = shutil.which("uv")
if uv_bin:
try:
result = subprocess.run(
[uv_bin, "pip", "install", *args],
capture_output=capture_output, text=True, timeout=timeout,
env=uv_env,
)
if result.returncode == 0:
return result
# Fall through to pip — uv may have failed for an unrelated reason
# (resolution conflict, network), and pip might handle it.
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
pip_cmd = [sys.executable, "-m", "pip"]
try:
# Probe for pip; bootstrap via ensurepip if missing (uv venv lacks it).
probe = subprocess.run(
pip_cmd + ["--version"],
capture_output=True, text=True, timeout=15,
)
if probe.returncode != 0:
raise FileNotFoundError("pip not in venv")
except (subprocess.TimeoutExpired, FileNotFoundError):
try:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
capture_output=True, text=True, timeout=120, check=True,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
# Synthesize a result so callers see a clean failure path.
return subprocess.CompletedProcess(
pip_cmd, returncode=1, stdout="",
stderr=f"pip not available and ensurepip failed: {e}",
)
return subprocess.run(
pip_cmd + ["install", *args],
capture_output=capture_output, text=True, timeout=timeout,
)
def pip_install_deps(packages: list[str], *, timeout: int = 300, quiet: bool = True) -> subprocess.CompletedProcess:
"""Thin wrapper around the authoritative managed_uv.pip_install for tools_config."""
from hermes_cli.managed_uv import pip_install
return pip_install(packages, timeout=timeout, quiet=quiet)
def _check_cua_driver_asset_for_arch() -> bool:
@@ -833,6 +772,8 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -
def _run_post_setup(post_setup_key: str):
"""Run post-setup hooks for tools that need extra installation steps."""
import shutil
import subprocess
if post_setup_key in {"agent_browser", "browserbase"}:
node_modules = PROJECT_ROOT / "node_modules" / "agent-browser"
npm_bin = shutil.which("npm")
@@ -840,7 +781,6 @@ def _run_post_setup(post_setup_key: str):
# Step 1: install the agent-browser npm package into node_modules/
if not node_modules.exists() and npm_bin:
_print_info(" Installing Node.js dependencies for browser tools...")
import subprocess
# Use the resolved npm_bin absolute path so subprocess.Popen can
# execute npm.cmd on Windows (CreateProcessW otherwise rejects
# batch shims). On POSIX npm_bin is the plain path — same
@@ -908,7 +848,6 @@ def _run_post_setup(post_setup_key: str):
return
_print_info(" Installing Chromium (~170MB one-time download)...")
import subprocess
# Prefer the bundled agent-browser install subcommand so the
# version of Chromium matches the CLI. Fall back to npx shim on
# setups where the local bin stub isn't present.
@@ -951,7 +890,6 @@ def _run_post_setup(post_setup_key: str):
_npm_bin = shutil.which("npm")
if not camofox_dir.exists() and _npm_bin:
_print_info(" Installing Camofox browser server...")
import subprocess
# Absolute npm path so .cmd shim executes on Windows.
result = subprocess.run(
# --workspaces=false avoids resolving apps/desktop. See #38772.
@@ -987,7 +925,7 @@ def _run_post_setup(post_setup_key: str):
"0.8.1/kittentts-0.8.1-py3-none-any.whl"
)
try:
result = _pip_install(["-U", wheel_url, "soundfile", "--quiet"], timeout=300)
result = pip_install_deps(["-U", wheel_url, "soundfile", "--quiet"], timeout=300)
if result.returncode == 0:
_print_success(" kittentts installed")
_print_info(" Voices: Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo")
@@ -1007,7 +945,7 @@ def _run_post_setup(post_setup_key: str):
except ImportError:
_print_info(" Installing piper-tts (~14MB wheel, voices downloaded on first use)...")
try:
result = _pip_install(["-U", "piper-tts", "--quiet"], timeout=300)
result = pip_install_deps(["-U", "piper-tts", "--quiet"], timeout=300)
if result.returncode == 0:
_print_success(" piper-tts installed")
else:
@@ -1030,7 +968,7 @@ def _run_post_setup(post_setup_key: str):
except ImportError:
_print_info(" Installing ddgs (DuckDuckGo search package)...")
try:
result = _pip_install(["-U", "ddgs", "--quiet"], timeout=300)
result = pip_install_deps(["-U", "ddgs", "--quiet"], timeout=300)
if result.returncode == 0:
_print_success(" ddgs installed")
else:
@@ -1081,7 +1019,7 @@ def _run_post_setup(post_setup_key: str):
_print_success(" langfuse SDK already installed")
except ImportError:
_print_info(" Installing langfuse SDK...")
result = _pip_install(["langfuse", "--quiet"], timeout=120)
result = pip_install_deps(["langfuse", "--quiet"], timeout=120)
if result.returncode == 0:
_print_success(" langfuse SDK installed")
else:
+3 -4
View File
@@ -25,9 +25,7 @@ def log_success(msg: str):
def log_warn(msg: str):
print(f"{color('', Colors.YELLOW)} {msg}")
def get_project_root() -> Path:
"""Get the project installation directory."""
return Path(__file__).parent.parent.resolve()
def find_shell_configs() -> list:
@@ -572,7 +570,8 @@ def run_uninstall(args):
- Full uninstall: removes code + ~/.hermes/ (configs, data, logs)
- Keep data: removes code but keeps ~/.hermes/ for future reinstall
"""
project_root = get_project_root()
from hermes_constants import get_hermes_source_root
project_root = get_hermes_source_root()
hermes_home = get_hermes_home()
# Detect named profiles when uninstalling from the default root —
+103 -20
View File
@@ -37,7 +37,8 @@ from typing import Any, Dict, List, Optional, Tuple
import yaml
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
@@ -84,7 +85,7 @@ except ImportError:
except Exception:
raise SystemExit(
"Web UI requires fastapi and uvicorn.\n"
f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
f"Install with: uv pip install 'fastapi' 'uvicorn[standard]'"
)
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
@@ -246,7 +247,31 @@ def _has_valid_session_token(request: Request) -> bool:
def _require_token(request: Request) -> None:
"""Validate the ephemeral session token. Raises 401 on mismatch."""
"""Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.
Two auth schemes protect the dashboard, exactly one active per bind:
* **Loopback / ``--insecure`` mode** (``auth_required`` False): the
ephemeral ``_SESSION_TOKEN`` is injected into the SPA HTML and echoed
back via ``X-Hermes-Session-Token`` (or the legacy ``Bearer`` header).
Validate it here.
* **Gated / OAuth mode** (``auth_required`` True): ``_SESSION_TOKEN`` is
NOT injected (the SPA authenticates with a session cookie), so there is
no token to check. The ``gated_auth_middleware`` has already verified the
cookie before the request reached this handler any non-public ``/api/``
route it lets through carries a verified ``request.state.session``. The
legacy ``auth_middleware`` likewise short-circuits in this mode. Requiring
the (absent) token here would 401 every cookie-authenticated request,
making plugin install/enable/disable and the other ``_require_token``
endpoints permanently unreachable behind the gate. Defer to the gate.
"""
if getattr(request.app.state, "auth_required", False):
# Gate is authoritative. It attaches ``request.state.session`` on
# success and 401s otherwise, so a request that reached us is already
# authenticated. Belt-and-braces: confirm the session is present.
if getattr(request.state, "session", None) is not None:
return
raise HTTPException(status_code=401, detail="Unauthorized")
if not _has_valid_session_token(request):
raise HTTPException(status_code=401, detail="Unauthorized")
@@ -633,21 +658,6 @@ class AudioTranscriptionRequest(BaseModel):
mime_type: Optional[str] = None
class ModelAssignment(BaseModel):
"""Payload for POST /api/model/set — assign a provider/model to a slot.
scope="main" writes model.provider + model.default
scope="auxiliary" writes auxiliary.<task>.provider + auxiliary.<task>.model
scope="auxiliary" with task="" applied to every auxiliary.* slot
scope="auxiliary" with task="__reset__" resets every slot to provider="auto"
"""
scope: str
provider: str
model: str
task: str = ""
_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
"audio/aac": ".aac",
"audio/flac": ".flac",
@@ -689,6 +699,7 @@ class ModelAssignment(BaseModel):
# reads model.base_url from config (it ignores OPENAI_BASE_URL), so this is
# the path that actually wires a local endpoint into resolution.
base_url: str = ""
confirm_expensive_model: bool = False
def _apply_main_model_assignment(
@@ -1362,11 +1373,28 @@ def _tail_lines(path: Path, n: int) -> List[str]:
return lines[-n:] if n > 0 else lines
def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
Multiple dashboard paths can request a restart in quick succession
(restart button double-click, or a stale cached frontend firing its own
restart after the server already auto-restarted post-onboarding). Two
concurrent ``hermes gateway restart`` children race each other on the
manual kill-and-start path, so reuse the live one instead.
Returns ``(proc, reused)``.
"""
existing = _ACTION_PROCS.get("gateway-restart")
if existing is not None and existing.poll() is None:
return existing, True
return _spawn_hermes_action(["gateway", "restart"], "gateway-restart"), False
@app.post("/api/gateway/restart")
async def restart_gateway():
"""Kick off a ``hermes gateway restart`` in the background."""
try:
proc = _spawn_hermes_action(["gateway", "restart"], "gateway-restart")
proc, _reused = _spawn_gateway_restart()
except Exception as exc:
_log.exception("Failed to spawn gateway restart")
raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}")
@@ -2446,6 +2474,30 @@ async def set_model_assignment(body: ModelAssignment):
try:
cfg = load_config()
if model and not body.confirm_expensive_model:
try:
from hermes_cli.model_cost_guard import expensive_model_warning
# Pricing lookup can hit models.dev / a /models endpoint on a
# cache miss — keep it off the event loop.
warning = await asyncio.to_thread(
expensive_model_warning,
model,
provider=provider,
base_url=base_url,
)
except Exception:
warning = None
if warning is not None:
return {
"ok": False,
"scope": scope,
"provider": provider,
"model": model,
"confirm_required": True,
"confirm_message": warning.message,
}
if scope == "main":
if not provider or not model:
raise HTTPException(status_code=400, detail="provider and model required for main")
@@ -3714,6 +3766,34 @@ async def get_telegram_onboarding_status(pairing_id: str):
)
def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
"""Best-effort gateway restart after saving Telegram QR onboarding.
The QR flow naturally pulls users into Telegram on another device. If the
saved token waits on a separate dashboard restart click, Hermes appears
broken from the chat side. Keep the config save authoritative, but report
restart failures so the UI can fall back to the existing manual banner.
"""
try:
proc, reused = _spawn_gateway_restart()
except Exception as exc:
_log.exception("Failed to auto-restart gateway after Telegram onboarding")
return {
"restart_started": False,
"restart_error": str(exc),
}
if reused:
_log.info(
"Telegram onboarding: reusing in-flight gateway restart (pid %s)",
proc.pid,
)
return {
"restart_started": True,
"restart_action": "gateway-restart",
"restart_pid": proc.pid,
}
@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
async def apply_telegram_onboarding(
pairing_id: str, body: TelegramOnboardingApply
@@ -3768,11 +3848,14 @@ async def apply_telegram_onboarding(
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
restart_result = _restart_gateway_after_telegram_onboarding()
return {
"ok": True,
"platform": "telegram",
"bot_username": bot_username,
"needs_restart": True,
"needs_restart": not restart_result["restart_started"],
**restart_result,
}
+33 -21
View File
@@ -20,7 +20,10 @@ from typing import List, Optional
from tools import write_approval as wa
_VALID_MODES = (wa.MODE_ON, wa.MODE_OFF, wa.MODE_APPROVE)
def _fmt_state(subsystem: str) -> str:
on = wa.write_approval_enabled(subsystem)
return f"{subsystem}.write_approval = {'on' if on else 'off'}"
# ---------------------------------------------------------------------------
@@ -63,18 +66,17 @@ def handle_pending_subcommand(
memory_store: live MemoryStore for applying approved memory writes
(CLI passes ``self.agent._memory_store``; gateway applies against a
freshly loaded store).
set_mode_fn: optional callable ``(mode: str) -> None`` that persists the
new write_mode to config (gateway provides this; CLI uses its own
``save_config_value`` and passes a closure).
set_mode_fn: optional callable ``(enabled: bool) -> None`` that
persists the new write_approval boolean to config (gateway provides
this; CLI uses its own ``save_config_value`` and passes a closure).
Returns a text string to show the user. Returns None when the args are not
a write-approval subcommand (caller falls through to its other handling,
e.g. /skills search).
"""
if not args:
# Bare /memory or /skills with no sub → show pending + current mode.
mode = wa.get_write_mode(subsystem)
return f"{subsystem}.write_mode = {mode}\n\n" + _fmt_pending_list(subsystem)
# Bare /memory or /skills with no sub → show pending + gate state.
return f"{_fmt_state(subsystem)}\n\n" + _fmt_pending_list(subsystem)
sub = args[0].lower()
rest = args[1:]
@@ -91,8 +93,8 @@ def handle_pending_subcommand(
if sub == "diff" and subsystem == wa.SKILLS:
return _diff(rest)
if sub == "mode":
return _set_mode(subsystem, rest, set_mode_fn)
if sub in {"approval", "mode"}: # 'mode' kept as a back-compat alias
return _set_approval(subsystem, rest, set_mode_fn)
return None # not ours — caller handles
@@ -179,19 +181,29 @@ def _diff(rest: List[str]) -> str:
return header + "\n" + diff
def _set_mode(subsystem: str, rest: List[str], set_mode_fn) -> str:
def _set_approval(subsystem: str, rest: List[str], set_mode_fn) -> str:
"""Turn the approval gate on/off for a subsystem.
``set_mode_fn`` (when provided) persists the new boolean to config.
"""
if not rest:
cur = wa.get_write_mode(subsystem)
return (f"{subsystem}.write_mode = {cur}\n"
f"Set with: /{subsystem} mode <on|off|approve>")
mode = rest[0].lower()
if mode not in _VALID_MODES:
return f"Invalid mode '{mode}'. Use: on, off, approve."
return (f"{_fmt_state(subsystem)}\n"
f"Set with: /{subsystem} approval <on|off>")
arg = rest[0].strip().lower()
truthy = {"on", "true", "yes", "1", "enable", "enabled"}
falsey = {"off", "false", "no", "0", "disable", "disabled"}
if arg in truthy:
enabled = True
elif arg in falsey:
enabled = False
else:
return f"Invalid value '{arg}'. Use: on or off."
if set_mode_fn is None:
return (f"To change {subsystem} write mode, run:\n"
f" hermes config set {subsystem}.write_mode {mode}")
val = "true" if enabled else "false"
return (f"To change the {subsystem} approval gate, run:\n"
f" hermes config set {subsystem}.write_approval {val}")
try:
set_mode_fn(mode)
set_mode_fn(enabled)
except Exception as e:
return f"Failed to set {subsystem}.write_mode: {e}"
return f"{subsystem}.write_mode set to '{mode}'."
return f"Failed to set {subsystem}.write_approval: {e}"
return f"{subsystem}.write_approval set to '{'on' if enabled else 'off'}'."
+22
View File
@@ -11,6 +11,28 @@ from contextvars import ContextVar, Token
from pathlib import Path
# ---------------------------------------------------------------------------
# Source-root helper — single source of truth for the repo / install root.
# ---------------------------------------------------------------------------
# ``hermes_constants.py`` lives at the repo root, so its parent *is* the root.
# Every other module that previously did ``Path(__file__).parent.parent`` to
# climb out of sub-packages (hermes_cli/, gateway/, tools/, …) should call
# this instead. Tests can monkeypatch it directly without touching __file__.
def get_hermes_source_root() -> Path:
"""Return the Hermes Agent source / installation root directory.
This is the single authoritative way to locate the project root.
Previously this was scattered across ~20 files as
``Path(__file__).parent.parent.resolve()`` relative to each sub-package.
Returns the directory that contains ``pyproject.toml``, ``hermes_constants.py``,
``hermes_cli/``, ``tools/``, etc.
"""
return Path(__file__).resolve().parent
_profile_fallback_warned: bool = False
_UNSET = object()
_HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar(
+2 -2
View File
@@ -452,7 +452,7 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
if not _MCP_SERVER_AVAILABLE:
raise ImportError(
"MCP server requires the 'mcp' package. "
f"Install with: {sys.executable} -m pip install 'mcp'"
f"Install with: uv pip install 'mcp'"
)
mcp = FastMCP(
@@ -868,7 +868,7 @@ def run_mcp_server(verbose: bool = False) -> None:
if not _MCP_SERVER_AVAILABLE:
print(
"Error: MCP server requires the 'mcp' package.\n"
f"Install with: {sys.executable} -m pip install 'mcp'",
f"Install with: uv pip install 'mcp'",
file=sys.stderr,
)
sys.exit(1)
+8
View File
@@ -1,3 +1,11 @@
# ⚠️ DEPRECATED
This Homebrew formula is **discontinued and no longer receives updates**.
Please use a supported installation method (curl installer, Docker, or Nix).
See https://hermes-agent.nousresearch.com/ for details.
---
Homebrew packaging notes for Hermes Agent.
Use `packaging/homebrew/hermes-agent.rb` as a tap or `homebrew-core` starting point.
+5
View File
@@ -1,8 +1,13 @@
# FROZEN: This formula is deprecated and will not receive further updates.
# pip/PyPI and Homebrew installations are discontinued.
# See https://hermes-agent.nousresearch.com/ for supported install methods.
class HermesAgent < Formula
include Language::Python::Virtualenv
desc "Self-improving AI agent that creates skills from experience"
homepage "https://hermes-agent.nousresearch.com"
deprecate! because: "is discontinued upstream. See https://hermes-agent.nousresearch.com/ for supported install methods."
# Stable source should point at the semver-named sdist asset attached by
# scripts/release.py, not the CalVer tag tarball.
url "https://github.com/NousResearch/hermes-agent/releases/download/v2026.3.30/hermes_agent-0.6.0.tar.gz"
+259
View File
@@ -0,0 +1,259 @@
# Platform Support Tiers — Implementation Plan
## Goal
Formalize Hermes Agent's platform support into three tiers, document them in the repo and docs, and deprecate removed platforms (pip/PyPI, Homebrew formula) with a clear migration path.
---
## Platform Support Tiers
### Explicitly supported — guaranteed to work, first-party installers only
| Platform | Installer |
|----------|-----------|
| Linux x86_64 / arm64 | `curl \| bash` installer, Docker image |
| Latest Debian, Ubuntu, Fedora, Windows WSL | `curl \| bash` installer |
| Official Docker image | `docker pull` |
| macOS arm64 | Desktop app installer, `curl \| bash` installer |
| Windows x86_64 / arm64 | Desktop app installer, PowerShell installer |
### Best-effort — PRs accepted for fixes, but Nous won't prioritize them, nor will we accept packaging-specific code in the repo
- Termux / Android
- AUR packaging
- Homebrew packaging
- Nix packaging (flake + NixOS module stay in-tree and maintained; packaging bugs outside core nix support are best-effort)
### Explicitly unsupported — no PRs for support will be accepted
- macOS x86_64
- Packaging via pip / PyPI
- FreeBSD
---
## Where the tier information lives
### In the repo (for agents and contributors)
| File | What to add |
|------|-------------|
| `AGENTS.md` | New `## Platform Support` section with the tier table and a link to the docs reference page. This is the canonical in-repo source — agents and contributors read it first. |
| `CONTRIBUTING.md` | Update the "Contribution Priorities" section (currently lines 817) to reference the tiers. Replace the generic "Cross-platform compatibility" bullet with explicit tier-aware language: PRs for best-effort platforms are welcome but won't block releases; PRs for unsupported platforms will be closed. |
### In the docs (for users)
| File | What to add |
|------|-------------|
| `website/docs/reference/platform-support.md` (new) | The canonical user-facing reference. Full tier breakdown with nuance, migration instructions for each deprecated path, and a support policy summary. Other pages link here. |
| `website/docs/getting-started/installation.md` | Add a prominent tier summary (table or callout) near the top, before the install commands. Remove the pip install row from the install layout table. Add a "Migrating from pip" subsection. |
| `website/docs/getting-started/updating.md` | Replace the "pip installs" update section with a deprecation notice linking to the platform-support page. |
| `website/docs/getting-started/termux.md` | Add a best-effort support banner at the top. |
| `website/docs/getting-started/nix-setup.md` | Add a note clarifying that the nix flake + NixOS module are maintained in-tree but nix-specific packaging bugs are best-effort. |
### What does NOT change
- `flake.nix`, `nix/`, Dockerfile — these are deployment methods, not just packaging. They stay.
- `scripts/install.sh` termux detection — already works, no reason to break it.
- `constraints-termux.txt` and `[termux]`/`[termux-all]` extras in pyproject.toml — still needed for best-effort users.
---
## Deprecating removed platforms
### 1. pip / PyPI
**Step 1: Publish one final version to PyPI**
- Update the package `description` in `pyproject.toml` to include a deprecation prefix:
```
⚠️ DEPRECATED: pip/PyPI installs are discontinued. See https://hermes-agent.nousresearch.com/ for supported install methods.
```
- Bump the version (next semver, e.g. `0.17.0`)
- Cut the release through the existing pipeline (`scripts/release.py` → tag push → `upload_to_pypi.yml`)
- After that release ships, **disable `upload_to_pypi.yml`**: add `if: false` to the job definitions and a comment explaining why
**Step 2: Add runtime deprecation notices**
Every touchpoint where Hermes detects a pip install must surface a clear deprecation warning. The message should be consistent across all surfaces:
> ⚠️ pip/PyPI installs are discontinued and no longer receive updates. Switch to a supported install method: https://hermes-agent.nousresearch.com/
In `hermes_cli/`:
| Location | Change |
|----------|--------|
| `config.py``detect_install_method()` returns `"pip"` | Keep returning `"pip"` (detection still works, needed so existing installs see the deprecation message) |
| `config.py``cmd_update` pip path | Replace the `uv pip install --upgrade hermes-agent` command with the deprecation message above. Do not attempt the upgrade — just print the message and exit. |
| `banner.py` — existing `detect_install_method() == "pip"` check | Add a deprecation line to the startup banner for pip installs |
| `main.py``hermes doctor` | Print the deprecation warning when `detect_install_method() == "pip"`, with an additional line: "Migrate with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh \| bash" |
**Step 3: Update the docs**
- `website/docs/getting-started/installation.md`: remove the pip install row from the install layout table; add a deprecation callout box; add a brief "Migrating from pip" section
- `website/docs/getting-started/updating.md`: replace the "pip installs" section with a deprecation notice + link
**Step 4: Clean up pyproject.toml and enforce build failure (after the final release)**
- Add a comment at the top of `[project]` noting that PyPI publishing is discontinued
- Remove `[project.scripts]` entries — they're only needed for pip's `console_scripts` entry points; git/docker/nix all use their own launchers
- Keep `[build-system]`, `[project.optional-dependencies]`, and `[tool.setuptools]` sections — they're used by nix build and local dev setup (like termux), not just pip
- Remove `hermes_agent.egg-info/` from tracking
- **Enforce wheel build failure**: replace any remaining `setup.py` with a minimal stub that explicitly raises a `RuntimeError("pip/wheel builds are discontinued. Please use curl install, docker, or nix. See https://hermes-agent.nousresearch.com/")`. This prevents accidental silent fallback builds.
- **Standardize on uv**: update all remaining local dev/build documentation, scripts, and comments to explicitly use `uv pip` instead of plain `pip`.
**Step 5: Rip out `ensurepip` and standardize entirely on `uv`**
Since the `curl | bash` installer and all supported environments guarantee a working `uv` binary, remove all legacy `ensurepip` bootstrapping and plain `pip` fallback logic across the codebase. Any remaining local dependency provisioning (e.g., in dev setups, update recovery, or tool environments like Modal) must strictly use `uv pip`. This eliminates race conditions, partial installs, and state confusion from legacy pip bootstrapping.
- Print a hard deprecation message instead of attempting any `pip install` commands in `config.py` / `main.py` update paths.
- `is_uv_tool_install()` detection can stay (it's used internally to differentiate from source/nix/docker builds).
### 2. Homebrew
**Step 1: Deprecate the formula**
- In `packaging/homebrew/hermes-agent.rb`, add Homebrew's official deprecation:
```ruby
deprecate! because: "is discontinued upstream. See https://hermes-agent.nousresearch.com/ for supported install methods."
```
- Bump the formula `url`/`version`/`sha256` one final time to match the last release
**Step 2: Add runtime deprecation notices**
Every touchpoint where Hermes detects a Homebrew install must surface a clear deprecation warning. The message should be consistent across all surfaces:
> ⚠️ Homebrew installs are discontinued and no longer receive updates. Switch to a supported install method: https://hermes-agent.nousresearch.com/
In `hermes_cli/`:
| Location | Change |
|----------|--------|
| `config.py``get_managed_update_command()` | Return the deprecation message above instead of `"brew upgrade hermes-agent"`. Do not suggest running brew upgrade. |
| `config.py``format_managed_message()` | Prepend the deprecation notice before any Homebrew-specific managed-install messages. |
| `banner.py` | Add a deprecation line to the startup banner when `get_managed_system() == "Homebrew"`. |
| `main.py``hermes doctor` | Print the deprecation warning when `detect_install_method() == "homebrew"`, with an additional line: "Migrate with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh \| bash" |
**Step 3: Mark the formula as frozen**
- Add a comment at the top of `hermes-agent.rb` saying it's frozen and won't receive further updates
- Update `packaging/homebrew/README.md` to say the formula is deprecated
- Keep the directory in-tree as a reference — don't delete it
### 3. AUR
- No in-tree code. Purely a social/docs change.
- Add a note in `website/docs/reference/platform-support.md` saying AUR packaging is community-maintained and best-effort.
- No code changes needed.
### 4. Nix (stays in-tree, boundary clarified)
- `flake.nix` and `nix/` stay in-tree and maintained — they're a deployment method, not just packaging
- Add a comment in `flake.nix` and `nix/packages.nix` clarifying that nix-specific packaging bugs (e.g. a new dependency that doesn't build under nix) are best-effort: PRs accepted, but won't block releases
- The flake's `systems` list already excludes `x86_64-darwin` (only `x86_64-linux`, `aarch64-linux`, `aarch64-darwin`) — that's correct for the new tiers, no change needed
### 5. Termux
- Add a best-effort support banner to `website/docs/getting-started/termux.md`
- `constraints-termux.txt`, `[termux]`/`[termux-all]` extras, and `scripts/install.sh` termux detection all stay
- Termux-specific bugs won't block releases
### 6. macOS x86_64
- Add a runtime warning: if `platform.machine() == 'x86_64'` and `sys.platform == 'darwin'`, print a one-time deprecation notice in `hermes doctor` saying the platform is unsupported
- Don't actively break anything — just set expectations
---
## Execution order
1. Add `## Platform Support` section to `AGENTS.md`
2. Create `website/docs/reference/platform-support.md`
3. Update `CONTRIBUTING.md` to reference the tiers
4. Update `website/docs/getting-started/installation.md` with tier info and pip deprecation
5. Update `website/docs/getting-started/updating.md` with pip deprecation
6. Add runtime deprecation notices in `hermes_cli/config.py`, `hermes_cli/banner.py`, `hermes_cli/main.py` for **both pip and Homebrew** installs
7. Update `packaging/homebrew/hermes-agent.rb` with `deprecate!`
8. Cut the final PyPI release with deprecation description in pyproject.toml
9. After the release: disable `upload_to_pypi.yml`, replace pip update command with deprecation message, add deprecation comments to pyproject.toml, remove `[project.scripts]`
10. Rip out all `ensurepip` and legacy `pip` fallback logic across `hermes_cli/main.py`, `hermes_cli/tools_config.py`, `tools/lazy_deps.py`, `tools/environments/modal.py`, and install scripts, standardizing exclusively on `uv pip`.
11. Add termux best-effort banner to termux docs
12. Add macOS x86_64 unsupported warning to `hermes doctor`
---
## Files touched (summary)
| File | Action |
|------|--------|
| `AGENTS.md` | Add platform support section |
| `CONTRIBUTING.md` | Update contribution priorities with tier awareness |
| `website/docs/reference/platform-support.md` | Create — canonical user-facing tier reference |
| `website/docs/getting-started/installation.md` | Add tier summary, pip deprecation, migration section |
| `website/docs/getting-started/updating.md` | Replace pip section with deprecation |
| `website/docs/getting-started/termux.md` | Add best-effort banner |
| `website/docs/getting-started/nix-setup.md` | Add best-effort boundary note |
| `hermes_cli/config.py` | Deprecation messages for pip update + Homebrew update command |
| `hermes_cli/banner.py` | Deprecation line for pip installs + Homebrew installs |
| `hermes_cli/main.py` | `hermes doctor` pip + Homebrew deprecation warnings, macOS x86_64 warning |
| `hermes_cli/tools_config.py` | Rip out `ensurepip` fallback, standardize on `uv pip` for local provisioning |
| `tools/lazy_deps.py` | Rip out `ensurepip` fallback, standardize on `uv pip` for local provisioning |
| `tools/environments/modal.py` | Rip out `ensurepip` fallback, standardize on `uv pip` for local provisioning |
| `scripts/install.ps1` | Rip out `ensurepip` fallback, standardize on `uv pip` for local provisioning |
| `packaging/homebrew/hermes-agent.rb` | Add `deprecate!`, freeze comment |
| `packaging/homebrew/README.md` | Mark as deprecated |
| `pyproject.toml` | Deprecation description, later: remove `[project.scripts]`, add comments |
| `.github/workflows/upload_to_pypi.yml` | Disable after final release (`if: false`) |
---
## Full removal (future, after deprecation period)
After a suitable deprecation period (suggested: 23 minor releases, or ~6 months), fully remove the deprecated code and packaging infrastructure. This is a separate PR to avoid breaking existing installs prematurely.
### pip / PyPI — full removal
| Item | Action |
|------|--------|
| `hermes_cli/config.py``detect_install_method()` | Remove the `"pip"` return path entirely. If no stamp, no managed marker, and no `.git`, treat it as an unknown install rather than defaulting to pip. |
| `hermes_cli/config.py``cmd_update` pip path | Remove the pip-specific update branch. |
| `hermes_cli/config.py``_MANAGED_SYSTEM_NAMES` | Remove `"brew"` and `"homebrew"` entries. |
| `hermes_cli/config.py``get_managed_update_command()` | Remove the `Homebrew` branch. |
| `hermes_cli/config.py``format_managed_message()` | Remove the `Homebrew` branch. |
| `hermes_cli/banner.py` | Remove pip and Homebrew deprecation lines from the banner. |
| `hermes_cli/main.py``hermes doctor` | Remove pip and Homebrew deprecation warnings. |
| `pyproject.toml``[project.optional-dependencies]` | Keep the `termux` and `termux-all` extras (local source builds via uv/nix still use them for best-effort support). Remove the `pty` and `vision` back-compat aliases (they were legacy pip-only install targets). |
| `pyproject.toml``[project]` | Remove `description` deprecation prefix. |
| `setup.py` | Replace with a minimal stub that raises a `RuntimeError` explaining pip/wheel builds are discontinued (prevents silent fallback builds). Move any skills/optional-skills data-file logic into the nix build if nix still needs it. |
| `hermes_agent.egg-info/` | Delete entirely. |
| `.github/workflows/upload_to_pypi.yml` | Delete the workflow file. |
| `constraints-termux.txt` | Remove — termux users build from source and can maintain their own constraints. |
| `scripts/install_psutil_android.py` | Remove — termux-specific pip hack. |
| `tests/test_packaging_metadata.py` | Remove pip-specific assertions (wheel/sdist packaging tests). |
| `tests/test_termux_all_extra_compat.py` | Remove. |
| `tests/test_wheel_locales_e2e.py` | Remove — tests pip wheel install behavior. |
| `tests/hermes_cli/test_cmd_update.py` — pip regression tests | Remove the `"pip"` parameterized test cases. |
| `tests/hermes_cli/test_cmd_update_docker.py` — pip test case | Remove the `test_cmd_update_check_on_pip_install_still_uses_pypi` test. |
### Homebrew — full removal
| Item | Action |
|------|--------|
| `packaging/homebrew/` | Delete the entire directory (formula + README). The formula is frozen and will never be updated again — no reason to keep it. |
| `hermes_cli/config.py``_MANAGED_SYSTEM_NAMES` | Remove `"brew"` and `"homebrew"` entries (listed above, duplicate for clarity). |
| `pyproject.toml``[project.optional-dependencies]` comments | Remove Homebrew-specific comments (e.g. the `voice` extra comment about "source-build packagers like Homebrew"). |
| `pyproject.toml``[all]` policy comment | Remove the "packagers (Nix, AUR, Homebrew)" references, update to just "packagers (Nix, AUR)". |
### macOS x86_64 — full removal
| Item | Action |
|------|--------|
| `hermes_cli/main.py``hermes doctor` | Remove the x86_64 macOS warning (or escalate to a hard error that refuses to start). |
| `flake.nix``systems` | Already correct (no `x86_64-darwin`). No change needed. |
### General cleanup
- **Rip out all `ensurepip` and `pip` fallback logic**: Search the codebase for `ensurepip`, `-m pip`, and `pip install`. Update `hermes_cli/main.py`, `hermes_cli/tools_config.py`, `tools/lazy_deps.py`, `tools/environments/modal.py`, and install scripts to exclusively use `uv pip` for *any* remaining local environment provisioning.
- Search the codebase for any remaining references to `"pip"`, `"homebrew"`, `"brew"`, `PyPI`, `pypi.org`, `upload_to_pypi`, `egg-info`, and `setup.py` — remove or update them.
- Run the full test suite to confirm nothing is broken by the removals.
- Update `AGENTS.md` and `website/docs/reference/platform-support.md` to remove any "deprecated" language and state the removed paths as simply unsupported (no longer "deprecated and still detected" — just gone).
+5 -10
View File
@@ -13,10 +13,12 @@ from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
from typing import Optional
from hermes_cli.managed_uv import pip_install
from hermes_constants import get_hermes_home
from plugins.google_meet import process_manager as pm
@@ -249,16 +251,9 @@ def _cmd_install(*, realtime: bool, assume_yes: bool) -> int:
# 1) pip deps — always safe, venv-scoped.
pip_pkgs = ["playwright", "websockets"]
print(f"\n[1/3] pip install: {' '.join(pip_pkgs)}")
try:
res = _sp.run(
[sys.executable, "-m", "pip", "install", "--upgrade", *pip_pkgs],
check=False,
)
if res.returncode != 0:
print(" pip install failed")
return 1
except Exception as e:
print(f" pip install failed: {e}")
res = pip_install(pip_pkgs, upgrade=True, capture_output=False)
if res.returncode != 0:
print(" pip install failed")
return 1
# 2) Playwright browsers — pulls chromium (~300MB first run).
+3 -7
View File
@@ -7,9 +7,11 @@ from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from hermes_cli.managed_uv import pip_install
from hermes_constants import get_hermes_home
from plugins.memory.honcho.client import _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST
from hermes_cli.config import cfg_get
@@ -410,14 +412,8 @@ def _ensure_sdk_installed() -> bool:
print(" Skipping install. Run: pip install 'honcho-ai>=2.0.1'\n")
return False
import subprocess
print(" Installing honcho-ai...", flush=True)
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"],
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
result = pip_install(["honcho-ai>=2.0.1"])
if result.returncode == 0:
print(" Installed.\n")
return True
+23 -3
View File
@@ -116,6 +116,8 @@ class OpenRouterProfile(ProviderProfile):
the same backend server across turns.
"""
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
extra_headers: dict[str, Any] = {}
if supports_reasoning:
# Reasoning-mandatory Anthropic models (Claude 4.6+ / fable /
# future named models) use *adaptive* thinking: the model decides
@@ -132,18 +134,36 @@ class OpenRouterProfile(ProviderProfile):
# The only reliable behavior is to omit ``reasoning`` and let the
# model default to adaptive. See hermes-agent#42991 (disable case)
# and the tool-replay follow-up.
#
# ``reasoning.effort`` being ignored does NOT mean these models have
# no effort lever — OpenRouter honors the requested effort on the
# top-level ``verbosity`` field instead (it maps to Anthropic's
# ``output_config.effort``; ``reasoning.effort`` is accepted but
# ignored — confirmed by OpenRouter's Claude migration docs and a
# live token-spend probe in hermes-agent#43432). Route the existing
# ``reasoning_config["effort"]`` (sourced from
# ``agent.reasoning_effort``) onto ``verbosity`` so the knob the user
# already sets keeps working for these models. We still send NO
# ``reasoning`` field, preserving the #42991 400 fix.
if _anthropic_reasoning_is_mandatory(model):
pass # omit reasoning entirely → adaptive default
cfg = reasoning_config or {}
effort = cfg.get("effort")
# Only emit when effort is actually requested and reasoning
# isn't explicitly disabled. Otherwise omit ``verbosity`` so the
# model keeps its own adaptive default (``high``).
if cfg.get("enabled", True) is not False and effort and effort != "none":
top_level["verbosity"] = effort
elif reasoning_config is not None:
extra_body["reasoning"] = dict(reasoning_config)
else:
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
extra_headers: dict[str, Any] = {}
if session_id and model and model.startswith(("x-ai/grok-", "xai/grok-")):
extra_headers["x-grok-conv-id"] = session_id
if extra_headers:
top_level["extra_headers"] = extra_headers
return extra_body, {"extra_headers": extra_headers} if extra_headers else {}
return extra_body, top_level
openrouter = OpenRouterProfile(
+115 -5
View File
@@ -602,6 +602,11 @@ class DiscordAdapter(BasePlatformAdapter):
self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop
self._voice_input_callback: Optional[Callable] = None # set by run.py
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
# Resolves the current voice-reply mode ("off"|"voice_only"|"all") for a
# linked text-channel id; set by run.py. Lets the inactivity timer leave
# the bot in the channel when the user deliberately picked text-only
# (/voice off) instead of leaving (/voice leave).
self._voice_mode_getter: Optional[Callable] = None # set by run.py
# Phase 3: continuous voice mixer (ambient idle bed + ducked speech).
# Installed once per guild on join; lets acks / TTS / the "thinking"
# loop overlap in one outgoing stream instead of stop-and-swap.
@@ -789,6 +794,7 @@ class DiscordAdapter(BasePlatformAdapter):
# Must run BEFORE the user allowlist check so that bots
# permitted by DISCORD_ALLOW_BOTS are not rejected for
# not being in DISCORD_ALLOWED_USERS (fixes #4466).
_role_authorized = False
if getattr(message.author, "bot", False):
allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
if allow_bots == "none":
@@ -812,6 +818,7 @@ class DiscordAdapter(BasePlatformAdapter):
is_dm=_is_dm,
):
return
_role_authorized = bool(getattr(self, "_allowed_role_ids", set()))
# Multi-agent filtering: if the message mentions specific bots
# but NOT this bot, the sender is talking to another agent —
@@ -853,7 +860,7 @@ class DiscordAdapter(BasePlatformAdapter):
if "*" not in _free_channels and not (_channel_ids & _free_channels):
return
await self._handle_message(message)
await self._handle_message(message, role_authorized=_role_authorized)
@self._client.event
async def on_voice_state_update(member, before, after):
@@ -2265,6 +2272,20 @@ class DiscordAdapter(BasePlatformAdapter):
except asyncio.CancelledError:
return
text_ch_id = self._voice_text_channels.get(guild_id)
# ``/voice off`` mutes spoken replies but deliberately keeps the bot in
# the channel (leaving is ``/voice leave``). The inactivity timer only
# counts the bot's OWN audio as activity, so under voice-off mode it
# fires every VOICE_TIMEOUT seconds, yanks the bot out, and spams the
# text channel with "Left voice channel (inactivity timeout)." Honor the
# user's choice: skip the auto-disconnect while voice replies are off.
# (The timer re-arms when the bot next speaks or hears a user.)
_mode_getter = getattr(self, "_voice_mode_getter", None)
if text_ch_id is not None and _mode_getter is not None:
try:
if _mode_getter(str(text_ch_id)) == "off":
return
except Exception:
pass
await self.leave_voice_channel(guild_id)
# Notify the runner so it can clean up voice_mode state
if self._on_voice_disconnect and text_ch_id:
@@ -2395,6 +2416,11 @@ class DiscordAdapter(BasePlatformAdapter):
is_dm=False,
):
continue
# A user speaking to the bot is activity too — not just the
# bot's own playback. Reset the inactivity timer so an active
# listener isn't disconnected mid-conversation (this also
# covers voice-on text-only sessions that never play audio).
self._reset_voice_timeout(guild_id)
await self._process_voice_input(guild_id, user_id, pcm_data)
except asyncio.CancelledError:
pass
@@ -4702,7 +4728,7 @@ class DiscordAdapter(BasePlatformAdapter):
raise Exception(f"HTTP {resp.status}")
return await resp.read()
async def _handle_message(self, message: DiscordMessage) -> None:
async def _handle_message(self, message: DiscordMessage, role_authorized: bool = False) -> None:
"""Handle incoming Discord messages."""
# In server channels (not DMs), require the bot to be @mentioned
# UNLESS the channel is in the free-response list or the message is
@@ -4886,6 +4912,7 @@ class DiscordAdapter(BasePlatformAdapter):
guild_id=str(guild.id) if guild else None,
parent_chat_id=parent_channel_id,
message_id=str(message.id),
role_authorized=role_authorized,
)
# Build media URLs -- download image attachments to local cache so the
@@ -5611,6 +5638,7 @@ def _define_discord_view_classes() -> None:
self.allowed_role_ids = allowed_role_ids or set()
self.resolved = False
self._selected_provider: str = ""
self._pending_expensive_model: str = ""
self._build_provider_select()
@@ -5693,6 +5721,41 @@ def _define_discord_view_classes() -> None:
cancel_btn.callback = self._on_cancel
self.add_item(cancel_btn)
def _build_expensive_confirm(self, model_id: str):
"""Build confirmation buttons for unusually expensive models."""
self.clear_items()
self._pending_expensive_model = model_id
confirm_btn = discord.ui.Button(
label="Switch anyway",
style=discord.ButtonStyle.red,
custom_id="model_expensive_confirm",
)
confirm_btn.callback = self._on_expensive_confirm
self.add_item(confirm_btn)
cancel_btn = discord.ui.Button(
label="Cancel",
style=discord.ButtonStyle.grey,
custom_id="model_expensive_cancel",
)
cancel_btn.callback = self._on_cancel
self.add_item(cancel_btn)
async def _expensive_warning_for(self, model_id: str):
try:
from hermes_cli.model_cost_guard import expensive_model_warning
# Pricing lookup can hit models.dev / a /models endpoint on a
# cache miss — keep it off the event loop.
return await asyncio.to_thread(
expensive_model_warning,
model_id,
provider=self._selected_provider,
)
except Exception:
return None
async def _on_provider_selected(self, interaction: discord.Interaction):
if not self._check_auth(interaction):
await interaction.response.send_message(
@@ -5722,7 +5785,11 @@ def _define_discord_view_classes() -> None:
view=self,
)
async def _on_model_selected(self, interaction: discord.Interaction):
async def _switch_selected_model(
self,
interaction: discord.Interaction,
model_id: str,
):
if self.resolved:
await interaction.response.send_message(
"Already resolved~", ephemeral=True
@@ -5735,7 +5802,6 @@ def _define_discord_view_classes() -> None:
return
self.resolved = True
model_id = interaction.data["values"][0]
self.clear_items()
await interaction.response.edit_message(
embed=discord.Embed(
@@ -5764,6 +5830,50 @@ def _define_discord_view_classes() -> None:
view=None,
)
async def _on_model_selected(self, interaction: discord.Interaction):
if self.resolved:
await interaction.response.send_message(
"Already resolved~", ephemeral=True
)
return
if not self._check_auth(interaction):
await interaction.response.send_message(
"You're not authorized~", ephemeral=True
)
return
model_id = interaction.data["values"][0]
warning = await self._expensive_warning_for(model_id)
if warning is not None:
self._build_expensive_confirm(model_id)
await interaction.response.edit_message(
embed=discord.Embed(
title="⚠ Expensive Model Warning",
description=warning.message,
color=discord.Color.red(),
),
view=self,
)
return
await self._switch_selected_model(interaction, model_id)
async def _on_expensive_confirm(self, interaction: discord.Interaction):
if not self._check_auth(interaction):
await interaction.response.send_message(
"You're not authorized~", ephemeral=True
)
return
if not self._pending_expensive_model:
await interaction.response.send_message(
"Model selection expired.", ephemeral=True
)
return
await self._switch_selected_model(
interaction,
self._pending_expensive_model,
)
async def _on_back(self, interaction: discord.Interaction):
if not self._check_auth(interaction):
await interaction.response.send_message(
@@ -6494,7 +6604,7 @@ def register(ctx) -> None:
check_fn=check_discord_requirements,
is_connected=_is_connected,
required_env=["DISCORD_BOT_TOKEN"],
install_hint="pip install 'hermes-agent[messaging]'",
install_hint="uv pip install -e '.[messaging]' # from the hermes-agent checkout",
# Interactive setup wizard — replaces the central
# hermes_cli/setup.py::_setup_discord function. Same shape as Teams.
setup_fn=interactive_setup,
+1 -1
View File
@@ -3299,7 +3299,7 @@ def register(ctx) -> None:
"GOOGLE_CHAT_SUBSCRIPTION_NAME",
"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON",
],
install_hint="pip install 'hermes-agent[google_chat]'",
install_hint="uv pip install -e '.[google_chat]' # from the hermes-agent checkout",
setup_fn=interactive_setup,
# Env-driven auto-configuration — the core env-populator hook calls
# this during ``_apply_env_overrides`` and seeds
+6 -6
View File
@@ -68,7 +68,8 @@ import sys
from pathlib import Path
from typing import Any, List, Optional, Tuple
# Pin the legacy logger name so operator-side log filters keep matching
from hermes_cli.managed_uv import pip_install
from utils import atomic_replace
# after the in-tree → plugin migration. See adapter.py for context.
logger = logging.getLogger("gateway.platforms.google_chat_user_oauth")
@@ -379,16 +380,15 @@ def install_deps() -> bool:
print("Installing Google Chat OAuth dependencies...")
try:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "--quiet"] + _REQUIRED_PACKAGES,
stdout=subprocess.DEVNULL,
)
result = pip_install(_REQUIRED_PACKAGES, quiet=True)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, result.args)
print("Dependencies installed.")
return True
except subprocess.CalledProcessError as exc:
print(f"ERROR: Failed to install dependencies: {exc}")
print("Or install via the optional extra:")
print(" pip install 'hermes-agent[google_chat]'")
print(" uv pip install -e '.[google_chat]' # from the hermes-agent checkout")
return False
+2 -1
View File
@@ -37,6 +37,7 @@ import sys
from pathlib import Path
from providers.base import OMIT_TEMPERATURE, ProviderProfile # noqa: F401
from hermes_constants import get_hermes_source_root
logger = logging.getLogger(__name__)
@@ -46,7 +47,7 @@ _discovered = False
# Repo-root ``plugins/model-providers/`` — populated at discovery time.
_BUNDLED_PLUGINS_DIR = (
Path(__file__).resolve().parent.parent / "plugins" / "model-providers"
get_hermes_source_root() / "plugins" / "model-providers"
)
+82 -16
View File
@@ -50,7 +50,7 @@ dependencies = [
"tenacity==9.1.4",
"pyyaml==6.0.3",
"ruamel.yaml==0.18.17",
"requests==2.33.0", # CVE-2026-25645
"requests==2.33.0", # CVE-2026-25645
"jinja2==3.1.6",
# Bumped from 2.12.5 to 2.13.4 to pull in pydantic-core 2.46.4.
# pydantic-core 2.41.5 (pulled by 2.12.5) segfaults when the OpenAI SDK's
@@ -82,7 +82,7 @@ dependencies = [
# it out of the lazy-install path that exists only for the heavy matrix deps.
"Markdown==3.10.2",
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
"PyJWT[crypto]==2.13.0", # PYSEC-2026-175/177/178/179
"PyJWT[crypto]==2.13.0", # PYSEC-2026-175/177/178/179
# urllib3 2.7.0 fixes GHSA-mf9v-mfxr-j63j (decompression-bomb bypass)
# and GHSA-qccp-gfcp-xxvc (header leak across origins).
"urllib3>=2.7.0,<3",
@@ -117,7 +117,7 @@ dependencies = [
[project.optional-dependencies]
# Native Anthropic provider — only needed when provider=anthropic (not via
# OpenRouter or other aggregators).
anthropic = ["anthropic==0.87.0"] # CVE-2026-34450, CVE-2026-34452
anthropic = ["anthropic==0.87.0"] # CVE-2026-34450, CVE-2026-34452
# Web search backends — each only loaded when the user picks it as their
# search provider (configured via `hermes tools` or config.yaml).
exa = ["exa-py==2.10.2"]
@@ -131,11 +131,34 @@ edge-tts = ["edge-tts==7.2.7"]
modal = ["modal==1.3.4"]
daytona = ["daytona==0.155.0"]
hindsight = ["hindsight-client==0.6.1"]
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"] # starlette: CVE-2026-48710
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.4", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] # aiohttp: CVE-2026-34513/34518/34519/34520/34525
cron = [] # croniter is now a core dependency; this extra kept for back-compat
dev = [
"debugpy==1.8.20",
"pytest==9.0.2",
"pytest-asyncio==1.3.0",
"pytest-timeout==2.4.0",
"mcp==1.26.0",
"starlette==1.0.1",
"ty==0.0.21",
"ruff==0.15.10",
"setuptools==82.0.1",
] # starlette: CVE-2026-48710
messaging = [
"python-telegram-bot[webhooks]==22.6",
"discord.py[voice]==2.7.1",
"aiohttp==3.13.4",
"brotlicffi==1.2.0.1",
"slack-bolt==1.27.0",
"slack-sdk==3.40.1",
"qrcode==7.4.2",
] # aiohttp: CVE-2026-34513/34518/34519/34520/34525
cron = [] # croniter is now a core dependency; this extra kept for back-compat
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.4"]
matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"]
matrix = [
"mautrix[encryption]==0.21.0",
"aiosqlite==0.22.1",
"asyncpg==0.31.0",
"aiohttp-socks==0.11.0",
]
# WeCom callback-mode adapter — parses untrusted XML POST bodies from
# WeCom-controlled callback endpoints, so we use defusedxml (drop-in
# replacement for stdlib xml.etree.ElementTree) to block billion-laughs
@@ -171,7 +194,7 @@ vision = []
# `request.url` can be bypassed. We pin a patched Starlette directly in every
# extra that exposes a Starlette-backed server surface so pip/uv can't resolve
# a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock.
mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
nemo-relay = ["nemo-relay==0.3"]
homeassistant = ["aiohttp==3.13.4"]
sms = ["aiohttp==3.13.4"]
@@ -179,7 +202,7 @@ sms = ["aiohttp==3.13.4"]
# The cua-driver binary itself is installed via `hermes tools` post-setup
# (curl install script); this extra just pins the MCP client used to talk
# to it, which is already provided by the `mcp` extra.
computer-use = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
computer-use = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
acp = ["agent-client-protocol==0.9.0"]
# mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version.
# The `mistralai` PyPI project was quarantined 2026-05-12 after the malicious
@@ -213,7 +236,11 @@ termux-all = [
"hermes-agent[sms]",
"hermes-agent[web]",
]
dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"]
dingtalk = [
"dingtalk-stream==0.24.3",
"alibabacloud-dingtalk==2.2.42",
"qrcode==7.4.2",
]
feishu = ["lark-oapi==1.5.3", "qrcode==7.4.2"]
google = [
# Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts,
@@ -275,7 +302,22 @@ hermes-agent = "run_agent:main"
hermes-acp = "acp_adapter.entry:main"
[tool.setuptools]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils", "mcp_serve"]
py-modules = [
"run_agent",
"model_tools",
"toolsets",
"batch_runner",
"trajectory_compressor",
"toolset_distributions",
"cli",
"hermes_bootstrap",
"hermes_constants",
"hermes_state",
"hermes_time",
"hermes_logging",
"utils",
"mcp_serve",
]
[tool.setuptools.data-files]
# i18n catalogs. locales/ is a bare data directory (no __init__.py), so it is
@@ -301,7 +343,12 @@ locales = ["locales/*.yaml"]
"optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"]
[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
hermes_cli = [
"web_dist/**/*",
"tui_dist/**/*",
"scripts/install.sh",
"scripts/install.ps1",
]
gateway = ["assets/**/*"]
plugins = [
"*/dashboard/manifest.json",
@@ -319,13 +366,30 @@ plugins = [
]
[tool.setuptools.packages.find]
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
include = [
"agent",
"agent.*",
"tools",
"tools.*",
"hermes_cli",
"hermes_cli.*",
"gateway",
"gateway.*",
"tui_gateway",
"tui_gateway.*",
"cron",
"acp_adapter",
"plugins",
"plugins.*",
"providers",
"providers.*",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"integration: marks tests requiring external services (API keys, Modal, etc.)",
"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",
"integration: marks tests requiring external services (API keys, Modal, etc.)",
"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",
]
# pytest-timeout: per-test 30s hard cap with cross-platform thread method.
# This is the fallback inside each per-file pytest subprocess (see
@@ -338,11 +402,13 @@ addopts = "-m 'not integration' --timeout=30 --timeout-method=thread"
python-version = "3.13"
[tool.ty.rules]
all = "warn"
unknown-argument = "warn"
redundant-cast = "ignore"
unresolved-reference = "error"
[tool.ruff]
preview = true # required for PLW1514 (unspecified-encoding) — preview rule
preview = true # required for PLW1514 (unspecified-encoding) — preview rule
[tool.ruff.lint]
# All other lints are intentionally disabled (see comment history on this

Some files were not shown because too many files have changed in this diff Show More