diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 0000000000000000000000000000000000000000..81e80c14b617fb1a38b07380aca5f637afd64861 --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,36 @@ +# hadolint configuration for the Hermes Agent Dockerfile. +# See https://github.com/hadolint/hadolint#configure for rules. +# +# We want hadolint to surface NEW Dockerfile lint regressions, but we +# don't want to rewrite the existing image to silence rules that are +# either intentional or pragmatic tradeoffs for this project. Each +# ignore below has a one-line justification. +failure-threshold: warning + +ignored: + # Pin versions in apt get install. We intentionally don't pin common + # tools (curl, git, openssh-client, etc.) — security updates flow in + # via the periodic base-image rebuild, and pinning would lock us to + # superseded patch releases. Same rationale as nearly every distro- + # base official image (python, node, debian). + - DL3008 + # Use WORKDIR to switch to a directory. The image uses `(cd web && …)` + # / `(cd ../ui-tui && …)` inline subshells for one-off build steps + # because they don't affect later RUN commands; promoting them to + # full WORKDIR switches with restores would obscure intent. + - DL3003 + # Multiple consecutive RUN instructions. The `touch README.md` + `uv + # sync` split is intentional — `touch` is cheap, `uv sync` is the + # expensive layer-cached step we want isolated, and merging them + # would invalidate the cache for trivial changes. + - DL3059 + # Last USER should not be root. /init (s6-overlay) runs as root so the + # stage2 hook can usermod/groupmod and chown the data volume per + # HERMES_UID at runtime; each supervised service then drops to the + # hermes user via `s6-setuidgid`. + - DL3002 + +# Require explicit base-image pins (SHA256) — we already do this. +trustedRegistries: + - docker.io + - ghcr.io diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..30774bdf31e9efc9aca8a1bbe132e152c47ea8b9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,470 @@ +# Debian 13 still ships SQLite 3.46.1, which contains the upstream WAL-reset +# corruption bug. Build a pinned shared library for the runtime image instead +# of relying on a distro backport that trixie does not currently provide. +# See #70480 and https://sqlite.org/wal.html#walresetbug. +FROM debian:13.4 AS sqlite_build +ARG SQLITE_AUTOCONF_VERSION=3530400 +ARG SQLITE_SHA256=0e9483900e92cd5de8fd48d16bf9200145a61f7fd5be542a5ac81d8a9516eb9c +RUN apt-get -o Acquire::Retries=3 update && \ + apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ + build-essential ca-certificates curl && \ + rm -rf /var/lib/apt/lists/* && \ + (curl -fsSL --retry 1 --retry-all-errors --connect-timeout 15 --max-time 60 \ + -o /tmp/sqlite.tar.gz \ + "https://sqlite.org/2026/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz" || \ + curl -fsSL --retry 3 --retry-all-errors --connect-timeout 15 --max-time 120 \ + -o /tmp/sqlite.tar.gz \ + "https://sources.buildroot.net/sqlite/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz") && \ + printf '%s %s\n' "${SQLITE_SHA256}" /tmp/sqlite.tar.gz > /tmp/sqlite.sha256 && \ + sha256sum -c /tmp/sqlite.sha256 && \ + tar -xzf /tmp/sqlite.tar.gz -C /tmp && \ + cd "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}" && \ + CFLAGS="-O2 \ + -DSQLITE_ENABLE_FTS3 \ + -DSQLITE_ENABLE_FTS3_PARENTHESIS \ + -DSQLITE_ENABLE_FTS4 \ + -DSQLITE_ENABLE_FTS5 \ + -DSQLITE_ENABLE_RTREE \ + -DSQLITE_ENABLE_GEOPOLY \ + -DSQLITE_ENABLE_COLUMN_METADATA \ + -DSQLITE_ENABLE_UNLOCK_NOTIFY \ + -DSQLITE_ENABLE_DBSTAT_VTAB \ + -DSQLITE_ENABLE_DBPAGE_VTAB \ + -DSQLITE_ENABLE_MATH_FUNCTIONS \ + -DSQLITE_ENABLE_PREUPDATE_HOOK \ + -DSQLITE_ENABLE_SESSION \ + -DSQLITE_SECURE_DELETE \ + -DSQLITE_THREADSAFE=1 \ + -DSQLITE_MAX_VARIABLE_NUMBER=250000" \ + ./configure --prefix=/opt/sqlite-fixed --disable-static && \ + make -j"$(nproc)" && \ + make install + +FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source +# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x +# which reached EOL in April 2026 — we copy node + npm from the upstream +# node:26 image instead (Hermes pins its toolchain to Node 26 everywhere). +# Bookworm-based slim image used so the produced binary links +# against glibc 2.36, which runs cleanly on our Debian 13 (trixie, glibc +# 2.41) runtime. Bumping to a new Node major is a one-line ARG change; see +# #4977. +FROM node:26-bookworm-slim@sha256:9e6f9357d371591e32ab6f2d8a26d63bdd0d17c29eee3f4f3e7e454d9634bf73 AS node_source +FROM debian:13.4 + +# Disable Python stdout buffering to ensure logs are printed immediately. +# Do not write .pyc files at runtime: /opt/hermes is immutable in the +# published container and writable state belongs under /opt/data. +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +# Store Playwright browsers outside the volume mount so the build-time +# install survives the /opt/data volume overlay at runtime. +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright + +# Install system dependencies in one layer, clear APT cache. +# tini was previously PID 1 to reap orphaned zombie processes (MCP stdio +# subprocesses, git, bun, etc.) that would otherwise accumulate when hermes +# ran as PID 1. See #15012. Phase 2 of the s6-overlay supervision plan +# replaces tini with s6-overlay's /init (PID 1 = s6-svscan), which reaps +# zombies non-blockingly on SIGCHLD and additionally supervises the main +# hermes process, the dashboard, and per-profile gateways. +RUN apt-get -o Acquire::Retries=3 update && \ + apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ + ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev libatomic1 procps git openssh-client docker-cli xz-utils && \ + rm -rf /var/lib/apt/lists/* + +# Prefer the fixed SQLite over Debian's vulnerable libsqlite3.so.0. Keep the +# public library name stable so both the system interpreter and the uv-created +# venv resolve the replacement without changing Python import paths. +COPY --from=sqlite_build /opt/sqlite-fixed/lib/libsqlite3.so.3.53.4 /usr/local/lib/ +RUN ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so.0 && \ + ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so && \ + printf '/usr/local/lib\n' > /etc/ld.so.conf.d/000-sqlite-fixed.conf && \ + ldconfig && \ + python3 -c "import sqlite3, sys; \ +v = sqlite3.sqlite_version_info; \ +sys.exit(f'linked SQLite {sqlite3.sqlite_version} still has the WAL-reset bug') if v < (3, 51, 3) else None; \ +db = sqlite3.connect(':memory:'); \ +db.execute(\"CREATE VIRTUAL TABLE docs USING fts5(content, tokenize='trigram')\"); \ +db.execute(\"INSERT INTO docs VALUES ('hermes')\"); \ +sys.exit('SQLite FTS5 trigram self-test failed') if db.execute(\"SELECT count(*) FROM docs WHERE docs MATCH 'erm'\").fetchone()[0] != 1 else None; \ +db.close()" + +# ---------- s6-overlay install ---------- +# s6-overlay provides supervision for the main hermes process, the dashboard, +# and per-profile gateways. /init becomes PID 1 below — see ENTRYPOINT. +# +# Multi-arch: BuildKit auto-populates TARGETARCH (amd64 / arm64). s6-overlay +# uses tarball names keyed on the kernel arch string (x86_64 / aarch64), so +# we map between them inline. The noarch + symlinks tarballs are +# architecture-independent and reused as-is. +# +# We use `curl` instead of `ADD` for ALL three tarballs: `ADD` evaluates its +# URL at parse time (no ARG / TARGETARCH substitution) and — critically for +# CI reliability — cannot retry, so a single GitHub-release CDN blip fails +# the whole 15-45 min build. curl -fsSL --retry 3 self-heals those blips, +# and every tarball is still checksum-verified below before extraction. +ARG TARGETARCH +ARG S6_OVERLAY_VERSION=3.2.3.0 +ARG S6_OVERLAY_NOARCH_SHA256=b720f9d9340efc8bb07528b9743813c836e4b02f8693d90241f047998b4c53cf +ARG S6_OVERLAY_X86_64_SHA256=a93f02882c6ed46b21e7adb5c0add86154f01236c93cd82c7d682722e8840563 +ARG S6_OVERLAY_AARCH64_SHA256=0952056ff913482163cc30e35b2e944b507ba1025d78f5becbb89367bf344581 +ARG S6_OVERLAY_SYMLINKS_SHA256=a60dc5235de3ecbcf874b9c1f18d73263ab99b289b9329aa950e8729c4789f0e +RUN set -eu; \ + case "${TARGETARCH:-amd64}" in \ + amd64) s6_arch="x86_64"; s6_arch_sha="${S6_OVERLAY_X86_64_SHA256}" ;; \ + arm64) s6_arch="aarch64"; s6_arch_sha="${S6_OVERLAY_AARCH64_SHA256}" ;; \ + *) echo "Unsupported TARGETARCH=${TARGETARCH} for s6-overlay" >&2; exit 1 ;; \ + esac; \ + base="https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}"; \ + curl -fsSL --retry 3 -o /tmp/s6-overlay-noarch.tar.xz \ + "${base}/s6-overlay-noarch.tar.xz"; \ + curl -fsSL --retry 3 -o /tmp/s6-overlay-symlinks-noarch.tar.xz \ + "${base}/s6-overlay-symlinks-noarch.tar.xz"; \ + curl -fsSL --retry 3 -o /tmp/s6-overlay-arch.tar.xz \ + "${base}/s6-overlay-${s6_arch}.tar.xz"; \ + { \ + printf '%s %s\n' "${S6_OVERLAY_NOARCH_SHA256}" /tmp/s6-overlay-noarch.tar.xz; \ + printf '%s %s\n' "${s6_arch_sha}" /tmp/s6-overlay-arch.tar.xz; \ + printf '%s %s\n' "${S6_OVERLAY_SYMLINKS_SHA256}" /tmp/s6-overlay-symlinks-noarch.tar.xz; \ + } > /tmp/s6-overlay.sha256; \ + sha256sum -c /tmp/s6-overlay.sha256; \ + tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz; \ + tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz; \ + tar -C / -Jxpf /tmp/s6-overlay-symlinks-noarch.tar.xz; \ + rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256 + +# #34192 / #66679: backward-compat shim for orchestration templates that +# still reference the legacy /usr/bin/tini entrypoint (Hostinger's +# 'Hermes WebUI' catalog, NAS compose projects that preserve an old +# entrypoint on image update, etc.). A plain symlink to /init made the +# path exist, but forwarded tini flags like `-g` into s6-overlay's +# rc.init as the container CMD (`rc.init: 91: -g: not found`) and +# boot-looped any `restart: unless-stopped` deploy. The shim strips the +# tini CLI surface, then exec's /init + main-wrapper — see +# docker/tini-shim.sh. Safe to drop once the affected catalogs are +# updated. +COPY --chmod=0755 docker/tini-shim.sh /usr/bin/tini + +# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime +RUN useradd -u 10000 -m -d /opt/data hermes + +COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/ + +# Node 26: copy the node binary plus the bundled npm JS install from the +# upstream image. npm and npx are recreated as symlinks because they're +# symlinks in the source image (and need to live on PATH). +# +# No corepack: Node unbundled it upstream, so node:26 ships only npm in +# /usr/local/lib/node_modules. Nothing here needs it — no package.json +# declares a `packageManager`, and no build step shells out to yarn or pnpm. +# +# See node_source stage at the top of the file for the version-bump +# rationale (#4977). +COPY --chmod=0755 --from=node_source /usr/local/bin/node /usr/local/bin/ +COPY --from=node_source /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm +RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx + +WORKDIR /opt/hermes + +# ---------- Layer-cached dependency install ---------- +# Copy only package manifests first so npm install + Playwright are cached +# unless the lockfiles themselves change. +# +# ui-tui/packages/hermes-ink/ is copied IN FULL (not just its manifests) +# because it is referenced as a `file:` workspace dependency from +# ui-tui/package.json. Copying the tree up front lets npm resolve the +# workspace to real content instead of stopping at a bare package.json. +COPY package.json package-lock.json ./ +COPY web/package.json web/ +COPY ui-tui/package.json ui-tui/ +COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/ +# apps/shared/ is copied IN FULL because web/package.json references it as a +# `file:` workspace dependency (same pattern as hermes-ink above). +COPY apps/shared/ apps/shared/ + +# `npm_config_install_links=false` forces npm to install `file:` deps as +# symlinks instead of copies. This is the default since npm 10+, which is +# what the image ships now (via the node:22 source stage). We set it +# explicitly anyway as defense-in-depth: the previous Debian-bundled npm +# 9.x defaulted to install-as-copy, which produced a hidden +# node_modules/.package-lock.json that permanently disagreed with the root +# lock on the @hermes/ink entry, tripped the TUI launcher's +# `_tui_need_npm_install()` check on every startup, and triggered a +# runtime `npm install` that then failed with EACCES. Keeping the env +# guards against a future regression if the source npm version changes. +ENV npm_config_install_links=false + +RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \ + for i in 1 2 3; do \ + npx playwright install --with-deps chromium --only-shell && break || \ + { [ "$i" = 3 ] && exit 1; echo "playwright install failed (attempt $i); retrying in 10s"; sleep 10; }; \ + done && \ + npm cache clean --force + +# ---------- Photon iMessage sidecar deps (baked, NS-606) ---------- +# The photon plugin's Node sidecar needs its own node_modules +# (spectrum-ts). The install tree is immutable at runtime, so a lazy +# `npm ci` on first connect would hit EROFS — bake the deps here instead +# (deterministic installs, NS-559). The patch script is copied alongside +# the manifests because package.json's postinstall runs it, which also +# means the spectrum-ts patch is applied at build time. Layer-cached: +# only re-runs when the sidecar manifests/patch change. +COPY plugins/platforms/photon/sidecar/package.json \ + plugins/platforms/photon/sidecar/package-lock.json \ + plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs \ + plugins/platforms/photon/sidecar/ +RUN cd plugins/platforms/photon/sidecar && \ + npm ci --no-audit --fetch-retries=5 && \ + npm cache clean --force + +# ---------- Layer-cached Python dependency install ---------- +# Copy only pyproject.toml + uv.lock so the Python dep resolve + wheel +# download + native-extension compile layer is cached unless those inputs +# change. Before this split the Python install sat after `COPY . .`, so +# every source-only commit re-did ~4-5 min of dep work on cold builds. +# +# README.md is referenced by pyproject.toml's `readme =` field, but it's +# excluded from the build context by .dockerignore's `*.md`. uv's build +# frontend stats the readme path during dep resolution, so we `touch` an +# empty placeholder — the real README is restored by `COPY . .` below. +# +# `uv sync --frozen --no-install-project --extra all --extra messaging --extra otlp` +# installs the deps reachable through the composite `[all]` extra +# (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. +# +# Provider packages (anthropic, bedrock, azure-identity) are included +# so Docker users can use these providers without requiring runtime +# lazy-install access to PyPI (often blocked in containerized envs). +# +# The [otlp] extra contains the SDK/exporter imported by Hermes when Gateway +# Health export is enabled. Collector and observability-backend dependencies +# remain external and are not part of the Hermes production image. +# +# The hindsight memory provider's client (hindsight-client) is baked in +# for the same reason: it lazy-installs into /opt/hermes/.venv at first +# use, which lives inside the (immutable) image layer rather than the +# mounted /opt/data volume, so it is lost on every container recreate / +# 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. +# +# Google Chat's [google-chat] extra (google-cloud-pubsub + Chat API clients) +# is baked so hosted/immutable images can enable the adapter without writing +# the sealed venv. Runtime --install-deps still routes through lazy_deps into +# HERMES_LAZY_INSTALL_TARGET when the extra is not present. +# +# 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 otlp --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix --extra google-chat + +# ---------- 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/ +COPY apps/shared/ apps/shared/ +RUN cd web && npm run build && \ + cd ../ui-tui && npm run build + +# ---------- Source code ---------- +# .dockerignore excludes node_modules, so the installs above survive. +# --link decouples this layer from parents for cache purposes; --chmod bakes +# the final read-only permissions at copy time so we skip the separate +# `chmod -R` pass that previously walked ~30k files across the venv + +# node_modules + source (21s amd64 / 222s arm64 — #49113). `a+rX,go-w` +# gives the non-root hermes user read + traverse but no write; root retains +# write so the build steps below don't need chmod u+w dances. +COPY --link --chmod=a+rX,go-w . . + +# ---------- Permissions ---------- +# Link hermes-agent itself (editable). Deps are already installed in the +# cached layer above; `--no-deps` makes this a fast egg-link creation with no +# resolution or downloads. +RUN uv pip install --no-cache-dir --no-deps -e "." + +# Wire the exec shim and install-method stamp. Files under /opt/hermes are +# already root-owned (COPY, uv sync, npm install all run as root) and +# read-only for the hermes user (go-w from the --chmod above). + +USER root +RUN mkdir -p /opt/hermes/bin && \ + cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \ + chmod 0755 /opt/hermes /opt/hermes/bin/hermes && \ + printf 'docker\n' > /opt/hermes/.install_method +# The ``.install_method`` stamp is baked next to the running code (the install +# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data +# volume that is commonly bind-mounted from the host and even shared with a +# host-side Desktop/CLI install; stamping it at boot used to clobber that +# host install's marker and wrongly block its ``hermes update``. A code-scoped +# stamp is read first by detect_install_method() and is immune to the share. +# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown +# the data volume. Each supervised service then drops to the hermes user via +# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services +# run as the default hermes user (UID 10000). + +# ---------- Bake image provenance + build-time git revision ---------- +# The versioned, non-secret provenance marker is the authoritative runtime +# signal that this filesystem came from an immutable image. It deliberately +# lives outside both /opt/hermes (which operators sometimes bind-mount as a +# checkout) and /opt/data (the mutable HERMES_HOME volume). +# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the +# container always returns nothing — meaning `hermes dump` reports +# "(unknown)" and the startup banner drops its `· upstream ` suffix. +# That makes support triage from container bug reports impossible: +# we can't tell which commit the user is actually running. +# +# Fix: write the commit SHA passed via the HERMES_GIT_SHA build-arg to +# /opt/hermes/.hermes_build_sha at build time, and have +# hermes_cli/build_info.py read it at runtime. Both `hermes dump` and +# banner.get_git_banner_state() try the baked SHA first, then fall back +# to live `git rev-parse` for source installs (unchanged behaviour). +# +# The arg is optional — local `docker build` without --build-arg omits the +# SHA file (and records a null provenance revision), so build-info falls back +# to live-git lookup. CI +# (.github/workflows/docker.yml) passes ${{ github.sha }} so +# every published image has it. +ARG HERMES_GIT_SHA= +RUN set -eu; \ + if [ -n "${HERMES_GIT_SHA}" ]; then \ + printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \ + fi; \ + mkdir -p /etc/hermes; \ + HERMES_GIT_SHA="${HERMES_GIT_SHA}" python3 -c 'import json, os, pathlib, tomllib; project = tomllib.loads(pathlib.Path("/opt/hermes/pyproject.toml").read_text(encoding="utf-8"))["project"]; marker = pathlib.Path("/etc/hermes/image-provenance.json"); marker.write_text(json.dumps({"schema": 1, "deployment_kind": "image", "manager": "docker", "image": "nousresearch/hermes-agent", "version": project["version"], "revision": os.environ.get("HERMES_GIT_SHA") or None}, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8"); marker.chmod(0o444)' + +# ---------- s6-overlay service wiring ---------- +# Static services declared at build time: main-hermes + dashboard. +# Per-profile gateway services are registered dynamically at runtime by +# the profile create/delete hooks (Phase 4); they live under +# /run/service/ (tmpfs) and are reconciled on container restart by +# /etc/cont-init.d/02-reconcile-profiles (Phase 4 Task 4.0). +COPY docker/s6-rc.d/ /etc/s6-overlay/s6-rc.d/ + +# stage2-hook handles UID/GID remap, volume chown, config seeding, +# skills sync — all the work the old entrypoint.sh did before +# `exec hermes`. Wired in as cont-init.d/01- so it +# runs before user services start. +# +# 02-reconcile-profiles re-creates per-profile gateway s6 service +# slots from $HERMES_HOME/profiles// after a container restart +# (the /run/service/ scandir is tmpfs and wiped on restart). Phase 4. +RUN mkdir -p /etc/cont-init.d && \ + printf '#!/command/with-contenv sh\nexec /opt/hermes/docker/stage2-hook.sh\n' \ + > /etc/cont-init.d/01-hermes-setup && \ + chmod +x /etc/cont-init.d/01-hermes-setup +COPY --chmod=0755 docker/cont-init.d/015-supervise-perms /etc/cont-init.d/015-supervise-perms +COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-reconcile-profiles + +# ---------- Runtime ---------- +ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist +# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8: +# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle +# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the +# _tui_need_npm_install / runtime `npm install` branch entirely — exactly the +# nix/packaged-release path the launcher was designed for. +# +# Why this is required (not just an optimization): the root package-lock.json +# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*), +# but the image only installs root/web/ui-tui (apps/* — the desktop app — is +# never `npm install`ed here). So the actualized node_modules permanently +# disagrees with the canonical lock, _tui_need_npm_install() returns True on +# every launch, and the runtime `npm install` it triggers (a) can never +# converge against the partial monorepo and (b) races itself across concurrent +# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a +# 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole +# check. (A separate launcher hardening is tracked independently.) +ENV HERMES_TUI_DIR=/opt/hermes/ui-tui +ENV HERMES_HOME=/opt/data +ENV HERMES_WRITE_SAFE_ROOT=/opt/data +ENV HERMES_DISABLE_LAZY_INSTALLS=1 +# The published image seals /opt/hermes (root-owned, read-only) so a runtime +# lazy install can't mutate the agent's own venv and brick it. But opt-in +# backends (Firecrawl web search, Exa, Feishu, …) keep their SDKs in +# tools/lazy_deps.py — deliberately NOT baked into [all] (see pyproject.toml +# policy 2026-05-12: one quarantined release must not break every install). +# Redirect those lazy installs to a writable dir on the durable data volume. +# lazy_deps appends this dir to the END of sys.path, so a package installed +# here can only ADD modules — it can never shadow or downgrade a core module, +# so the sealed-venv guarantee holds even with installs re-enabled. The dir +# is seeded + chowned to the hermes user by docker/stage2-hook.sh and lives +# on the /opt/data volume, so it persists across container recreates / image +# updates (an ABI stamp invalidates it if a rebuild bumps the interpreter). +ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages + +# `docker exec` privilege-drop shim. When operators run +# `docker exec hermes ...` they default to root, and any file the +# command writes under $HERMES_HOME (auth.json, .env, config.yaml) ends +# up root-owned and unreadable to the supervised gateway (UID 10000). +# The shim lives at /opt/hermes/bin/hermes, sits earliest on PATH, and +# transparently re-exec's the real venv binary via `s6-setuidgid hermes` +# when invoked as root. Non-root callers (supervised processes, +# `--user hermes`, etc.) hit the short-circuit path with no overhead. +# Recursion is impossible because the shim exec's the venv binary by +# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for +# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1). +COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes +COPY --chmod=0755 docker/entrypoint-dispatch.sh /opt/hermes/docker/entrypoint-dispatch.sh + +# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported +# the venv bin onto PATH; Architecture B's main-wrapper.sh does the +# same for the container's main process, but `docker exec` and our +# cont-init.d scripts don't pass through the wrapper. Expose the venv +# bin globally so `docker exec hermes ...` and any +# subprocess that doesn't activate the venv first still find hermes. +# +# /opt/hermes/bin is prepended ahead of the venv so the privilege-drop +# shim wins PATH resolution. The shim's last act is to exec the venv +# binary by absolute path, so this PATH ordering is transparent to +# every other consumer. +ENV PATH="/opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}" +RUN mkdir -p /opt/data +VOLUME [ "/opt/data" ] + +# The image ENTRYPOINT is a tiny dispatcher rather than `/init` directly. +# When the image really owns PID 1 (normal Docker / Podman), the dispatcher +# execs `/init` and preserves the full s6 supervision tree. When a platform +# wraps the image entrypoint under its own PID-1 init (Fly Machines, +# `docker run --init`, some schedulers), `/init` would abort with +# `can only run as pid 1`; in that case the dispatcher falls back to +# `stage2-hook.sh` + `main-wrapper.sh` directly so foreground commands still +# work. See #38349. +# +# On the PID-1 path, s6-overlay's /init sets up the supervision tree, runs +# /etc/cont-init.d/* (our stage2 hook), starts s6-rc services declared in +# /etc/s6-overlay/s6-rc.d/, then exec's its remaining argv as the container's +# "main program" with stdin/stdout/stderr inherited (this is what makes +# interactive --tui work). When the main program exits, /init begins stage 3 +# shutdown and the container exits with the program's exit code. Replaces +# tini — see Phase 2 of docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md. +# +# We use the ENTRYPOINT+CMD split rather than CMD alone so the +# wrapper is prepended to user-supplied args automatically: +# +# docker run → entrypoint-dispatch.sh (CMD default) +# docker run chat -q "hi" → entrypoint-dispatch.sh chat -q hi +# docker run sleep infinity → entrypoint-dispatch.sh sleep infinity +# docker run --tui → entrypoint-dispatch.sh --tui +# +# main-wrapper.sh handles arg routing (bare-exec vs. hermes +# subcommand vs. no-args), drops to the hermes user via s6-setuidgid, +# and exec's the final program so its exit code becomes the container +# exit code. The dispatcher preserves that contract across both the +# supervised PID-1 path and the non-PID-1 fallback path. Without the +# wrapper-as-ENTRYPOINT, leading-dash args like `--version` would be +# intercepted by /init's POSIX shell. +ENTRYPOINT [ "/opt/hermes/docker/entrypoint-dispatch.sh" ] +CMD [ ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..75410e73319c72cd3e991a501c5455eb78f38375 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Nous Research + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.es.md b/README.es.md new file mode 100644 index 0000000000000000000000000000000000000000..af8558513c5d62a126f05237d6c2e1b8cd14b752 --- /dev/null +++ b/README.es.md @@ -0,0 +1,220 @@ +

+ Hermes Agent +

+ +# Hermes Agent ☤ +

+ Hermes Agent | Hermes Desktop +

+

+ Documentación + Discord + Licencia: MIT + Creado por Nous Research + English + 中文 + اردو +

+ +**El agente de IA con mejora continua creado por [Nous Research](https://nousresearch.com).** Es el único agente con un bucle de aprendizaje integrado: crea habilidades a partir de la experiencia, las mejora durante el uso, se impulsa a sí mismo a persistir el conocimiento, busca en sus propias conversaciones pasadas y construye un modelo cada vez más profundo de quién eres a lo largo de las sesiones. Ejecútalo en un VPS de $5, un clúster de GPUs o infraestructura sin servidor que cuesta casi nada cuando está inactivo. No está atado a tu laptop — habla con él desde Telegram mientras trabaja en una VM en la nube. + +Usa cualquier modelo que quieras — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (más de 200 modelos), [NovitaAI](https://novita.ai), [NVIDIA NIM](https://build.nvidia.com) (Nemotron), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, o tu propio endpoint. Cambia con `hermes model` — sin cambios de código, sin dependencias. + + + + + + + + + +
Una interfaz de terminal realTUI completa con edición multilínea, autocompletado de comandos, historial de conversaciones, interrupción y redirección, y salida de herramientas en streaming.
Vive donde tú vivesTelegram, Discord, Slack, WhatsApp, Signal y CLI — todo desde un único proceso gateway. Transcripción de notas de voz, continuidad de conversación entre plataformas.
Un bucle de aprendizaje cerradoMemoria curada por el agente con recordatorios periódicos. Creación autónoma de habilidades tras tareas complejas. Las habilidades mejoran solas durante el uso. Búsqueda FTS5 de sesiones con resumención por LLM para recuperación entre sesiones. Modelado de usuario dialéctico Honcho. Compatible con el estándar abierto de agentskills.io.
Automatizaciones programadasPlanificador cron integrado con entrega a cualquier plataforma. Informes diarios, copias de seguridad nocturnas, auditorías semanales — todo en lenguaje natural, ejecutándose de forma autónoma.
Delega y paralelizaLanza subagentes aislados para flujos de trabajo paralelos. Escribe scripts de Python que llaman a herramientas vía RPC, convirtiendo pipelines de múltiples pasos en turnos de coste cero de contexto.
Funciona en cualquier lugar, no solo en tu laptopSeis backends de terminal — local, Docker, SSH, Singularity, Modal y Daytona. Daytona y Modal ofrecen persistencia sin servidor — el entorno de tu agente hiberna cuando está inactivo y se activa bajo demanda, costando casi nada entre sesiones. Ejecútalo en un VPS de $5 o un clúster de GPUs.
Listo para investigaciónGeneración de trayectorias en lote, compresión de trayectorias para entrenar la próxima generación de modelos de llamadas a herramientas.
+ +--- + +## Instalación rápida + +### Linux, macOS, WSL2, Termux + +```bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash +``` + +### Windows (nativo, PowerShell) + +> **Nota:** En Windows nativo, Hermes funciona sin WSL — la CLI, el gateway, la TUI y las herramientas funcionan de forma nativa. Si prefieres usar WSL2, el comando de Linux/macOS de arriba también funciona allí. ¿Encontraste un error? Por favor [crea un issue](https://github.com/NousResearch/hermes-agent/issues). + +Ejecuta esto en PowerShell: + +```powershell +iex (irm https://hermes-agent.nousresearch.com/install.ps1) +``` + +El instalador se encarga de todo: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **y un Git Bash portátil** (MinGit, descomprimido en `%LOCALAPPDATA%\hermes\git` — no requiere administrador, completamente aislado de cualquier instalación de Git del sistema). Hermes usa este Git Bash incluido para ejecutar comandos de shell. + +Si ya tienes Git instalado, el instalador lo detecta y lo usa en su lugar. De lo contrario, una descarga de ~45MB de MinGit es todo lo que necesitas — no tocará ni interferirá con ningún Git del sistema. + +> **Android / Termux:** La ruta manual probada está documentada en la [guía de Termux](https://hermes-agent.nousresearch.com/docs/getting-started/termux). En Termux, Hermes instala el extra `.[termux]` curado porque el extra completo `.[all]` actualmente incluye dependencias de voz incompatibles con Android. +> +> **Windows:** Windows nativo es totalmente compatible — el comando de PowerShell de arriba instala todo. Si prefieres usar WSL2, el comando de Linux también funciona allí. La instalación nativa de Windows se encuentra en `%LOCALAPPDATA%\hermes`; WSL2 instala en `~/.hermes` como en Linux. + +Después de la instalación: + +```bash +source ~/.bashrc # recargar shell (o: source ~/.zshrc) +hermes # ¡empieza a chatear! +``` + +--- + +## Primeros pasos + +```bash +hermes # CLI interactiva — inicia una conversación +hermes model # Elige tu proveedor y modelo LLM +hermes tools # Configura qué herramientas están habilitadas +hermes config set # Establece valores de configuración individuales +hermes gateway # Inicia el gateway de mensajería (Telegram, Discord, etc.) +hermes setup # Ejecuta el asistente de configuración completo +hermes claw migrate # Migra desde OpenClaw (si vienes de OpenClaw) +hermes update # Actualiza a la última versión +hermes doctor # Diagnostica cualquier problema +``` + +📖 **[Documentación completa →](https://hermes-agent.nousresearch.com/docs/)** + +--- + +## Evita la colección de claves API — Nous Portal + +Hermes funciona con cualquier proveedor que quieras — eso no cambiará. Pero si prefieres no recopilar cinco claves API separadas para el modelo, búsqueda web, generación de imágenes, TTS y un navegador en la nube, **[Nous Portal](https://portal.nousresearch.com)** las cubre todas bajo una sola suscripción: + +- **Más de 300 modelos** — elige cualquiera con `/model ` +- **Tool Gateway** — búsqueda web (Firecrawl), generación de imágenes (FAL), texto a voz (OpenAI), navegador en la nube (Browser Use), todo enrutado a través de tu suscripción. Sin cuentas adicionales. + +Un comando desde una instalación nueva: + +```bash +hermes setup --portal +``` + +Esto te autentica vía OAuth, establece Nous como tu proveedor y activa el Tool Gateway. Comprueba qué está conectado en cualquier momento con `hermes portal info`. Detalles completos en la [página de documentación del Tool Gateway](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway). + +Puedes seguir usando tus propias claves por herramienta cuando quieras — el gateway es por backend, no todo o nada. + +--- + +## Referencia rápida: CLI vs Mensajería + +Hermes tiene dos puntos de entrada: inicia la interfaz de terminal con `hermes`, o ejecuta el gateway y habla con él desde Telegram, Discord, Slack, WhatsApp, Signal o Email. Una vez en una conversación, muchos comandos de barra son compartidos entre ambas interfaces. + +| Acción | CLI | Plataformas de mensajería | +| ----------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------- | +| Empezar a chatear | `hermes` | Ejecuta `hermes gateway setup` + `hermes gateway start`, luego envía un mensaje al bot | +| Nueva conversación | `/new` o `/reset` | `/new` o `/reset` | +| Cambiar modelo | `/model [proveedor:modelo]` | `/model [proveedor:modelo]` | +| Establecer personalidad | `/personality [nombre]` | `/personality [nombre]` | +| Reintentar o deshacer último turno | `/retry`, `/undo` | `/retry`, `/undo` | +| Comprimir contexto / ver uso | `/compress`, `/usage`, `/insights [--days N]` | `/compress`, `/usage`, `/insights [days]` | +| Explorar habilidades | `/skills` o `/` | `/` | +| Interrumpir trabajo actual | `Ctrl+C` o enviar un nuevo mensaje | `/stop` o enviar un nuevo mensaje | +| Estado específico de plataforma | `/platforms` | `/status`, `/sethome` | + +Para las listas de comandos completas, consulta la [guía de CLI](https://hermes-agent.nousresearch.com/docs/user-guide/cli) y la [guía del Gateway de Mensajería](https://hermes-agent.nousresearch.com/docs/user-guide/messaging). + +--- + +## Documentación + +Toda la documentación está en **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)**: + +| Sección | Contenido | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| [Inicio rápido](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | Instalar → configurar → primera conversación en 2 minutos | +| [Uso de CLI](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | Comandos, atajos de teclado, personalidades, sesiones | +| [Configuración](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | Archivo de configuración, proveedores, modelos, todas las opciones | +| [Gateway de Mensajería](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram, Discord, Slack, WhatsApp, Signal, Home Assistant | +| [Seguridad](https://hermes-agent.nousresearch.com/docs/user-guide/security) | Aprobación de comandos, emparejamiento por DM, aislamiento en contenedor | +| [Herramientas y Toolsets](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | Más de 40 herramientas, sistema de toolsets, backends de terminal | +| [Sistema de Habilidades](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | Memoria procedimental, Skills Hub, creación de habilidades | +| [Memoria](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | Memoria persistente, perfiles de usuario, mejores prácticas | +| [Integración MCP](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | Conecta cualquier servidor MCP para capacidades extendidas | +| [Programación Cron](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | Tareas programadas con entrega a plataforma | +| [Archivos de Contexto](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | Contexto de proyecto que da forma a cada conversación | +| [Arquitectura](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | Estructura del proyecto, bucle del agente, clases principales | +| [Contribuir](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | Configuración de desarrollo, proceso de PR, estilo de código | +| [Referencia de CLI](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | Todos los comandos y flags | +| [Variables de Entorno](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | Referencia completa de variables de entorno | + +--- + +## Migración desde OpenClaw + +Si vienes de OpenClaw, Hermes puede importar automáticamente tu configuración, memorias, habilidades y claves API. + +**Durante la configuración inicial:** El asistente de configuración (`hermes setup`) detecta automáticamente `~/.openclaw` y ofrece migrar antes de que comience la configuración. + +**En cualquier momento después de instalar:** + +```bash +hermes claw migrate # Migración interactiva (preset completo) +hermes claw migrate --dry-run # Vista previa de qué se migraría +hermes claw migrate --preset user-data # Migrar sin secretos +hermes claw migrate --overwrite # Sobreescribir conflictos existentes +``` + +Qué se importa: + +- **SOUL.md** — archivo de personalidad +- **Memorias** — entradas de MEMORY.md y USER.md +- **Habilidades** — habilidades creadas por el usuario → `~/.hermes/skills/openclaw-imports/` +- **Lista de comandos permitidos** — patrones de aprobación +- **Configuración de mensajería** — configuración de plataformas, usuarios permitidos, directorio de trabajo +- **Claves API** — secretos en lista de permitidos (Telegram, OpenRouter, OpenAI, Anthropic, ElevenLabs) +- **Assets de TTS** — archivos de audio del espacio de trabajo +- **Instrucciones del espacio de trabajo** — AGENTS.md (con `--workspace-target`) + +Consulta `hermes claw migrate --help` para todas las opciones, o usa la habilidad `openclaw-migration` para una migración guiada interactiva por el agente con vistas previas de dry-run. + +--- + +## Contribuir + +¡Las contribuciones son bienvenidas! Consulta la [Guía de Contribución](CONTRIBUTING.es.md) para la configuración del desarrollo, el estilo de código y el proceso de PR. + +Inicio rápido para colaboradores — clona y comienza con `setup-hermes.sh`: + +```bash +git clone https://github.com/NousResearch/hermes-agent.git +cd hermes-agent +./setup-hermes.sh # instala uv, crea venv, instala .[all], enlaza ~/.local/bin/hermes +./hermes # detecta automáticamente el venv, no necesitas hacer `source` primero +``` + +Ruta manual (equivalente a lo anterior): + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +uv venv .venv --python 3.11 +source .venv/bin/activate +uv pip install -e ".[all,dev]" +scripts/run_tests.sh +``` + +--- + +## Comunidad + +- 💬 [Discord](https://discord.gg/NousResearch) +- 📚 [Skills Hub](https://agentskills.io) +- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues) +- 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — Servidor MCP de control de escritorio Linux para Hermes y otros hosts MCP, con árboles de accesibilidad AT-SPI, entrada Wayland/X11, capturas de pantalla y targeting de ventanas del compositor. +- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Puente WeChat comunitario: Ejecuta Hermes Agent y OpenClaw en la misma cuenta de WeChat. + +--- + +## Licencia + +MIT — ver [LICENSE](LICENSE). + +Creado por [Nous Research](https://nousresearch.com). diff --git a/README.ur-pk.md b/README.ur-pk.md new file mode 100644 index 0000000000000000000000000000000000000000..100b7461a02e972acb7700cf2b50f302fe9fee9c --- /dev/null +++ b/README.ur-pk.md @@ -0,0 +1,261 @@ +
+ +

+ Hermes Agent +

+ +# ہرمیس ایجنٹ ☤ (Hermes Agent) + +

+ Documentation + Discord + License: MIT + Built by Nous Research + English + 中文 +

+ +**[نوس ریسرچ (Nous Research)](https://nousresearch.com) کا تیار کردہ خود کو بہتر بنانے والا اے آئی (AI) ایجنٹ۔** یہ واحد ایجنٹ ہے جس میں سیکھنے کا عمل (learning loop) پہلے سے موجود ہے — یہ اپنے تجربات سے نئی مہارتیں (skills) بناتا ہے، استعمال کے دوران ان کو بہتر کرتا ہے، معلومات کو محفوظ رکھنے کے لیے خود کو یاد دہانی کرواتا ہے، اپنی پرانی بات چیت کو تلاش کر سکتا ہے، اور مختلف سیشنز کے دوران آپ کے بارے میں ایک گہری سمجھ پیدا کرتا ہے۔ اسے $5 والے VPS پر چلائیں، GPU کلسٹر پر، یا سرور لیس (serverless) انفراسٹرکچر پر جس کی قیمت استعمال نہ ہونے پر تقریباً صفر ہے۔ یہ آپ کے لیپ ٹاپ تک محدود نہیں ہے — آپ ٹیلی گرام (Telegram) سے اس کے ساتھ بات چیت کر سکتے ہیں جبکہ یہ کلاؤڈ VM پر کام کر رہا ہو۔ + +آپ اپنی مرضی کا کوئی بھی ماڈل استعمال کر سکتے ہیں — [Nous Portal](https://portal.nousresearch.com)، [OpenRouter](https://openrouter.ai) (200 سے زائد ماڈلز)، [NovitaAI](https://novita.ai) (ماڈل API، ایجنٹ سینڈ باکس، اور GPU کلاؤڈ کے لیے اے آئی مقامی کلاؤڈ)، [NVIDIA NIM](https://build.nvidia.com) (Nemotron)، [Xiaomi MiMo](https://platform.xiaomimimo.com)، [z.ai/GLM](https://z.ai)، [Kimi/Moonshot](https://platform.moonshot.ai)، [MiniMax](https://www.minimax.io)، [Hugging Face](https://huggingface.co)، OpenAI، یا اپنا حسب ضرورت اینڈ پوائنٹ (endpoint) استعمال کریں۔ ماڈل تبدیل کرنے کے لیے صرف `hermes model` استعمال کریں — کسی کوڈ کو تبدیل کرنے کی ضرورت نہیں، کوئی پابندی نہیں۔ + + + + + + + + + +
حقیقی ٹرمینل انٹرفیسمکمل TUI جس میں ملٹی لائن ایڈیٹنگ، سلیش-کمانڈ آٹو کمپلیٹ، بات چیت کی ہسٹری، انٹرپٹ اور ری ڈائریکٹ، اور سٹریمنگ ٹول آؤٹ پٹ شامل ہے۔
یہ وہاں موجود ہے جہاں آپ ہیںٹیلی گرام، ڈسکارڈ (Discord)، سلیک (Slack)، واٹس ایپ (WhatsApp)، سگنل (Signal)، اور CLI — سب ایک ہی گیٹ وے پروسیس سے کام کرتے ہیں۔ وائس میمو (Voice memo) ٹرانسکرپشن، کراس پلیٹ فارم بات چیت کا تسلسل۔
سیکھنے کا ایک مکمل عملایجنٹ کی اپنی ترتیب دی گئی میموری، جس میں وہ خود کو وقتاً فوقتاً یاد دہانی کرواتا ہے۔ پیچیدہ کاموں کے بعد خود کار طریقے سے مہارت (skill) کی تخلیق۔ استعمال کے دوران مہارتوں میں بہتری۔ LLM سمرائزیشن کے ساتھ FTS5 سیشن سرچ تاکہ پرانے سیشنز کی یاددہانی کی جا سکے۔ Honcho کے ذریعے صارف کی ماڈلنگ۔ agentskills.io اوپن سٹینڈرڈ کے ساتھ مکمل مطابقت۔
شیڈول کی گئی خودکار کارروائیاںبلٹ ان (Built-in) کرون (cron) شیڈیولر جو کسی بھی پلیٹ فارم پر ڈیلیوری کے لیے استعمال ہو سکتا ہے۔ روزانہ کی رپورٹس، رات کے بیک اپس، ہفتہ وار آڈٹس — یہ سب کچھ قدرتی زبان (natural language) میں اور بغیر کسی نگرانی کے کام کرتا ہے۔
کام کی تقسیم اور متوازی عملمتوازی (parallel) کاموں کے لیے الگ سے ذیلی ایجنٹس (subagents) بنائیں۔ پائتھون (Python) سکرپٹس لکھیں جو RPC کے ذریعے ٹولز کو استعمال کریں، تاکہ کئی مراحل پر مشتمل کاموں کو بغیر کسی سیاق و سباق (context) کے خرچ کے، ایک ہی باری میں انجام دیا جا سکے۔
کہیں بھی چلائیں، صرف اپنے لیپ ٹاپ پر نہیںچھ (Six) ٹرمینل بیک اینڈز — لوکل، Docker، SSH، Singularity، Modal، اور Daytona۔ ڈیٹونا (Daytona) اور موڈل (Modal) سرور لیس (serverless) فعالیت پیش کرتے ہیں — جب آپ کا ایجنٹ فارغ ہوتا ہے تو اس کا ماحول سلیپ (hibernate) ہو جاتا ہے اور ضرورت پڑنے پر خود بخود جاگ جاتا ہے، جس کی وجہ سے سیشنز کے درمیان لاگت تقریباً صفر رہتی ہے۔ اسے $5 والے VPS یا GPU کلسٹر پر چلائیں۔
تحقیق کے لیے تیاربیچ (Batch) ٹریجیکٹری (trajectory) جنریشن، اگلی نسل کے ٹول کالنگ ماڈلز کی تربیت کے لیے ٹریجیکٹری کمپریشن۔
+ +--- + +## فوری انسٹالیشن (Quick Install) + +### لینکس (Linux)، میک او ایس (macOS)، ڈبلیو ایس ایل ٹو (WSL2)، ٹرمکس (Termux) + +
+ +```bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash +``` + +
+ +### ونڈوز (نیٹو، پاور شیل) + +> **توجہ فرمائیں:** مقامی ونڈوز (Native Windows) پر ہرمیس بغیر WSL کے چلتا ہے — CLI، گیٹ وے، TUI، اور ٹولز سب مقامی طور پر کام کرتے ہیں۔ اگر آپ WSL2 استعمال کرنا پسند کرتے ہیں، تو اوپر دی گئی لینکس/میک او ایس کی کمانڈ وہاں بھی کام کرے گی۔ کوئی مسئلہ نظر آیا؟ براہ کرم [مسائل (issues) درج کریں](https://github.com/NousResearch/hermes-agent/issues)۔ + +اسے پاور شیل (PowerShell) میں چلائیں: + +
+ +```powershell +iex (irm https://hermes-agent.nousresearch.com/install.ps1) +``` + +
+ +انسٹالر سب کچھ خود سنبھالتا ہے: uv، Python 3.11، Node.js، ripgrep، ffmpeg، **اور ایک پورٹ ایبل (portable) گٹ بیش (Git Bash)** (یعنی MinGit، جو `%LOCALAPPDATA%\hermes\git` میں ان پیک ہوتا ہے — اس کے لیے ایڈمن کی اجازت درکار نہیں، اور یہ سسٹم کے کسی بھی گٹ انسٹال سے بالکل الگ ہے)۔ ہرمیس اس بنڈل شدہ گٹ بیش کو شیل کمانڈز چلانے کے لیے استعمال کرتا ہے۔ + +اگر آپ کے پاس پہلے سے گٹ (Git) انسٹال ہے، تو انسٹالر اسے شناخت کر لیتا ہے اور اسے ہی استعمال کرتا ہے۔ بصورت دیگر آپ کو صرف ~45MB کے MinGit ڈاؤنلوڈ کی ضرورت ہوگی — یہ آپ کے سسٹم کے گٹ پر کوئی اثر نہیں ڈالے گا۔ + +> **اینڈرائیڈ (Android) / ٹرمکس (Termux):** ٹیسٹ کیا گیا مینوئل طریقہ [Termux گائیڈ](https://hermes-agent.nousresearch.com/docs/getting-started/termux) میں موجود ہے۔ ٹرمکس پر ہرمیس ایک مخصوص `.[termux]` ایکسٹرا انسٹال کرتا ہے کیونکہ مکمل `.[all]` ایکسٹرا میں ایسی وائس ڈیپینڈینسیز شامل ہیں جو اینڈرائیڈ کے ساتھ مطابقت نہیں رکھتیں۔ +> +> **ونڈوز (Windows):** مقامی ونڈوز کی مکمل سپورٹ موجود ہے — اوپر دی گئی پاور شیل کی کمانڈ سب کچھ انسٹال کر دیتی ہے۔ اگر آپ WSL2 استعمال کرنا چاہتے ہیں، تو لینکس کی کمانڈ وہاں کام کرتی ہے۔ مقامی ونڈوز میں انسٹالیشن `%LOCALAPPDATA%\hermes` میں ہوتی ہے؛ جبکہ WSL2 میں لینکس کی طرح `~/.hermes` میں ہوتی ہے۔ ہرمیس کا وہ واحد فیچر جسے فی الحال خاص طور پر WSL2 کی ضرورت ہے وہ براؤزر پر مبنی ڈیش بورڈ چیٹ پین ہے (یہ POSIX PTY استعمال کرتا ہے — کلاسک CLI اور گیٹ وے دونوں مقامی طور پر چلتے ہیں)۔ + +انسٹالیشن کے بعد: + +
+ +```bash +source ~/.bashrc # شیل کو ری لوڈ کریں (یا: source ~/.zshrc) +hermes # بات چیت شروع کریں! +``` + +
+ +--- + +## آغاز کریں (Getting Started) + +
+ +```bash +hermes # انٹرایکٹو CLI — بات چیت شروع کریں +hermes model # اپنا LLM پرووائیڈر اور ماڈل منتخب کریں +hermes tools # کنفیگر کریں کہ کون سے ٹولز ایکٹو ہیں +hermes config set # انفرادی کنفگ (config) ویلیوز سیٹ کریں +hermes gateway # میسجنگ گیٹ وے شروع کریں (ٹیلی گرام، ڈسکارڈ، وغیرہ) +hermes setup # مکمل سیٹ اپ وزرڈ چلائیں (یہ سب کچھ ایک ساتھ کنفیگر کر دے گا) +hermes claw migrate # OpenClaw سے مائیگریٹ کریں (اگر آپ OpenClaw سے آ رہے ہیں) +hermes update # لیٹسٹ ورژن پر اپ ڈیٹ کریں +hermes doctor # کسی بھی مسئلے کی تشخیص کریں +``` + +
+ +📖 **[مکمل دستاویزات →](https://hermes-agent.nousresearch.com/docs/)** + +--- + +## API-کیز اکٹھی کرنے سے بچیں — Nous Portal + +ہرمیس آپ کے پسندیدہ پرووائیڈر کے ساتھ کام کرتا ہے — یہ چیز تبدیل نہیں ہو رہی۔ لیکن اگر آپ ماڈل، ویب سرچ، امیج جنریشن، TTS، اور کلاؤڈ براؤزر کے لیے پانچ الگ الگ API کیز جمع نہیں کرنا چاہتے، تو **[Nous Portal](https://portal.nousresearch.com)** ان سب کو ایک ہی سبسکرپشن کے تحت کور کرتا ہے: + +- **300+ ماڈلز** — ان میں سے کوئی بھی ماڈل `/model ` کے ذریعے منتخب کریں +- **ٹول گیٹ وے (Tool Gateway)** — ویب سرچ (Firecrawl)، امیج جنریشن (FAL)، ٹیکسٹ ٹو سپیچ (OpenAI)، کلاؤڈ براؤزر (Browser Use)، یہ سب آپ کی سبسکرپشن کے ذریعے چلتے ہیں۔ کسی اضافی اکاؤنٹ کی ضرورت نہیں۔ + +نئی انسٹالیشن کے بعد بس ایک کمانڈ کی ضرورت ہے: + +
+ +```bash +hermes setup --portal +``` + +
+ +یہ آپ کو OAuth کے ذریعے لاگ ان کرواتا ہے، Nous کو آپ کا پرووائیڈر مقرر کرتا ہے، اور ٹول گیٹ وے کو آن کر دیتا ہے۔ `hermes portal info` کمانڈ استعمال کر کے آپ کسی بھی وقت چیک کر سکتے ہیں کہ کون کون سی سروسز منسلک ہیں۔ مکمل تفصیلات [Tool Gateway دستاویزات کے صفحے](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway) پر موجود ہیں۔ + +آپ اب بھی کسی بھی ٹول کے لیے اپنی مرضی کی API کیز استعمال کر سکتے ہیں — گیٹ وے ہر سروس کے لیے الگ الگ کام کرتا ہے، ایسا نہیں کہ یا تو سب کچھ استعمال کریں یا کچھ بھی نہیں۔ + +--- + +## CLI بمقابلہ میسجنگ فوری حوالہ + +ہرمیس کے دو بنیادی انٹر فیس ہیں: آپ ٹرمینل UI کو `hermes` کے ساتھ شروع کریں، یا گیٹ وے چلا کر اس کے ساتھ ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، یا ای میل کے ذریعے بات کریں۔ جب آپ کسی بات چیت میں ہوتے ہیں، تو بہت سی سلیش (slash) کمانڈز دونوں انٹرفیسز میں ایک جیسی ہوتی ہیں۔ + +
+ +| کارروائی (Action) | سی ایل آئی (CLI) | میسجنگ پلیٹ فارمز (Messaging platforms) | +| --------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------- | +| بات چیت شروع کریں | `hermes` | `hermes gateway setup` اور `hermes gateway start` چلائیں، پھر بوٹ کو میسج بھیجیں | +| نئی بات چیت شروع کریں | `/new` یا `/reset` | `/new` یا `/reset` | +| ماڈل تبدیل کریں | `/model [provider:model]` | `/model [provider:model]` | +| پرسنلٹی (Personality) سیٹ کریں | `/personality [name]` | `/personality [name]` | +| پچھلی باری کو دوبارہ یا منسوخ (undo) کریں | `/retry`، `/undo` | `/retry`، `/undo` | +| کانٹیکسٹ (context) کمپریس کریں / استعمال چیک کریں | `/compress`، `/usage`، `/insights [--days N]` | `/compress`، `/usage`، `/insights [days]` | +| مہارتیں (Skills) براؤز کریں | `/skills` یا `/` | `/` | +| موجودہ کام کو روکیں | `Ctrl+C` دبائیں یا نیا میسج بھیجیں | `/stop` یا نیا میسج بھیجیں | +| پلیٹ فارم کے لحاظ سے سٹیٹس | `/platforms` | `/status`، `/sethome` | + +
+ +مکمل کمانڈ لسٹ کے لیے، [CLI گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/cli) اور [میسجنگ گیٹ وے گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) دیکھیں۔ + +--- + +## دستاویزات (Documentation) + +تمام دستاویزات **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)** پر موجود ہیں: + +
+ +| سیکشن (Section) | تفصیل (What's Covered) | +| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| [فوری آغاز (Quickstart)](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | انسٹالیشن → سیٹ اپ → 2 منٹ میں پہلی بات چیت شروع کریں | +| [CLI کا استعمال](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | کمانڈز، کی بائنڈنگز (keybindings)، پرسنلٹیز (personalities)، سیشنز | +| [کنفیگریشن (Configuration)](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | کنفگ فائل، پرووائیڈرز، ماڈلز، اور تمام آپشنز | +| [میسجنگ گیٹ وے](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، ہوم اسسٹنٹ | +| [سیکیورٹی (Security)](https://hermes-agent.nousresearch.com/docs/user-guide/security) | کمانڈ کی منظوری، DM پیئرنگ (pairing)، کنٹینر آئسولیشن | +| [ٹولز اور ٹول سیٹس](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40 سے زائد ٹولز، ٹول سیٹ سسٹم، ٹرمینل بیک اینڈز | +| [مہارتوں کا سسٹم (Skills System)](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills)| پروسیجرل (Procedural) میموری، سکلز ہب، نئی مہارتیں بنانا | +| [میموری (Memory)](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | مستقل میموری، یوزر پروفائلز، بہترین طریقہ کار | +| [MCP انضمام (Integration)](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | صلاحیتوں کو بڑھانے کے لیے کسی بھی MCP سرور کو جوڑیں | +| [کرون (Cron) شیڈیولنگ](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | پلیٹ فارم ڈیلیوری کے ساتھ شیڈول کیے گئے کام | +| [کانٹیکسٹ (Context) فائلز](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files)| پروجیکٹ کا سیاق و سباق (context) جو ہر بات چیت پر اثر انداز ہوتا ہے | +| [آرکیٹیکچر (Architecture)](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | پروجیکٹ کا ڈھانچہ، ایجنٹ لوپ، اہم کلاسز | +| [تعاون (Contributing)](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | ڈیویلپمنٹ سیٹ اپ، PR کا طریقہ کار، کوڈنگ کا انداز | +| [CLI حوالہ جات (Reference)](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | تمام کمانڈز اور فلیگز (flags) | +| [انوائرمنٹ ویری ایبلز](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | مکمل انوائرمنٹ ویری ایبل حوالہ جات | + +
+ +--- + +## OpenClaw سے منتقلی + +اگر آپ OpenClaw سے منتقل ہو رہے ہیں، تو ہرمیس آپ کی سیٹنگز، یادیں (memories)، مہارتیں (skills)، اور API کیز کو خود بخود امپورٹ کر سکتا ہے۔ + +**پہلی بار سیٹ اپ کے دوران:** سیٹ اپ وزرڈ (`hermes setup`) خود بخود `~/.openclaw` کو پہچان لیتا ہے اور کنفیگریشن شروع ہونے سے پہلے مائیگریٹ (migrate) کرنے کا آپشن دیتا ہے۔ + +**انسٹالیشن کے بعد کسی بھی وقت:** + +
+ +```bash +hermes claw migrate # انٹرایکٹو مائیگریشن (مکمل پری سیٹ) +hermes claw migrate --dry-run # جائزہ لیں کہ کیا کیا مائیگریٹ ہوگا +hermes claw migrate --preset user-data # حساس معلومات (secrets) کے بغیر مائیگریٹ کریں +hermes claw migrate --overwrite # موجودہ متصادم فائلوں کو اوور رائٹ کریں +``` + +
+ +جو چیزیں امپورٹ ہوتی ہیں: + +- **SOUL.md** — پرسونا (persona) فائل +- **میموریز (Memories)** — MEMORY.md اور USER.md کی اندراجات +- **مہارتیں (Skills)** — صارف کی بنائی گئی مہارتیں → `~/.hermes/skills/openclaw-imports/` +- **کمانڈ الاؤ لسٹ (allowlist)** — منظوری کے پیٹرنز (approval patterns) +- **میسجنگ سیٹنگز** — پلیٹ فارم کنفیگریشنز، اجازت یافتہ صارفین، ورکنگ ڈائریکٹری +- **API کیز** — الاؤ لسٹ شدہ حساس معلومات (ٹیلی گرام، OpenRouter، OpenAI، Anthropic، ElevenLabs) +- **TTS اثاثے** — ورک اسپیس کی آڈیو فائلیں +- **ورک اسپیس کی ہدایات** — AGENTS.md (`--workspace-target` کے ساتھ) + +تمام آپشنز دیکھنے کے لیے `hermes claw migrate --help` استعمال کریں، یا انٹرایکٹو ایجنٹ کی مدد سے مائیگریٹ کرنے کے لیے `openclaw-migration` سکل کا استعمال کریں (جس میں ڈرائی رن (dry-run) پریویوز شامل ہیں)۔ + +--- + +## تعاون کریں (Contributing) + +ہم آپ کے تعاون کا خیرمقدم کرتے ہیں! ڈیویلپمنٹ سیٹ اپ، کوڈ کے انداز اور PR کے طریقہ کار کے لیے براہ کرم ہماری [Contributing گائیڈ](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) دیکھیں۔ + +معاونین (contributors) کے لیے فوری آغاز — کلون (clone) کریں اور `setup-hermes.sh` چلائیں: + +
+ +```bash +git clone https://github.com/NousResearch/hermes-agent.git +cd hermes-agent +./setup-hermes.sh # uv کو انسٹال کرتا ہے، venv بناتا ہے، .[all] کو انسٹال کرتا ہے، اور ~/.local/bin/hermes کا سیم لنک (symlink) بناتا ہے +./hermes # خود بخود venv کی شناخت کرتا ہے، پہلے `source` کرنے کی ضرورت نہیں +``` + +
+ +مینوئل طریقہ (اوپر والے طریقے کے مساوی): + +
+ +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +uv venv .venv --python 3.11 +source .venv/bin/activate +uv pip install -e ".[all,dev]" +scripts/run_tests.sh +``` + +
+ +--- + +## کمیونٹی (Community) + +- 💬 [ڈسکارڈ (Discord)](https://discord.gg/NousResearch) +- 📚 [سکلز ہب (Skills Hub)](https://agentskills.io) +- 🐛 [مسائل (Issues)](https://github.com/NousResearch/hermes-agent/issues) +- 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — ہرمیس اور دیگر MCP ہوسٹس کے لیے لینکس (Linux) ڈیسک ٹاپ کنٹرول MCP سرور، جس میں AT-SPI ایکسیسیبلٹی ٹریز، Wayland/X11 ان پٹ، سکرین شاٹس، اور کمپوزیٹر ونڈو ٹارگیٹنگ شامل ہے۔ +- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — کمیونٹی وی چیٹ (WeChat) برج: ہرمیس ایجنٹ اور OpenClaw کو ایک ہی وی چیٹ اکاؤنٹ پر چلائیں۔ + +--- + +## لائسنس (License) + +MIT — تفصیلات کے لیے [LICENSE](LICENSE) دیکھیں۔ + +[نوس ریسرچ (Nous Research)](https://nousresearch.com) کی جانب سے تیار کردہ۔ + +
diff --git a/batch_runner.py b/batch_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..48551d0e2c99f9e766a1ee2b42cc1c49bd6076bb --- /dev/null +++ b/batch_runner.py @@ -0,0 +1,1008 @@ +#!/usr/bin/env python3 +"""Batch Agent Runner — run the agent over a JSONL prompt dataset in parallel. + +Batches are processed by a multiprocessing pool with per-batch ``batch_N.jsonl`` output, +checkpointing for ``--resume``, trajectories in from/value format, and tool-usage +statistics aggregated across all batches. See ``main`` (fire CLI) for usage. +""" + +# hermes_bootstrap must be the very first import — UTF-8 stdio on Windows, no-op on POSIX. +try: + import hermes_bootstrap # noqa: F401 +except ModuleNotFoundError: + # Partial ``hermes update`` (git reset landed, ``uv pip install -e .`` did not): + # only Windows UTF-8 stdio setup is skipped. + pass + +import json +import logging +import os +import time +import traceback +from datetime import datetime +from multiprocessing import Lock, Pool +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import fire +from rich.console import Console +from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeRemainingColumn + +from model_tools import TOOL_TO_TOOLSET_MAP +from run_agent import AIAgent +from toolset_distributions import ( + list_distributions, + sample_toolsets_from_distribution, + validate_distribution, +) + +logger = logging.getLogger(__name__) + +# Auto-derived from model_tools so it stays in sync as tools are added. Gives every +# trajectory a consistent tool_stats schema (Arrow/Parquet for HF datasets) and filters +# corrupted entries (hallucinated tool names) when combining trajectories. +ALL_POSSIBLE_TOOLS = set(TOOL_TO_TOOLSET_MAP.keys()) + +DEFAULT_TOOL_STATS = {'count': 0, 'success': 0, 'failure': 0} +_REASONING_KEYS = ("total_assistant_turns", "turns_with_reasoning", "turns_without_reasoning") + +# BatchRunner.__init__ parameters stored as same-named attributes. +_RUNNER_FIELDS = ( + "batch_size", "run_name", "distribution", "max_iterations", "base_url", "api_key", "model", + "num_workers", "verbose", "ephemeral_system_prompt", "log_prefix_chars", "providers_allowed", + "providers_ignored", "providers_order", "provider_sort", "openrouter_min_coding_score", + "reasoning_config", "prefill_messages", "max_samples", +) +# BatchRunner attributes forwarded verbatim to every AIAgent in the worker config. +_AGENT_PASSTHROUGH = ( + "base_url", "api_key", "ephemeral_system_prompt", "providers_allowed", "providers_ignored", + "providers_order", "provider_sort", "openrouter_min_coding_score", + "reasoning_config", "prefill_messages", + # Without this, every batch task run is attributed to the "unknown" execution + # surface in shared metrics even though "batch" is a first-class surface. + "platform", +) + + +def _normalize_tool_stats(tool_stats: Dict[str, Dict[str, int]]) -> Dict[str, Dict[str, int]]: + """All possible tools with zero defaults (consistent HF schema), plus any unexpected tools.""" + normalized = { + tool: tool_stats[tool].copy() if tool in tool_stats else DEFAULT_TOOL_STATS.copy() + for tool in ALL_POSSIBLE_TOOLS + } + for tool, stats in tool_stats.items(): + if tool not in normalized: + normalized[tool] = stats.copy() + return normalized + + +def _normalize_tool_error_counts(tool_error_counts: Dict[str, int]) -> Dict[str, int]: + """All possible tools with zero defaults, plus any unexpected tools.""" + normalized = {tool: tool_error_counts.get(tool, 0) for tool in ALL_POSSIBLE_TOOLS} + for tool, count in tool_error_counts.items(): + if tool not in normalized: + normalized[tool] = count + return normalized + + +def _merge_tool_stats(total: Dict[str, Dict[str, int]], tool_stats: Dict[str, Dict[str, int]]) -> None: + """Add per-tool count/success/failure from *tool_stats* into *total* in place.""" + for tool_name, stats in tool_stats.items(): + agg = total.setdefault(tool_name, DEFAULT_TOOL_STATS.copy()) + agg["count"] += stats["count"] + agg["success"] += stats["success"] + agg["failure"] += stats["failure"] + + +def _merge_reasoning_stats(total: Dict[str, int], reasoning_stats: Dict[str, Any]) -> None: + """Add the turn counters from *reasoning_stats* into *total* in place.""" + for key in total: + total[key] += reasoning_stats.get(key, 0) + + +def _tool_call_succeeded(content) -> bool: + """Judge a tool response: JSON with a non-null ``error`` / ``success: false`` fails; + non-JSON fails only when empty or starting with ``Error:`` (no substring matching, to + avoid false positives). Non-zero exit codes are NOT failures — the model self-corrects.""" + try: + content_json = json.loads(content) if isinstance(content, str) else content + except (json.JSONDecodeError, ValueError, TypeError): + return bool(content) and not content.strip().lower().startswith("error:") + if not isinstance(content_json, dict): + return True + if content_json.get("error") is not None: + return False + # Terminal wraps its response in a "content" field. + inner_content = content_json.get("content") + if isinstance(inner_content, dict) and inner_content.get("error") is not None: + return False + return content_json.get("success") is not False + + +def _extract_tool_stats(messages: List[Dict[str, Any]]) -> Dict[str, Dict[str, int]]: + """Per-tool call counts and success/failure tallies from a message history.""" + tool_stats = {} + tool_calls_map = {} # tool_call_id -> tool name + + for msg in messages: + if msg["role"] == "assistant" and "tool_calls" in msg and msg["tool_calls"]: + for tool_call in msg["tool_calls"]: + if not tool_call or not isinstance(tool_call, dict): continue + tool_name = tool_call["function"]["name"] + tool_stats.setdefault(tool_name, DEFAULT_TOOL_STATS.copy())["count"] += 1 + tool_calls_map[tool_call["id"]] = tool_name + elif msg["role"] == "tool": + tool_call_id = msg.get("tool_call_id", "") + is_success = _tool_call_succeeded(msg.get("content", "")) + if tool_call_id in tool_calls_map: + tool_stats[tool_calls_map[tool_call_id]]["success" if is_success else "failure"] += 1 + + return tool_stats + + +def _turn_has_reasoning(msg: Dict[str, Any]) -> bool: + """```` in content, or a non-empty native ``reasoning`` field.""" + if "" in (msg.get("content", "") or ""): + return True + return bool(msg.get("reasoning", "").strip()) if msg.get("reasoning") else False + + +def _extract_reasoning_stats(messages: List[Dict[str, Any]]) -> Dict[str, int]: + """Count assistant turns with reasoning vs without.""" + assistant_turns = [msg for msg in messages if msg.get("role") == "assistant"] + total = len(assistant_turns) + with_reasoning = sum(1 for msg in assistant_turns if _turn_has_reasoning(msg)) + return { + "total_assistant_turns": total, + "turns_with_reasoning": with_reasoning, + "turns_without_reasoning": total - with_reasoning, + "has_any_reasoning": with_reasoning > 0, + } + + +def _failure_result(prompt_index: int, batch_num: int, error: str) -> Dict[str, Any]: + """Result dict for a prompt that produced no trajectory.""" + return { + "success": False, + "prompt_index": prompt_index, + "error": error, + "trajectory": None, + "tool_stats": {}, + "toolsets_used": [], + "metadata": {"batch_num": batch_num, "timestamp": datetime.now().isoformat()}, + } + + +def _prepare_container_image( + prompt_index: int, prompt_data: Dict[str, Any], batch_num: int, task_id: str, config: Dict[str, Any] +) -> Optional[Dict[str, Any]]: + """Register the dataset row's per-prompt container image (``image``/``docker_image``) + for this task's sandbox (Docker, Modal, Singularity, Daytona). + + For Docker the image is verified (local cache, then pull) before spending tokens on the + agent loop; Modal pulls server-side so no local check. Returns a failure result when the + pull fails, else ``None``. + """ + container_image = prompt_data.get("image") or prompt_data.get("docker_image") + if not container_image: + return None + env_type = os.getenv("TERMINAL_ENV", "local") + if env_type == "docker": + import subprocess as _sp + try: + probe = _sp.run( + ["docker", "image", "inspect", container_image], + capture_output=True, timeout=10, + ) + if probe.returncode != 0: + if config.get("verbose"): + print(f" Prompt {prompt_index}: Pulling docker image {container_image}...", flush=True) + pull = _sp.run( + ["docker", "pull", container_image], + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=600, + ) + if pull.returncode != 0: + return _failure_result( + prompt_index, batch_num, + f"Docker image not available: {container_image}\n{pull.stderr[:500]}", + ) + except FileNotFoundError: + pass # Docker CLI not installed — skip check (e.g., Modal backend) + except Exception as img_err: + if config.get("verbose"): + print(f" Prompt {prompt_index}: Docker image check failed: {img_err}", flush=True) + from tools.terminal_tool import register_task_env_overrides + overrides = { + "docker_image": container_image, + "modal_image": container_image, + "singularity_image": f"docker://{container_image}", + "daytona_image": container_image, + } + if prompt_data.get("cwd"): + overrides["cwd"] = prompt_data["cwd"] + register_task_env_overrides(task_id, overrides) + if config.get("verbose"): + print(f" Prompt {prompt_index}: Using container image {container_image}") + return None + + +def _process_single_prompt( + prompt_index: int, + prompt_data: Dict[str, Any], + batch_num: int, + config: Dict[str, Any] +) -> Dict[str, Any]: + """Run the agent on one prompt; returns trajectory, stats and metadata (or a failure result).""" + prompt = prompt_data["prompt"] + task_id = f"task_{prompt_index}" + failure = _prepare_container_image(prompt_index, prompt_data, batch_num, task_id, config) + if failure is not None: + return failure + + try: + selected_toolsets = sample_toolsets_from_distribution(config["distribution"]) + + if config.get("verbose"): + print(f" Prompt {prompt_index}: Using toolsets {selected_toolsets}") + agent = AIAgent( + model=config["model"], + max_iterations=config["max_iterations"], + enabled_toolsets=selected_toolsets, + save_trajectories=False, # We handle saving ourselves + verbose_logging=config.get("verbose", False), + log_prefix_chars=config.get("log_prefix_chars", 100), + log_prefix=f"[B{batch_num}:P{prompt_index}]", + skip_context_files=True, # Don't pollute trajectories with SOUL.md/AGENTS.md + skip_memory=True, # Don't use persistent memory in batch runs + **{key: config.get(key) for key in _AGENT_PASSTHROUGH if key != "platform"}, + # Batch is a first-class execution surface. Defaulting here (rather than + # relying on the caller's config dict) keeps task-run telemetry attributable + # even for callers that build a config without it. + platform=config.get("platform") or "batch", + ) + + # task_id ensures each task gets its own isolated VM + result = agent.run_conversation(prompt, task_id=task_id) + + # Stats before conversion — keep the original evaluation order. + tool_stats = _extract_tool_stats(result["messages"]) + reasoning_stats = _extract_reasoning_stats(result["messages"]) + trajectory = agent._convert_to_trajectory_format(result["messages"], prompt, result["completed"]) + + return { + "success": True, + "prompt_index": prompt_index, + "trajectory": trajectory, + "tool_stats": tool_stats, + "reasoning_stats": reasoning_stats, + "completed": result["completed"], + # Sibling of the non-empty-response return below (#64686): the classifier's failure_reason must + # survive the empty-response normalization path too, or downstream consumers (TUI billing + # surface, transient-failure persistence) lose the structured reason exactly when the run + # produced no text. + "partial": result.get("partial", False), + "api_calls": result["api_calls"], + "toolsets_used": selected_toolsets, + "metadata": {"batch_num": batch_num, "timestamp": datetime.now().isoformat(), "model": config["model"]}, + } + except Exception as e: + print(f"❌ Error processing prompt {prompt_index}: {e}") + if config.get("verbose"): + traceback.print_exc() + return _failure_result(prompt_index, batch_num, str(e)) + + +def _append_jsonl(path: Path, row: Dict[str, Any]) -> None: + """Append one JSON row and fsync so a crash never loses an acknowledged prompt.""" + with open(path, 'a', encoding='utf-8') as f: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + f.flush() + os.fsync(f.fileno()) + + +def _process_batch_worker(args: Tuple) -> Dict[str, Any]: + """Pool worker: process one batch of ``(index, prompt_data)`` sequentially. + + ``args`` is ``(batch_num, batch_data, output_dir, completed_prompts, config)``. + """ + batch_num, batch_data, output_dir, completed_prompts_set, config = args + output_dir = Path(output_dir) + print(f"\n🔄 Batch {batch_num}: Starting ({len(batch_data)} prompts)") + batch_output_file = output_dir / f"batch_{batch_num}.jsonl" + prompts_to_process = [(idx, data) for idx, data in batch_data if idx not in completed_prompts_set] + + if not prompts_to_process: + print(f"✅ Batch {batch_num}: Already completed (skipping)") + return {"batch_num": batch_num, "processed": 0, "skipped": len(batch_data), "tool_stats": {}, "completed_prompts": []} + print(f" Processing {len(prompts_to_process)} prompts (skipping {len(batch_data) - len(prompts_to_process)} already completed)") + batch_tool_stats = {} + batch_reasoning_stats = dict.fromkeys(_REASONING_KEYS, 0) + completed_in_batch = [] + discarded_no_reasoning = 0 + + for prompt_index, prompt_data in prompts_to_process: + result = _process_single_prompt(prompt_index, prompt_data, batch_num, config) + + if result["success"] and result["trajectory"]: + reasoning = result.get("reasoning_stats", {}) + if not reasoning.get("has_any_reasoning", True): + print(f" 🚫 Prompt {prompt_index} discarded (no reasoning in any turn)") + discarded_no_reasoning += 1 + completed_in_batch.append(prompt_index) + # Tombstone row (#93527): resume filters by scanning batch_*.jsonl + # rows for prompt content, so a discarded sample without a row would + # be re-run at full cost on every restart. The merge step excludes + # tombstones from trajectories.jsonl. + _append_jsonl(batch_output_file, { + "prompt_index": prompt_index, + "discarded": "no_reasoning", + "prompt": _entry_prompt_text(prompt_data), + }) + continue + + # Normalize for a consistent schema across all entries. + raw_tool_stats = result.get("tool_stats", {}) + raw_error_counts = { + tool_name: stats.get("failure", 0) + for tool_name, stats in raw_tool_stats.items() + } + _append_jsonl(batch_output_file, { + "prompt_index": prompt_index, + "conversations": result["trajectory"], + "metadata": result["metadata"], + "completed": result["completed"], + "partial": result.get("partial", False), # True if stopped due to invalid tool calls + "api_calls": result["api_calls"], + "toolsets_used": result["toolsets_used"], + "tool_stats": _normalize_tool_stats(raw_tool_stats), # {tool: {count, success, failure}} + "tool_error_counts": _normalize_tool_error_counts(raw_error_counts) # {tool: failure_count} + }) + _merge_tool_stats(batch_tool_stats, result.get("tool_stats", {})) + _merge_reasoning_stats(batch_reasoning_stats, result.get("reasoning_stats", {})) + + # Only mark as completed if successfully saved (failed prompts can be retried on resume) + if result["success"] and result["trajectory"]: + completed_in_batch.append(prompt_index) + status = "⚠️ partial" if result.get("partial") else "✅" + print(f" {status} Prompt {prompt_index} completed") + else: + print(f" ❌ Prompt {prompt_index} failed (will retry on resume)") + print(f"✅ Batch {batch_num}: Completed ({len(prompts_to_process)} prompts processed)") + + return { + "batch_num": batch_num, + "processed": len(prompts_to_process), + "skipped": len(batch_data) - len(prompts_to_process), + "tool_stats": batch_tool_stats, + "reasoning_stats": batch_reasoning_stats, + "discarded_no_reasoning": discarded_no_reasoning, + "completed_prompts": completed_in_batch + } + + +def _entry_prompt_text(entry: Dict) -> str: + """Human prompt text from a dataset/trajectory entry: flat ``prompt``, ShareGPT + ``conversations`` (from/value), chat ``conversations``/``messages`` (role/content), + or a no-reasoning discard tombstone.""" + if not isinstance(entry, dict): + return "" + text = str(entry.get("prompt") or "").strip() + if text: + return text + for key in ("conversations", "messages"): + for msg in entry.get(key, []) or []: + if not isinstance(msg, dict): + continue + role = msg.get("role") or msg.get("from") + if role in {"user", "human"}: + text = str(msg.get("content") or msg.get("value") or "").strip() + if text: + return text + return "" + + +def _banner(title: str) -> None: + print("\n" + "=" * 70) + print(title) + print("=" * 70) + + +def _chunk(entries: List[Tuple[int, Dict[str, Any]]], size: int) -> List[List[Tuple[int, Dict[str, Any]]]]: + """Split ``(index, entry)`` tuples into batches of *size*, preserving original indices.""" + return [entries[i:i + size] for i in range(0, len(entries), size)] + + +class BatchRunner: + """Manages batch processing of agent prompts with checkpointing and statistics.""" + + def __init__( + self, + dataset_file: str, + batch_size: int, + run_name: str, + distribution: str = "default", + max_iterations: int = 10, + base_url: str = None, + api_key: str = None, + model: str = "claude-opus-4-20250514", + num_workers: int = 4, + verbose: bool = False, + ephemeral_system_prompt: str = None, + log_prefix_chars: int = 100, + providers_allowed: List[str] = None, + providers_ignored: List[str] = None, + providers_order: List[str] = None, + provider_sort: str = None, + openrouter_min_coding_score: Optional[float] = None, + + reasoning_config: Dict[str, Any] = None, + prefill_messages: List[Dict[str, Any]] = None, + max_samples: int = None, + ): + """Load the dataset (truncated to *max_samples*), validate *distribution*, create batches. + + ``ephemeral_system_prompt`` is used during execution but NOT saved to trajectories. + ``prefill_messages`` are prepended as few-shot context; Anthropic Sonnet/Opus 4.6+ + reject a trailing assistant-role prefill (400) — use user-role priming for those. + """ + params = dict(locals()) + self.dataset_file = Path(dataset_file) + for name in _RUNNER_FIELDS: + setattr(self, name, params[name]) + # Batch runs are their own execution surface; declaring it here keeps every + # worker's task-run telemetry attributable instead of falling back to "unknown". + self.platform = "batch" + + if not validate_distribution(distribution): + raise ValueError(f"Unknown distribution: {distribution}. Available: {list(list_distributions().keys())}") + self.output_dir = Path("data") / run_name + self.output_dir.mkdir(parents=True, exist_ok=True) + self.checkpoint_file = self.output_dir / "checkpoint.json" + self.stats_file = self.output_dir / "statistics.json" + self.dataset = self._load_dataset() + if self.max_samples and self.max_samples < len(self.dataset): + full_count = len(self.dataset) + self.dataset = self.dataset[:self.max_samples] + print(f"✂️ Truncated dataset from {full_count} to {self.max_samples} samples (--max_samples)") + self.batches = self._create_batches() + print("📊 Batch Runner Initialized") + print(f" Dataset: {self.dataset_file} ({len(self.dataset)} prompts)") + print(f" Batch size: {self.batch_size}") + print(f" Total batches: {len(self.batches)}") + print(f" Run name: {self.run_name}") + print(f" Distribution: {self.distribution}") + print(f" Output directory: {self.output_dir}") + print(f" Workers: {self.num_workers}") + if self.ephemeral_system_prompt: + prompt_preview = self.ephemeral_system_prompt[:60] + "..." if len(self.ephemeral_system_prompt) > 60 else self.ephemeral_system_prompt + print(f" 🔒 Ephemeral system prompt: '{prompt_preview}'") + + def _load_dataset(self) -> List[Dict[str, Any]]: + """Load JSONL entries that have a ``prompt`` field; skip blank/invalid lines.""" + if not self.dataset_file.exists(): + raise FileNotFoundError(f"Dataset file not found: {self.dataset_file}") + dataset = [] + with open(self.dataset_file, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + + try: + entry = json.loads(line) + if 'prompt' not in entry: + print(f"⚠️ Warning: Line {line_num} missing 'prompt' field, skipping") + continue + dataset.append(entry) + except json.JSONDecodeError as e: + print(f"⚠️ Warning: Invalid JSON on line {line_num}: {e}") + continue + + if not dataset: + raise ValueError(f"No valid entries found in dataset file: {self.dataset_file}") + + return dataset + + def _create_batches(self) -> List[List[Tuple[int, Dict[str, Any]]]]: + """Split the dataset into batches of ``(index, entry)`` tuples.""" + return _chunk(list(enumerate(self.dataset)), self.batch_size) + + def _empty_checkpoint(self) -> Dict[str, Any]: + return {"run_name": self.run_name, "completed_prompts": [], "batch_stats": {}, "last_updated": None} + + def _load_checkpoint(self) -> Dict[str, Any]: + """Checkpoint data (completed prompt indices), or an empty one if missing/unreadable.""" + if not self.checkpoint_file.exists(): + return self._empty_checkpoint() + + try: + with open(self.checkpoint_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"⚠️ Warning: Failed to load checkpoint: {e}") + return self._empty_checkpoint() + + def _save_checkpoint(self, checkpoint_data: Dict[str, Any], lock: Optional[Lock] = None): + """Atomically write *checkpoint_data* (stamped ``last_updated``), under *lock* if given.""" + checkpoint_data["last_updated"] = datetime.now().isoformat() + from utils import atomic_json_write + if lock: + with lock: + atomic_json_write(self.checkpoint_file, checkpoint_data) + else: + atomic_json_write(self.checkpoint_file, checkpoint_data) + + def _scan_completed_prompts_by_content(self) -> set: + """Prompt texts already processed, scanned from every ``batch_*.jsonl``. + + Matching on content rather than index lets resume recover even when indices + don't line up. Failed entries are skipped (retried); discard tombstones count + as completed (#93527) — re-running would just re-discard. + """ + completed_prompts = set() + batch_files = sorted(self.output_dir.glob("batch_*.jsonl")) + + if not batch_files: + return completed_prompts + print(f"📂 Scanning {len(batch_files)} batch files for completed prompts...") + + for batch_file in batch_files: + try: + with open(batch_file, 'r', encoding='utf-8') as f: + for line in f: + try: + entry = json.loads(line.strip()) + if entry.get("failed", False): + continue + prompt_text = _entry_prompt_text(entry) + if prompt_text: + completed_prompts.add(prompt_text) + except json.JSONDecodeError: + continue + except Exception as e: + print(f" ⚠️ Warning: Error reading {batch_file.name}: {e}") + + return completed_prompts + + def _filter_dataset_by_completed(self, completed_prompts: set) -> Tuple[List[Dict], List[int]]: + """Return ``([(index, entry)] not yet completed, [skipped indices])``.""" + filtered_dataset = [] + skipped_indices = [] + + for idx, entry in enumerate(self.dataset): + prompt_text = entry.get("prompt", "").strip() + + # Also check conversations format + if not prompt_text: + conversations = entry.get("conversations", []) + for msg in conversations: + role = msg.get("role") or msg.get("from") + if role in {"user", "human"}: + prompt_text = (msg.get("content") or msg.get("value", "")).strip() + break + + if prompt_text in completed_prompts: + skipped_indices.append(idx) + else: + filtered_dataset.append((idx, entry)) + + return filtered_dataset, skipped_indices + + def _apply_resume(self) -> bool: + """Rebuild ``self.batches`` from unprocessed prompts. False when nothing is left to run.""" + completed_prompt_texts = self._scan_completed_prompts_by_content() + if not completed_prompt_texts: + return True + print(f" Found {len(completed_prompt_texts)} already-completed prompts by content matching") + filtered_entries, skipped_indices = self._filter_dataset_by_completed(completed_prompt_texts) + + if not filtered_entries: + print("\n✅ All prompts have already been processed!") + return False + self.batches = _chunk(filtered_entries, self.batch_size) + _banner("📊 RESUME SUMMARY") + print(f" Original dataset size: {len(self.dataset):,} prompts") + print(f" Already completed: {len(skipped_indices):,} prompts") + print(" ─────────────────────────────────────────") + print(f" 🎯 RESUMING WITH: {len(filtered_entries):,} prompts") + print(f" New batches created: {len(self.batches)}") + print("=" * 70 + "\n") + return True + + def _worker_config(self) -> Dict[str, Any]: + """Picklable agent configuration for worker processes. + + ``self.api_key`` may be a zero-arg callable (Azure Foundry Entra ID bearer provider + from ``agent.azure_identity_adapter``), which is not safely picklable across the + Pool boundary. Drop it and let each worker rebuild its own provider via + ``resolve_runtime_provider()`` from ``model.auth_mode`` in config.yaml + (azure-identity caches in-process, so each worker gets its own short-lived cache). + """ + if callable(self.api_key) and not isinstance(self.api_key, str): + worker_api_key = None + print( + "ℹ️ Detected Entra ID bearer provider — workers will rebuild " + "credentials from config.yaml in each process.", + flush=True, + ) + else: + worker_api_key = self.api_key + config = {key: getattr(self, key) for key in _AGENT_PASSTHROUGH} + config["api_key"] = worker_api_key + for key in ("distribution", "model", "max_iterations", "verbose", "log_prefix_chars"): + config[key] = getattr(self, key) + return config + + def _run_pool(self, config, checkpoint_data, completed_prompts_set, checkpoint_lock) -> List[Dict[str, Any]]: + """Process all batches in a worker pool, checkpointing after each result.""" + print(f"\n🔧 Initializing {self.num_workers} worker processes...") + + with Pool(processes=self.num_workers) as pool: + # output_dir as str for pickling + tasks = [ + (batch_num, batch_data, str(self.output_dir), completed_prompts_set, config) + for batch_num, batch_data in enumerate(self.batches) + ] + print(f"✅ Created {len(tasks)} batch tasks") + print("🚀 Starting parallel batch processing...\n") + + # rich Progress gives a persistent bottom bar; stdout/stderr are NOT + # redirected so worker prints stay visible. + results = [] + console = Console(force_terminal=True) + with Progress( + SpinnerColumn(), TextColumn("[bold blue]📦 Batches"), BarColumn(bar_width=40), + MofNCompleteColumn(), TextColumn("•"), TimeRemainingColumn(), + console=console, refresh_per_second=2, transient=False, + redirect_stdout=False, redirect_stderr=False, + ) as progress: + task = progress.add_task("Processing", total=len(tasks)) + + # Temporarily suppress DEBUG logging to avoid bar interference + root_logger = logging.getLogger() + original_level = root_logger.level + root_logger.setLevel(logging.WARNING) + + try: + for result in pool.imap_unordered(_process_batch_worker, tasks): + results.append(result) + progress.update(task, advance=1) + + # Incremental checkpoint update (so resume works after crash) + try: + batch_num = result.get('batch_num') + completed = result.get('completed_prompts', []) or [] + completed_prompts_set.update(completed) + + if isinstance(batch_num, int): + checkpoint_data.setdefault('batch_stats', {})[str(batch_num)] = { + key: result.get(key, 0) for key in ('processed', 'skipped', 'discarded_no_reasoning') + } + checkpoint_data['completed_prompts'] = sorted(completed_prompts_set) + self._save_checkpoint(checkpoint_data, lock=checkpoint_lock) + except Exception as ckpt_err: + # Don't fail the run if checkpoint write fails + print(f"⚠️ Warning: Failed to save incremental checkpoint: {ckpt_err}") + except KeyboardInterrupt: + print("\n⚠️ Interrupted — terminating batch workers...") + pool.terminate() + pool.join() + raise + except Exception as e: + logger.error("Batch worker failed: %s", e, exc_info=True) + pool.terminate() + pool.join() + raise + finally: + root_logger.setLevel(original_level) + return results + + def _combine_batch_files(self) -> Tuple[int, int]: + """Merge ALL ``batch_*.jsonl`` (old runs + resume) into ``trajectories.jsonl``. + + Drops corrupted entries (hallucinated tool names, invalid JSON) and discard + tombstones (#93527, resume bookkeeping only). Returns ``(kept, files_found)``. + """ + combined_file = self.output_dir / "trajectories.jsonl" + print(f"\n📦 Combining ALL batch files into {combined_file.name}...") + total_entries = 0 + filtered_entries = 0 + tombstone_entries = 0 + batch_files_found = 0 + all_batch_files = sorted(self.output_dir.glob("batch_*.jsonl")) + + with open(combined_file, 'w', encoding='utf-8') as outfile: + for batch_file in all_batch_files: + batch_files_found += 1 + batch_num = batch_file.stem.split("_")[1] # Extract batch number for logging + + with open(batch_file, 'r', encoding='utf-8') as infile: + for line in infile: + total_entries += 1 + try: + data = json.loads(line) + + if data.get("discarded"): + tombstone_entries += 1 + continue + tool_stats = data.get('tool_stats', {}) + invalid_tools = [k for k in tool_stats if k not in ALL_POSSIBLE_TOOLS] + + if invalid_tools: + filtered_entries += 1 + invalid_preview = invalid_tools[0][:50] + "..." if len(invalid_tools[0]) > 50 else invalid_tools[0] + print(f" ⚠️ Filtering corrupted entry (batch {batch_num}): invalid tool '{invalid_preview}'") + continue + outfile.write(line) + except json.JSONDecodeError: + filtered_entries += 1 + print(f" ⚠️ Filtering invalid JSON entry (batch {batch_num})") + + if filtered_entries > 0: + print(f"⚠️ Filtered {filtered_entries} corrupted entries out of {total_entries} total") + kept = total_entries - filtered_entries - tombstone_entries + print(f"✅ Combined {batch_files_found} batch files into trajectories.jsonl ({kept} entries)") + return kept, batch_files_found + + def _print_summary(self, results, total_tool_stats, total_reasoning_stats, kept, batch_files_found, start_time) -> None: + _banner("📊 BATCH PROCESSING COMPLETE") + print(f"✅ Prompts processed this run: {sum(r.get('processed', 0) for r in results)}") + print(f"✅ Total trajectories in merged file: {kept}") + print(f"✅ Total batch files merged: {batch_files_found}") + print(f"⏱️ Total duration: {round(time.time() - start_time, 2)}s") + print("\n📈 Tool Usage Statistics:") + print("-" * 70) + + if total_tool_stats: + sorted_tools = sorted(total_tool_stats.items(), key=lambda x: x[1]["count"], reverse=True) + print(f"{'Tool Name':<25} {'Count':<10} {'Success':<10} {'Failure':<10} {'Success Rate':<12}") + print("-" * 70) + for tool_name, stats in sorted_tools: + print(f"{tool_name:<25} {stats['count']:<10} {stats['success']:<10} {stats['failure']:<10} {stats['success_rate']:.1f}%") + else: + print("No tool calls were made during this run.") + total_discarded = sum(r.get("discarded_no_reasoning", 0) for r in results) + + print("\n🧠 Reasoning Coverage:") + print("-" * 70) + total_turns = total_reasoning_stats["total_assistant_turns"] + with_reasoning = total_reasoning_stats["turns_with_reasoning"] + without_reasoning = total_reasoning_stats["turns_without_reasoning"] + if total_turns > 0: + pct_with = round(with_reasoning / total_turns * 100, 1) + pct_without = round(without_reasoning / total_turns * 100, 1) + print(f" Total assistant turns: {total_turns:,}") + print(f" With reasoning: {with_reasoning:,} ({pct_with}%)") + print(f" Without reasoning: {without_reasoning:,} ({pct_without}%)") + else: + print(" No assistant turns recorded.") + if total_discarded > 0: + print(f" 🚫 Samples discarded (zero reasoning): {total_discarded:,}") + print(f"\n💾 Results saved to: {self.output_dir}") + print(" - Trajectories: trajectories.jsonl (combined)") + print(" - Individual batches: batch_*.jsonl (for debugging)") + print(f" - Statistics: {self.stats_file.name}") + print(f" - Checkpoint: {self.checkpoint_file.name}") + + def run(self, resume: bool = False): + """Run the batch pipeline; with *resume*, skip prompts already present in batch files.""" + _banner("🚀 Starting Batch Processing") + + if resume and not self._apply_resume(): + return + + # Load existing checkpoint (so resume doesn't clobber prior progress) + checkpoint_data = self._load_checkpoint() + if checkpoint_data.get("run_name") != self.run_name: + checkpoint_data = self._empty_checkpoint() + config = self._worker_config() + + # Index tracking is secondary to content matching (backward compatibility). + completed_prompts_set = set(checkpoint_data.get("completed_prompts", [])) + start_time = time.time() + + # Checkpoint writes happen in the parent process; keep a lock for safety. + checkpoint_lock = Lock() + results = self._run_pool(config, checkpoint_data, completed_prompts_set, checkpoint_lock) + total_tool_stats = {} + total_reasoning_stats = dict.fromkeys(_REASONING_KEYS, 0) + for batch_result in results: + _merge_tool_stats(total_tool_stats, batch_result.get("tool_stats", {})) + _merge_reasoning_stats(total_reasoning_stats, batch_result.get("reasoning_stats", {})) + + # Final checkpoint is best-effort; incremental writes already happened. + try: + checkpoint_data["completed_prompts"] = sorted(completed_prompts_set) + self._save_checkpoint(checkpoint_data, lock=checkpoint_lock) + except Exception as ckpt_err: + print(f"⚠️ Warning: Failed to save final checkpoint: {ckpt_err}") + + for stats in total_tool_stats.values(): + total_calls = stats["success"] + stats["failure"] + stats["success_rate"] = round(stats["success"] / total_calls * 100, 2) if total_calls > 0 else 0.0 + stats["failure_rate"] = round(stats["failure"] / total_calls * 100, 2) if total_calls > 0 else 0.0 + kept, batch_files_found = self._combine_batch_files() + final_stats = { + "run_name": self.run_name, + "distribution": self.distribution, + "total_prompts": len(self.dataset), + "total_batches": len(self.batches), + "batch_size": self.batch_size, + # Snapshot the CLI-level credential/runtime fields BEFORE mutating them so a failed in-place + # agent swap can roll the whole CLI back to the old working model. Otherwise the broken + # credentials staged below leak into the next turn's resolution even though the agent itself + # rolled back (#50163). + # Snapshot CLI-level fields before mutation so a failed in-place swap rolls the whole CLI back + # to the old working model (#50163). + "model": self.model, + "completed_at": datetime.now().isoformat(), + "duration_seconds": round(time.time() - start_time, 2), + "tool_statistics": total_tool_stats, + "reasoning_statistics": total_reasoning_stats, + "discarded_no_reasoning": sum(r.get("discarded_no_reasoning", 0) for r in results), + } + + with open(self.stats_file, 'w', encoding='utf-8') as f: + json.dump(final_stats, f, indent=2, ensure_ascii=False) + self._print_summary(results, total_tool_stats, total_reasoning_stats, kept, batch_files_found, start_time) + + +def _split_csv(value: Optional[str]) -> Optional[List[str]]: + """Comma-separated CLI string to a list of stripped items; ``None`` when empty.""" + return [p.strip() for p in value.split(",")] if value else None + + +def main( + dataset_file: str = None, + batch_size: int = None, + run_name: str = None, + distribution: str = "default", + model: str = "anthropic/claude-sonnet-4.6", + api_key: str = None, + base_url: str = "https://openrouter.ai/api/v1", + max_turns: int = 10, + num_workers: int = 4, + resume: bool = False, + verbose: bool = False, + list_distributions: bool = False, + ephemeral_system_prompt: str = None, + log_prefix_chars: int = 100, + providers_allowed: str = None, + providers_ignored: str = None, + providers_order: str = None, + provider_sort: str = None, + + reasoning_effort: str = None, + reasoning_disabled: bool = False, + prefill_messages_file: str = None, + max_samples: int = None, +): + """ + Run batch processing of agent prompts from a dataset. + + Args: + dataset_file (str): Path to JSONL file with 'prompt' field in each entry + batch_size (int): Number of prompts per batch + run_name (str): Name for this run (used for output and checkpointing) + distribution (str): Toolset distribution to use (default: "default") + model (str): Model name to use (default: "claude-opus-4-20250514") + api_key (str): API key for model authentication + base_url (str): Base URL for model API + max_turns (int): Maximum number of tool calling iterations per prompt (default: 10) + num_workers (int): Number of parallel worker processes (default: 4) + resume (bool): Resume from checkpoint if run was interrupted (default: False) + verbose (bool): Enable verbose logging (default: False) + list_distributions (bool): List available toolset distributions and exit + ephemeral_system_prompt (str): System prompt used during agent execution but NOT saved to trajectories (optional) + log_prefix_chars (int): Number of characters to show in log previews for tool calls/responses (default: 20) + providers_allowed (str): Comma-separated list of OpenRouter providers to allow (e.g. "anthropic,openai") + providers_ignored (str): Comma-separated list of OpenRouter providers to ignore (e.g. "together,deepinfra") + providers_order (str): Comma-separated list of OpenRouter providers to try in order (e.g. "anthropic,openai,google") + provider_sort (str): Sort providers by "price", "throughput", or "latency" (OpenRouter only) + + reasoning_effort (str): Reasoning effort: "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra" (default: "medium") + reasoning_disabled (bool): Completely disable reasoning/thinking tokens (default: False) + prefill_messages_file (str): Path to JSON file containing prefill messages (list of {role, content} dicts) + max_samples (int): Only process the first N samples from the dataset (optional, processes all if not set) + + Examples: + # Basic usage + python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run + + # Resume interrupted run + python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run --resume + + # Use specific distribution + python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=image_test --distribution=image_gen + + # With disabled reasoning + python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run \\ + --reasoning_disabled + + # With prefill messages from file + python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run \\ + --prefill_messages_file=configs/prefill_opus.json + + # List available distributions + python batch_runner.py --list_distributions + """ + if list_distributions: + from toolset_distributions import print_distribution_info + print("📊 Available Toolset Distributions") + print("=" * 70) + all_dists = list_distributions() + for dist_name in sorted(all_dists.keys()): + print_distribution_info(dist_name) + + print("\n💡 Usage:") + print(" python batch_runner.py --dataset_file=data.jsonl --batch_size=10 \\") + print(" --run_name=my_run --distribution=") + return + + for invalid, message in ( + (not dataset_file, "--dataset_file is required"), + (not batch_size or batch_size < 1, "--batch_size must be a positive integer"), + (not run_name, "--run_name is required"), + ): + if invalid: + print(f"❌ Error: {message}") + raise SystemExit(1) + + # --reasoning_disabled takes priority, then --reasoning_effort, then default (medium) + reasoning_config = None + if reasoning_disabled: + reasoning_config = {"effort": "none"} + print("🧠 Reasoning: DISABLED (effort=none)") + elif reasoning_effort: + valid_efforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"] + if reasoning_effort not in valid_efforts: + print(f"❌ Error: --reasoning_effort must be one of: {', '.join(valid_efforts)}") + raise SystemExit(1) + reasoning_config = {"enabled": True, "effort": reasoning_effort} + print(f"🧠 Reasoning effort: {reasoning_effort}") + prefill_messages = None + if prefill_messages_file: + try: + with open(prefill_messages_file, 'r', encoding='utf-8') as f: + prefill_messages = json.load(f) + if not isinstance(prefill_messages, list): + print("❌ Error: prefill_messages_file must contain a JSON array of messages") + raise SystemExit(1) + print(f"💬 Loaded {len(prefill_messages)} prefill messages from {prefill_messages_file}") + except Exception as e: + print(f"❌ Error loading prefill messages: {e}") + raise SystemExit(1) + + try: + runner = BatchRunner( + dataset_file=dataset_file, + batch_size=batch_size, + run_name=run_name, + distribution=distribution, + max_iterations=max_turns, + base_url=base_url, + api_key=api_key, + model=model, + num_workers=num_workers, + verbose=verbose, + ephemeral_system_prompt=ephemeral_system_prompt, + log_prefix_chars=log_prefix_chars, + providers_allowed=_split_csv(providers_allowed), + providers_ignored=_split_csv(providers_ignored), + providers_order=_split_csv(providers_order), + provider_sort=provider_sort, + + reasoning_config=reasoning_config, + prefill_messages=prefill_messages, + max_samples=max_samples, + ) + runner.run(resume=resume) + except Exception as e: + print(f"\n❌ Fatal error: {e}") + if verbose: + traceback.print_exc() + raise SystemExit(1) + + +if __name__ == "__main__": + fire.Fire(main) diff --git a/cli-config.yaml.example b/cli-config.yaml.example new file mode 100644 index 0000000000000000000000000000000000000000..82e3bf7a10c2079b5e2b6d83b974afb1646bd642 --- /dev/null +++ b/cli-config.yaml.example @@ -0,0 +1,2138 @@ +# Hermes Agent CLI Configuration +# Copy settings from this example into ~/.hermes/config.yaml, or use +# `hermes config set ` to update the active profile. +# This file configures CLI behavior; only documented secret environment +# variables in .env take precedence over their corresponding settings. + +# ============================================================================= +# Database Configuration +# ============================================================================= +# WAL is the normal default. Hermes automatically falls back to DELETE when +# SQLite reports that WAL is incompatible with the filesystem. Set this to +# "delete" explicitly for deployments whose backing filesystem is not WAL +# crash-safe, such as Linux containers bind-mounted through macOS virtiofs, +# NFS, or SMB. Hermes will not live-downgrade a database already open in WAL. +database: + journal_mode: "wal" # Supported values: "wal", "delete" + # Optional WAL sizing pragmas (integers). Unset = SQLite defaults. + # wal_autocheckpoint: 1000 # pages between automatic checkpoints + # journal_size_limit: 67108864 # cap the WAL/journal file size in bytes + # + # Durability level for every state.db connection: OFF, NORMAL, FULL, EXTRA + # (or 0-3). Unset leaves SQLite's default, which is baked in at compile time + # (SQLITE_DEFAULT_WAL_SYNCHRONOUS) and therefore differs between the bundled + # interpreter, a distro python3 and a Homebrew one. Set it if you need to + # know which one you are running rather than infer it. On macOS this is a + # floor, not a pin: values below FULL are refused because Darwin's fsync() + # does not guarantee write ordering, while EXTRA is honored normally. + # synchronous: FULL + +# ============================================================================= +# Runtime Limits +# ============================================================================= +# Long-running Hermes server processes raise their RLIMIT_NOFILE soft limit to +# this value when the operating system permits it. The value is clamped to the +# hard limit and never lowers an already higher soft limit. Set to 0, false, or +# null to disable the adjustment. Default: 4096. +runtime: + nofile_soft_limit: 4096 + +# ============================================================================= +# Model Configuration +# ============================================================================= +model: + # Default model to use (can be overridden with --model flag) + # Both "default" and "model" work as the key name here. + default: "anthropic/claude-opus-4.6" + + # Inference provider selection: + # "auto" - Auto-detect from credentials (default) + # "openrouter" - OpenRouter (requires: OPENROUTER_API_KEY or OPENAI_API_KEY) + # "nous" - Nous Portal OAuth (requires: hermes auth add nous) + # "nous-api" - Nous Portal API key (requires: NOUS_API_KEY) + # "anthropic" - Direct Anthropic API (requires: ANTHROPIC_API_KEY) + # "openai-codex" - OpenAI Codex (requires: hermes auth) + # "copilot" - GitHub Copilot / GitHub Models (requires: GITHUB_TOKEN) + # "gemini" - Use Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY) + # "zai" - Use z.ai / ZhipuAI GLM models (requires: GLM_API_KEY) + # "kimi-coding" - Kimi / Moonshot AI (requires: KIMI_API_KEY) + # "minimax" - MiniMax global (requires: MINIMAX_API_KEY) + # "minimax-cn" - MiniMax China (requires: MINIMAX_CN_API_KEY) + # "huggingface" - Hugging Face Inference (requires: HF_TOKEN) + # "nvidia" - NVIDIA NIM / build.nvidia.com (requires: NVIDIA_API_KEY) + # "xiaomi" - Xiaomi MiMo (requires: XIAOMI_API_KEY) + # "arcee" - Arcee AI Trinity models (requires: ARCEEAI_API_KEY) + # "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY — https://ollama.com/settings) + # "deepinfra" - DeepInfra (requires: DEEPINFRA_API_KEY) + # "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY) + # "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY) + # "azure-foundry" - Microsoft Foundry / Azure OpenAI (API key or Entra ID) + # "lmstudio" - LM Studio local server (optional: LM_API_KEY, defaults to http://127.0.0.1:1234/v1) + # + # Local servers (LM Studio, Ollama, vLLM, llama.cpp): + # "custom" - Any other OpenAI-compatible endpoint. Set base_url below. + # Aliases: "ollama", "vllm", "llamacpp" all map to "custom". + # LM Studio is first-class and uses provider: "lmstudio". + # It works with both no-auth and auth-enabled server modes. + # + # Can also be overridden for a single invocation with the --provider flag. + provider: "auto" + + # API configuration (falls back to OPENROUTER_API_KEY env var) + # api_key: "your-key-here" # Uncomment to set here instead of .env + base_url: "https://openrouter.ai/api/v1" + + # Stream API responses from the provider (default: true). The agent core + # prefers streaming for every turn — subagents included — for liveness + # health-checking. Set false to force non-streaming requests for the whole + # session (persists across mid-session model switches). Escape hatch for + # self-hosted OpenAI-compatible servers whose streaming tool-call path is + # broken (e.g. vLLM with --tool-call-parser qwen3_xml + a reasoning parser + # can leak tool calls into plain text instead of returning tool_calls — + # #72901). Orthogonal to display.streaming, which controls token rendering + # only. + # streaming: true + + # Azure Foundry keyless auth example: + # provider: "azure-foundry" + # base_url: "https://.openai.azure.com/openai/v1" + # auth_mode: "entra_id" # DefaultAzureCredential: az login, managed identity, workload identity, etc. + # default: "gpt-4o" # Deployment/model name + # entra: + # scope: "https://ai.azure.com/.default" # Optional; this is the default. + + # ── Token limits — two settings, easy to confuse ────────────────────────── + # + # context_length: TOTAL context window (input + output tokens combined). + # Controls when Hermes compresses history and validates requests. + # Leave unset — Hermes auto-detects the correct value from the provider. + # Set manually only when auto-detection is wrong (e.g. a local server with + # a custom num_ctx, or a proxy that doesn't expose /v1/models). + # + # context_length: 131072 + # + # Output-token limits are provider-owned, not user configuration. Native + # protocols requiring a limit receive an internal value from Hermes. + + # ── Custom request headers (optional) ───────────────────────────────────── + # + # default_headers: extra HTTP headers sent on every request to an + # OpenAI-compatible endpoint. User values take precedence over the + # provider/SDK defaults, so this is the supported way to override the + # OpenAI Python SDK's identifying headers (User-Agent: OpenAI/Python ..., + # X-Stainless-*) when a custom provider sits behind a gateway/WAF that + # rejects them — e.g. an upstream that returns "502 Upstream access + # forbidden" for the SDK default User-Agent but accepts a plain one. + # Applies on the OpenAI wire only (not native Anthropic / Bedrock). + # + # default_headers: + # User-Agent: "curl/8.7.1" + # + # extra_headers: accepted as an alias of default_headers (merged, with + # extra_headers winning when both are set) — matches the per-provider + # extra_headers key below. + # + # Per-provider variant: named providers / custom_providers entries accept an + # extra_headers dict scoped to that endpoint only — for reverse proxies, + # gateways, or custom auth (e.g. Cloudflare Access service tokens). + # Merged onto SDK/provider defaults with the entry's values winning. + # Header values are treated as secrets and are never logged. + # + # providers: + # my-proxy: + # base_url: "https://llm.internal.example.com/v1" + # key_env: "MY_PROXY_API_KEY" + # extra_headers: + # CF-Access-Client-Id: "xxxx.access" + # CF-Access-Client-Secret: "${CF_ACCESS_SECRET}" + # X-Client-Name: "hermes-agent" +# providers: +# meta: +# base_url: https://api.meta.ai/v1 +# api_key: ${MODEL_API_KEY} +# # api_mode auto-detected as codex_responses for api.meta.ai; no need to set +# # (the bundled meta-ai provider covers this — a named custom provider is +# # only needed for a non-default Meta-compatible endpoint) +# providers: +# router: +# base_url: https://api.router.com/v1 +# api_key: ${RAMP_ROUTER_API_KEY} +# # api_mode auto-detected as codex_responses for api.router.com — the +# # Responses API is Router's native wire (chat/completions is only a +# # compatibility shim). The bundled router provider covers this; a named +# # custom provider is only needed for a non-default Router-compatible +# # endpoint. + + +# Command-minted credentials (optional): key_cmd +# ------------------------------------------------------------------ +# Enterprise gateways often issue SHORT-LIVED bearers (SSO/OIDC brokers, cloud +# IAM, internal auth proxies) rather than static API keys, so a value copied +# into .env via `key_env` is stale within the hour and every later request 401s. +# `key_cmd` names a command that PRINTS a token instead: Hermes runs it per +# request (cached until shortly before expiry), so long sessions keep working +# with no restart. +# +# Contract: print ONLY the token on stdout, either bare or as JSON with an +# "access_token" field ("expires_in" is honoured). Same shape as OAuth 2.0 +# token endpoints, Claude Code's `apiKeyHelper`, `gcloud auth +# print-access-token`, and `aws ecr get-login-password`. +# +# Precedence: an explicit --api-key still wins; otherwise key_cmd is preferred +# over inline api_key / key_env on that entry. +# +# Applies to the main agent turn and to auxiliary tasks (title generation, +# context compression, vision, embedding) alike. +# +# Not to be confused with `secrets.command`, which is a different mechanism: +# that one runs a helper ONCE at startup to populate env vars for many secrets +# at the process level. Use it for a vault or keychain helper that hands back a +# KEY=VALUE blob. Use `key_cmd` when ONE provider needs a credential refreshed +# DURING a session, because a startup-time env var cannot be re-minted after it +# expires. +# +# providers: +# my-gateway: +# base_url: "https://gateway.internal.example.com/v1" +# api_mode: chat_completions +# key_cmd: "my-auth-cli print-token --profile prod" +# +# Worked example — an AI gateway that routes by model family, so one entry per +# wire format shares the same credential helper: +# +# providers: +# dbx: # OpenAI-compatible (MLflow) route +# base_url: "https://.cloud.databricks.com/ai-gateway/mlflow/v1" +# api_mode: chat_completions +# model: databricks-claude-sonnet-4-6 +# key_cmd: "databricks auth token -p MY-PROFILE" +# dbx-gpt: # OpenAI Responses route +# base_url: "https://.cloud.databricks.com/ai-gateway/openai/v1" +# api_mode: codex_responses +# model: databricks-gpt-5-5 +# key_cmd: "databricks auth token -p MY-PROFILE" +# dbx-claude: # Anthropic Messages route +# base_url: "https://.cloud.databricks.com/ai-gateway/anthropic" +# api_mode: anthropic_messages +# model: databricks-claude-fable-5 +# key_cmd: "databricks auth token -p MY-PROFILE" + +# Named provider overrides (optional) +# Use this for per-provider request timeouts, non-stream stale timeouts, +# and per-model exceptions. +# Applies to the primary turn client on every api_mode (OpenAI-wire, native +# Anthropic, and Anthropic-compatible providers), the fallback chain, and +# client rebuilds during credential rotation. For OpenAI-wire chat +# completions (streaming and non-streaming) the configured value is also +# used as the per-request ``timeout=`` kwarg so it wins over the legacy +# HERMES_API_TIMEOUT env var (which still applies when no config is set). +# ``stale_timeout_seconds`` controls the non-streaming stale-call detector and +# wins over the legacy HERMES_API_CALL_STALE_TIMEOUT env var. Leaving these +# unset keeps the legacy defaults (HERMES_API_TIMEOUT=1800s, +# HERMES_API_CALL_STALE_TIMEOUT=90s, native Anthropic 900s). The +# implicit non-stream stale detector is auto-disabled for local endpoints +# and can scale upward for very large contexts. +# +# Not currently wired for AWS Bedrock (bedrock_converse + AnthropicBedrock +# SDK paths) — those use boto3 with its own timeout configuration. +# +# providers: +# ollama-local: +# request_timeout_seconds: 300 # Longer timeout for local cold-starts +# stale_timeout_seconds: 900 # Explicitly re-enable stale detection on local endpoints +# anthropic: +# request_timeout_seconds: 30 # Fast-fail cloud requests +# models: +# claude-opus-4.6: +# timeout_seconds: 600 # Longer timeout for extended-thinking Opus calls +# openai-codex: +# models: +# gpt-5.4: +# stale_timeout_seconds: 1800 # Longer non-stream stale timeout for slow large-context turns + +# ============================================================================= +# Unified Timeouts (operation deadlines) +# ============================================================================= +# One place to override Hermes's internal operation deadlines (seconds). +# Keys are dotted paths resolved by agent/deadline.py:resolve_timeout(). +# Precedence: this section > legacy HERMES_* env var (back-compat) > built-in +# default. 0 or a negative value disables the bound (unbounded); very large +# values are clamped to a platform-safe maximum automatically. +# +# Currently resolved keys (more paths migrate here over time — see issue #85125): +# +# timeouts: +# tools: +# concurrent_batch: 420 # Deadline for a parallel tool-call batch +# # (legacy env: HERMES_CONCURRENT_TOOL_TIMEOUT_S) +# sequential_call: 420 # Deadline for one sequentially-executed tool call. +# # Defaults to concurrent_batch's value so the two +# # executor paths stay in sync; human waits +# # (approval prompts, clarify) never count against it. + +# ============================================================================= +# OpenRouter Provider Routing (only applies when using OpenRouter) +# ============================================================================= +# Control how requests are routed across providers on OpenRouter. +# See: https://openrouter.ai/docs/guides/routing/provider-selection +# +# provider_routing: +# # Sort strategy: "price" (default), "throughput", or "latency" +# # Append :nitro to model name for a shortcut to throughput sorting. +# sort: "throughput" +# +# # Only allow these providers (provider slugs from OpenRouter) +# # only: ["anthropic", "google"] +# +# # Skip these providers entirely +# # ignore: ["deepinfra", "fireworks"] +# +# # Try providers in this order (overrides default load balancing) +# # order: ["anthropic", "google", "together"] +# +# # Require providers to support all parameters in your request +# # require_parameters: true +# +# # Data policy: "allow" (default) or "deny" to exclude providers that may store data +# # data_collection: "deny" +# +# # Per-model overrides: same keys, applied only when the agent is on that model +# # (spelling-tolerant match; unset keys fall through to the flat values above). +# # models: +# # "openai/gpt-6-astra": +# # only: ["openai"] +# # "anthropic/claude-fable-5.1": +# # only: ["anthropic"] + +# ============================================================================= +# OpenRouter Response Caching (only applies when using OpenRouter) +# ============================================================================= +# Cache identical API responses at the OpenRouter edge for free instant replays. +# When enabled, identical requests (same model, messages, parameters) return +# cached responses with zero billing. Separate from Anthropic prompt caching. +# See: https://openrouter.ai/docs/guides/features/response-caching +# +# openrouter: +# response_cache: true # Enable response caching (default: true) +# response_cache_ttl: 300 # Cache TTL in seconds, 1-86400 (default: 300) + +# ============================================================================= +# Git Worktree Isolation +# ============================================================================= +# When enabled, each CLI session creates an isolated git worktree so multiple +# agents can work on the same repo concurrently without file collisions. +# Equivalent to always passing --worktree / -w on the command line. +# +# worktree: true # Always create a worktree when in a git repo +# worktree: false # Default — only create when -w flag is passed +# +# By default a new worktree branches from the freshly-fetched remote tip +# (the current branch's upstream, else the remote's default branch) so it +# starts current with the project instead of from the local clone's +# (possibly stale) HEAD. Set worktree_sync: false to branch from local HEAD +# instead — useful when offline or when you deliberately want the clone's +# exact current state as the base. +# +# worktree_sync: true # Default — branch from the fetched remote tip +# worktree_sync: false # Branch from local HEAD (offline / pinned base) + +# ============================================================================= +# Kanban Review Dispatch +# ============================================================================= +# First-class review tasks are dispatched automatically by default. The worker +# is spawned with the bundled sdlc-review skill and can approve, request changes +# back to the original implementer, or escalate a genuine external blocker. +# Disable this only when every review is performed manually from the dashboard. +kanban: + review_dispatch: true + +# ============================================================================= +# Terminal Tool Configuration +# ============================================================================= +# Choose ONE of the following terminal configurations by uncommenting it. +# The terminal tool executes commands in the specified environment. + +# ----------------------------------------------------------------------------- +# OPTION 1: Local execution (default) +# Commands run directly on your machine in the current directory +# ----------------------------------------------------------------------------- +# Working directory behavior: +# - CLI (`hermes` command): Uses "." (current directory where you run hermes) +# - Gateway/messaging/cron: Uses terminal.cwd here; legacy .env cwd values are deprecated +cron: + catch_up_missed: true # False skips past-grace recurring misses after planned downtime. + +terminal: + backend: "local" + cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise. + # Desktop xterm font. Install the font locally; Nerd Fonts render Powerlevel10k glyphs. + # font_family: "MesloLGS NF" # Also accepts a CSS stack; blank uses bundled JetBrains Mono. + timeout: 180 + # HOME policy for tool subprocesses: + # auto - default: host uses your real HOME; containers use HERMES_HOME/home + # real - force your real OS-user HOME + # profile - force HERMES_HOME/home for strict per-profile CLI config isolation + home_mode: "auto" + docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace. + lifetime_seconds: 300 + # sudo_password: "hunter2" # Optional: pipe a sudo password via sudo -S. SECURITY WARNING: plaintext. + # sudo_password: "" # Explicit empty password: try empty and never open the interactive sudo prompt. + +# ----------------------------------------------------------------------------- +# OPTION 2: SSH remote execution +# Commands run on a remote server - agent code stays local (sandboxed) +# Great for: keeping agent isolated from its own code, using powerful remote hardware +# ----------------------------------------------------------------------------- +# terminal: +# backend: "ssh" +# cwd: "/home/myuser/project" # Path on the REMOTE server +# timeout: 180 +# lifetime_seconds: 300 +# ssh_host: "my-server.example.com" +# ssh_user: "myuser" +# ssh_port: 22 +# ssh_key: "~/.ssh/id_rsa" # Optional - uses ssh-agent if not specified + +# ----------------------------------------------------------------------------- +# OPTION 3: Docker container +# Commands run in an isolated Docker container +# Great for: reproducible environments, testing, isolation +# ----------------------------------------------------------------------------- +# terminal: +# backend: "docker" +# cwd: "/workspace" # Path INSIDE the container (default: /) +# timeout: 180 +# lifetime_seconds: 300 +# docker_image: "nikolaik/python-nodejs:python3.11-nodejs20" +# docker_mount_cwd_to_workspace: true # Explicit opt-in: mount your launch cwd into /workspace +# # Optional: run the container as your host user's uid:gid so files written +# # into bind-mounted dirs are owned by you, not root. Drops SETUID/SETGID +# # caps too since no gosu privilege drop is needed. Leave off if your +# # chosen docker_image expects to start as root. +# docker_run_as_host_user: true +# # Optional: explicitly forward selected env vars into Docker. +# # These values come from your current shell first, then ~/.hermes/.env. +# # Warning: anything forwarded here is visible to commands run in the container. +# docker_forward_env: +# - "GITHUB_TOKEN" +# - "NPM_TOKEN" +# # Optional: extra flags passed verbatim to docker run (appended after security defaults). +# # Useful for adding capabilities (e.g. apt installs needing SETUID) or custom options. +# # Example: add a Linux capability not included by default +# # docker_extra_args: +# # - "--cap-add" +# # - "SETUID" + +# ----------------------------------------------------------------------------- +# OPTION 4: Singularity/Apptainer container +# Commands run in a Singularity container (common in HPC environments) +# Great for: HPC clusters, shared compute environments +# ----------------------------------------------------------------------------- +# terminal: +# backend: "singularity" +# cwd: "/workspace" # Path INSIDE the container (default: /root) +# timeout: 180 +# lifetime_seconds: 300 +# singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20" + +# ----------------------------------------------------------------------------- +# OPTION 5: Modal cloud execution +# Commands run on Modal's cloud infrastructure +# Great for: GPU access, scalable compute, serverless execution +# ----------------------------------------------------------------------------- +# terminal: +# backend: "modal" +# cwd: "/workspace" # Path INSIDE the sandbox (default: /root) +# timeout: 180 +# lifetime_seconds: 300 +# modal_image: "nikolaik/python-nodejs:python3.11-nodejs20" + +# ----------------------------------------------------------------------------- +# OPTION 6: Daytona cloud execution +# Commands run in Daytona cloud sandboxes +# Great for: Cloud dev environments, persistent workspaces, team collaboration +# Requires: pip install daytona, DAYTONA_API_KEY env var +# ----------------------------------------------------------------------------- +# terminal: +# backend: "daytona" +# cwd: "~" +# timeout: 180 +# lifetime_seconds: 300 +# daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" +# container_disk: 10240 # Daytona max is 10GB per sandbox + +# +# --- Container resource limits (docker, singularity, modal, daytona -- ignored for local/ssh) --- +# These settings apply to all container backends. They control the resources +# allocated to the sandbox and whether its filesystem persists across sessions. + container_cpu: 1 # CPU cores + container_memory: 5120 # Memory in MB (5120 = 5GB) + container_disk: 51200 # Disk in MB (51200 = 50GB) + container_persistent: true # Persist filesystem across sessions (false = ephemeral) + +# ----------------------------------------------------------------------------- +# SUDO SUPPORT (works with ALL backends above) +# ----------------------------------------------------------------------------- +# Add sudo_password to any terminal config above to enable sudo commands. +# The password is piped via `sudo -S`. Works with local, ssh, docker, etc. +# +# SECURITY WARNING: Password stored in plaintext! +# +# INTERACTIVE PROMPT: If sudo_password is unset and the CLI is running, +# you'll be prompted to enter your password when sudo is needed: +# - 45-second timeout (auto-skips if no input) +# - Press Enter to skip (command fails gracefully) +# - Password is hidden while typing +# - Password is cached for the session +# +# EMPTY PASSWORDS: Setting sudo_password to an explicit empty string is different +# from leaving it unset. Hermes will try an empty password via `sudo -S` and +# will not open the interactive prompt. This is useful for passwordless sudo, +# Touch ID sudo setups, and environments where prompting is just noise. +# +# ALTERNATIVES: +# - SSH backend: Configure passwordless sudo on the remote server +# - Containers: Run as root inside the container (no sudo needed) +# - Local: Configure /etc/sudoers for specific commands +# +# Example (add to your terminal section): +# sudo_password: "your-password-here" + +# ============================================================================= +# Security Scanning (tirith) +# ============================================================================= +# Optional pre-exec command security scanning via tirith. +# Detects homograph URLs, pipe-to-shell, terminal injection, env manipulation. +# Install: brew install sheeki03/tap/tirith +# Docs: https://github.com/sheeki03/tirith +# +# security: +# tirith_enabled: true # Enable/disable tirith scanning +# tirith_path: "tirith" # Path to tirith binary (supports ~ expansion) +# tirith_timeout: 5 # Scan timeout in seconds +# tirith_fail_open: true # Allow commands if tirith unavailable +# approval: +# transport: builtin # Or an explicitly enabled plugin transport name +# transport_fallback: deny # Set builtin to opt into fallback on transport failure + +# ============================================================================= +# Browser Tool Configuration +# ============================================================================= +browser: + # Inactivity timeout in seconds - browser sessions are automatically closed + # after this period of no activity between agent loops (default: 120 = 2 minutes) + inactivity_timeout: 120 + # Let an authenticated browser extension register as the controller for an + # existing Hermes session. Disabled by default. Local API registration also + # requires the API server bearer key to be configured. + extension_control: + enabled: false + + # Maximum characters of snapshot content before truncate-and-store. + # Increase for long pages (for example, documentation or financial reports), + # or decrease to reduce context usage. Minimum: 1000; default: 15000 + # (same per-page budget as web_extract). + # snapshot_threshold: 15000 + +# ============================================================================= +# Tool Loop Guardrails +# ============================================================================= +# Soft warnings are enabled by default. They append guidance to repeated failed +# or non-progressing tool results but still let the tool execute. Hard stops stay +# opt-in for interactive CLI/TUI/Desktop/ACP sessions, but default on for unattended +# gateway/cron sessions where nobody is present to interrupt a model that +# ignores loop warnings. +tool_loop_guardrails: + warnings_enabled: true + hard_stop_enabled: false + non_interactive_hard_stop_enabled: true + warn_after: + exact_failure: 2 + same_tool_failure: 3 + idempotent_no_progress: 2 + hard_stop_after: + exact_failure: 5 + same_tool_failure: 8 + idempotent_no_progress: 5 + +# ============================================================================= +# Context Compression (Auto-shrinks long conversations) +# ============================================================================= +# When conversation approaches model's context limit, middle turns are +# automatically summarized to free up space while preserving important context. +# +# HOW IT WORKS: +# 1. Tracks actual token usage from API responses (not estimates) +# 2. When prompt_tokens >= threshold% of model's context_length, triggers compression +# 3. Protects first 3 turns (system prompt, initial request, first response) +# 4. Protects last N turns (default 20 messages = ~10 full turns of recent context) +# 5. Summarizes middle turns using a fast/cheap model +# 6. Inserts summary as a user message, continues conversation seamlessly +# +# Post-compression tail budget is target_ratio × threshold × context_length: +# 200K context, threshold 0.50, ratio 0.20 → 20K tokens of recent tail preserved +# 1M context, threshold 0.50, ratio 0.20 → 100K tokens of recent tail preserved +# +compression: + # Enable automatic context compression (default: true) + # Set to false if you prefer to manage context manually or want errors on overflow + enabled: true + + # Fail closed before lossy compaction unless an active memory provider that + # implements the pre-compress checkpoint contract (API v2) confirms its + # durable checkpoint (default: false). With this on and no checkpoint, the + # compaction attempt errors with BLOCKED_MISSING_PREREQUISITE and the + # uncompressed transcript is preserved for a later retry. The gate binds to + # every compaction authority: server-side native compaction + # (codex_responses_native) is suppressed while armed, post-turn + # micro-compaction is forced off (no checkpoint hook in its path), and the + # codex_app_server API mode is refused at agent init (the codex agent + # compacts its own thread — no checkpoint can be guaranteed there). Only + # enable it with a checkpoint-capable provider configured — see + # website/docs/developer-guide/memory-provider-plugin.md + # ("Pre-Compress Checkpoints"). + checkpoint_required: false + + # Opt-in compression progress notices on chat platforms (default: false). + # By design, routine automatic compression is SILENT on human-facing chat + # gateways (Telegram, Discord, Slack, ...) — it happens in the background + # with server-side logging only. Set true to also deliver the routine + # progress statuses (compacting started, preflight/pre-API compression, + # idle compaction, retry progress, and the compaction-complete notice) to + # chat platforms. Unrelated operational noise (auxiliary model failures, + # provider retry chatter) stays suppressed either way, and compression + # FAILURE notices + manual /compress feedback are always visible + # regardless of this setting. (#52995) + progress_notices: false + + # Trigger compression at this % of model's context limit (default: 0.50 = 50%) + # Lower values = more aggressive compression, higher values = compress later + # Models with context windows below 512K are floored at 0.75 (raise-only) so + # compaction doesn't fire with half the window still free; set above 0.75 to override. + threshold: 0.50 + + # Per-model threshold overrides: keys are substring-matched against the model + # name (longest match wins). Useful when some models need different compaction + # points — e.g. a 1M-context model can compress later (0.30) while a 128K + # model needs to compress earlier (0.60). The small-context floor (75% for + # <512K models) still applies on top of per-model overrides. + # model_thresholds: + # "glm-5.2": 0.40 + # "claude-sonnet": 0.35 + # "gpt-5": 0.30 + + # Optional absolute token cap for the compression trigger (default: null = disabled). + # When set, compression fires at the LOWER of the ratio-based threshold and this + # absolute token count — first-fires-wins. It never fires later than this count + # regardless of which model is active (useful when switching between models with + # very different context windows). Clamped to the model's context length at + # apply-time, so a cap above the window is a no-op (ratio-based threshold wins). + # Survives model switches and fallback activations. + # threshold_tokens: 200000 + + # Existing Codex gpt-5.5 behavior: raise Hermes' compaction trigger to 85% + # for the ChatGPT Codex OAuth route. Set false to opt back down to threshold. + codex_gpt55_autoraise: true + + # Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%) + # e.g. 20% of 50% threshold = 10% of total context kept as recent messages. + # Summary output is separately capped at 12K tokens (Gemini output limit). + # Range: 0.10 - 0.80 + target_ratio: 0.20 + + # Number of most-recent messages to always preserve (default: 20 ≈ 10 full turns) + # Higher values keep more recent conversation intact at the cost of more aggressive + # compression of older turns. + protect_last_n: 20 + + # Minimum number of REAL (actionable) user messages guaranteed to survive in + # the uncompressed tail (default: 1 = the existing single last-user anchor, + # behavior-preserving). Raise to e.g. 3 to keep the last 3 real user turns + # verbatim even when bulky tool outputs fill the tail token budget — blank + # platform echoes, compaction handoffs, and synthetic continuation rows never + # count toward N. The tail can exceed the token budget when this pulls the + # cut back; the guarantee wins over the budget by design. + min_tail_user_messages: 1 + + # Compression retry rounds before a turn gives up with "max compression + # attempts reached" (default: 3, same as the previous hardcoded value). + # Raise (e.g. 6) for tool-schema-heavy sessions where 3 rounds cannot bring + # the request estimate under the threshold. Validated >= 1, hard cap 10. + max_attempts: 3 + + # Codex app-server (codex CLI runtime) thread-compaction mode. The codex + # agent owns the real thread context on this runtime, so Hermes' summarizer + # cannot shrink it — compaction goes through the app server instead. + # native = let Codex decide when to compact its own thread (default) + # hermes = let Hermes threshold trigger Codex thread/compact/start + # off = Hermes will not auto-trigger compaction; Codex may still compact natively + codex_app_server_auto: native + + # Native OpenAI Responses server-side compaction (default: false). When true, + # gpt-5.6-family models on the DIRECT OpenAI API (api.openai.com) or a ChatGPT + # Codex subscription compact server-side: OpenAI prunes older context into an + # encrypted checkpoint that Hermes replays on later turns. No other provider, + # route, or model is affected. Hermes' local compression stays armed as the + # fallback and still handles every non-eligible session. + codex_responses_native: false + + # Optional absolute server compaction trigger in input tokens. The default + # null value follows the resolved local compression trigger with an 8192 token + # safety margin. For example, a local trigger of 765000 selects 756808. + # A positive integer stays absolute and only clamps downward when needed so + # the server compacts first. Invalid values use this automatic behavior. If + # the local trigger is unavailable, automatic mode uses 200000. + codex_responses_compact_threshold: null + + # Number of non-system messages to protect at the head of the transcript, in + # ADDITION to the system prompt (which is always implicitly protected). + # Head messages are NEVER summarized — they survive every compression + # indefinitely. This gives stable early context for short/medium sessions, + # but in long-running sessions that rely on rolling compaction the pinned + # opening turns may not match how you want the session framed over time. + # Set to 0 to preserve ONLY the system prompt (plus the rolling summary + # and recent tail) — the cleanest configuration for long-running sessions. + # Default 3 preserves the system prompt plus the first three non-system + # head messages, matching the pre-feature behaviour. + protect_first_n: 3 + + # Idle compaction (default: 0 = disabled). When > 0, a session that resumes + # after at least this many seconds of inactivity compacts its accumulated + # history up front, before the first reply, so a long-lived thread you come + # back to later doesn't re-read its full stale context on every turn. + # Time-based, so it complements (does not replace) the size-based `threshold` + # above. It is skipped when the context is already small (at or below the + # post-compression target = threshold × target_ratio), so it never wastes a + # summarization on a short idle thread. Example: 1800 = compact after 30 min idle. + idle_compact_after_seconds: 0 + + # Gateway session-hygiene turn-hold budget (default: 10). Max seconds an + # arriving user turn is held while a still-streaming hygiene summary + # finishes. Distinct from hygiene_timeout_seconds (compressor inactivity + # budget): this bounds user-visible latency so chat transports (Telegram + # ~30s) do not drop a silent connection. On expiry the turn proceeds + # uncompressed; the detached worker keeps its commit admission (when the + # commit is watermark-fenced) and the summary is adopted at the next safe + # boundary. Thinking-model summarizers often need longer than 10s to emit + # the first content token — raise to 300 (or >= your summarizer's real + # time-to-first-content) only if you want THIS turn to wait for the + # compression instead of adopting it one turn late. + hygiene_max_turn_hold_seconds: 10 + + # Proactive tool-result prune (default: 0 = disabled). Opt-in token trigger + # for a deterministic, no-LLM prune of OLD tool-result payloads, run + # independently of `threshold` above. On large-window models (512K/1M) the + # ratio threshold rarely fires, so bulky tool outputs (terminal dumps, file + # reads, web extracts) ride along in history and get re-billed every turn. + # When re-sent history exceeds this many tokens, the prune dedupes identical + # results, summarizes older oversized ones, and truncates large tool-call + # arguments — protecting the most recent `protect_last_n` messages and never + # calling the model. Try 48000 to enable. Built-in compressor engine only; + # other context engines inherit a safe no-op. + # NOTE: a committed prune rewrites already-sent history, which invalidates + # the provider's prompt-cache prefix — the min_reclaim gate below keeps + # those cache breaks episodic (like a compression boundary) instead of + # per-turn. + proactive_prune_tokens: 0 + + # The prune's summarize pass only touches tool results larger than this many + # characters (clamped to >= 200 so a generated summary can't be + # re-summarized). Default 8000. + proactive_prune_min_result_chars: 8000 + + # A proactive prune only COMMITS when it reclaims at least this many tokens + # (measured on the pruned output). This is the prompt-cache hysteresis gate: + # one meaningful, amortized cache break per batch of stale tool output + # instead of a tiny break on every tool iteration. 0 = commit any non-zero + # prune. Default 4096. + proactive_prune_min_reclaim_tokens: 4096 + + # To pin a specific model/provider for compression summaries, use the + # auxiliary section below (auxiliary.compression.provider / model). + +# ============================================================================= +# Tool-result budget (optional) +# ============================================================================= +# Controls when a large tool result is spilled to disk (full output saved to +# $HERMES_HOME/cache/spillover, preview + path kept in context). MCP tool +# results (tools named mcp_*) spill at a tighter 50,000-char threshold than +# the generic 100K default: MCP servers routinely return un-paginated 20-50K +# payloads that bloat context and slow every subsequent turn. Nothing is +# lost — the full result is on disk and readable with read_file. +# +# tool_budget: +# mcp_result_size_chars: 50000 # per-result spillover threshold for mcp_* tools + +# ============================================================================= +# Anthropic prompt caching TTL +# ============================================================================= +# When prompt caching is active (Claude via OpenRouter or native Anthropic), +# Anthropic supports two TTL tiers for cached prefixes: "5m" (default) and +# "1h". Other values are ignored and "5m" is used. +# +prompt_caching: + cache_ttl: "5m" # use "1h" for long sessions with pauses between turns + +# ============================================================================= +# Auxiliary Models (Advanced — Experimental) +# ============================================================================= +# Hermes uses lightweight "auxiliary" models for side tasks: image analysis, +# 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 +# here for normal usage. +# +# WARNING: Overriding these with providers other than OpenRouter or Nous Portal +# is EXPERIMENTAL and may not work. Not all models/providers support vision, +# produce usable summaries, or accept the same API format. Change at your own +# risk — if things break, reset to "auto" / empty values. +# +# Each task has its own provider + model pair so you can mix providers. +# For example: OpenRouter for vision (needs multimodal), but your main +# local endpoint for compression (just needs text). +# +# Provider options: +# "auto" - Best available: OpenRouter → Nous Portal → main endpoint (default) +# "openrouter" - Force OpenRouter (requires OPENROUTER_API_KEY) +# "nous" - Force Nous Portal (requires: hermes auth add nous) +# "gemini" - Force Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY) +# "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY) +# "codex" - Force Codex OAuth (requires: hermes model → Codex). +# Uses gpt-5.3-codex which supports vision. +# "main" - Use your custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY). +# Works with OpenAI API, local models, or any OpenAI-compatible +# endpoint. Also falls back to Codex OAuth and API-key providers. +# +# Model: leave empty to use the provider's default. When empty, OpenRouter +# uses "google/gemini-3-flash-preview" and Nous uses "gemini-3-flash". +# Other providers pick a sensible default automatically. +# +# auxiliary: +# # Image analysis: vision_analyze tool + browser screenshots +# vision: +# provider: "auto" +# model: "" # e.g. "google/gemini-2.5-flash", "openai/gpt-4o" +# timeout: 30 # LLM API call timeout (seconds) +# download_timeout: 30 # Image HTTP download timeout (seconds) +# # Increase for slow connections or self-hosted image servers +# reasoning_effort: "" # Per-task thinking level: none, minimal, low, medium, +# # high, xhigh, max, ultra. Empty = provider default. +# # Works on every auxiliary task block (vision, +# # web_extract, compression, title_generation, curator, +# # background_review, moa_reference, ...). Example: run +# # compression at "low" and vision at "none" to cut +# # side-task latency/cost on reasoning models. +# +# # Web page scraping / summarization + browser page text extraction +# web_extract: +# provider: "auto" +# model: "" +# reasoning_effort: "low" +# +# # Gemini 3.1 TTS hidden audio-tag insertion +# tts_audio_tags: +# provider: "auto" # empty model = your main chat model +# model: "" +# timeout: 30 +# +# # Automatic session title generation after the first exchange +# title_generation: +# enabled: true # set false to disable auto-title generation +# provider: "auto" +# model: "" +# timeout: 30 +# language: "" # empty = match the user's language; or e.g. "English" +# +# # Session search — summarizes matching past sessions +# session_search: +# provider: "auto" +# model: "" +# timeout: 30 +# max_concurrency: 3 # Limit parallel summaries to reduce request-burst 429s +# extra_body: {} # Provider-specific OpenAI-compatible request fields +# # Example for providers that support request-body +# # reasoning controls: +# # extra_body: +# # enable_thinking: false +# # Some vLLM/Qwen deployments expect this nested: +# # extra_body: +# # chat_template_kwargs: +# # enable_thinking: false +# +# # Auto-generated short session titles after the first exchange. +# # Each active Discord/Telegram channel can spawn a background title +# # call. Cap concurrency to keep retries during provider incidents +# # from amplifying the request burst. Leave unset for legacy behavior +# # (unlimited). +# title_generation: +# provider: "auto" +# model: "" +# # max_concurrency: 2 # Optional: cap simultaneous title calls +# +# # Context compression — summarizes long sessions to shrink the prompt. +# # Heavy and often hits the slowest provider chain. Setting a small +# # cap prevents many sessions from compressing simultaneously during +# # provider degradation. Leave unset for legacy behavior (unlimited). +# compression: +# provider: "auto" +# model: "" +# # max_concurrency: 2 # Optional: cap simultaneous compression calls +# +# # Post-turn memory/skill self-improvement review fork. Runs after a turn +# # when the nudge intervals fire; writes skills/memories in a daemon thread. +# # Usage is recorded under session_model_usage task='background_review'. +# background_review: +# enabled: true # false = skip automatic forks (/refine still works) +# provider: "auto" # or pin a cheaper model (see memory.md) +# model: "" + +# ============================================================================= +# Persistent Memory +# ============================================================================= +# Bounded curated memory injected into the system prompt every session. +# Two stores: MEMORY.md (agent's notes) and USER.md (user profile). +# Character limits keep the memory small and focused. The agent manages +# pruning -- when at the limit, it must consolidate or replace entries. +# Disabled by default in batch_runner. +# +memory: + # Agent's personal notes: environment facts, conventions, things learned + memory_enabled: true + + # User profile: preferences, communication style, expectations + user_profile_enabled: true + + # Character limits (~2.75 chars per token, model-independent) + memory_char_limit: 2200 # ~800 tokens + user_char_limit: 1375 # ~500 tokens + + # Periodic memory nudge: remind the agent to consider saving memories + # every N user turns. Set to 0 to disable. Only active when memory is enabled. + nudge_interval: 10 # Nudge every 10 user turns (0 = disabled) + +# ============================================================================= +# Session Continuity (Messaging Platforms) +# ============================================================================= +# Conversations persist across inactivity and daily boundaries. Use /new or +# /reset explicitly; context compression manages long conversations. + +# Maximum number of simultaneously active chat sessions across CLI, TUI, +# dashboard chat, and messaging gateway. Set to null, 0, or omit to allow +# unlimited concurrent sessions. When the limit is reached, new sessions get a +# clean error while existing active sessions keep their normal behavior. This +# top-level key takes precedence over gateway.max_concurrent_sessions. The cap +# is a best-effort single-host/profile runtime guard; Hermes fails open if the +# local runtime lease registry cannot be read or locked. +max_concurrent_sessions: null + +# When true, group/channel chats use one session per participant when the platform +# provides a user ID. This is the secure default and prevents users in the same +# room from sharing context, interrupts, and token costs. Set false only if you +# explicitly want one shared "room brain" per group/channel. +group_sessions_per_user: true + +# ============================================================================= +# Session Storage Retention (state.db) +# ============================================================================= +# ~/.hermes/state.db keeps every session, message, and tool call, plus the +# FTS5 search indexes. Since #54189, auto-pruning is ON by default so the file +# stays bounded: at CLI/gateway/cron startup (at most once per +# min_interval_hours) Hermes deletes ENDED sessions whose last activity is +# older than retention_days. Open, pinned, and in-progress sessions are never +# deleted. Stale automation sessions (cron/kanban/subagent/one-shot CLI) whose +# process died without closing them are first *closed*, then aged through a +# further full retention window before removal. +# +# After a prune that removed rows, VACUUM reclaims disk space only when both +# the time throttle (min_vacuum_interval_days) has elapsed AND more than 25% +# of the file's pages are reclaimable — a dense database never pays for a full +# rewrite to reclaim a few MB. +# +# Uncomment to change the defaults shown; set auto_prune: false to keep every +# ended session forever (the pre-#54189 behavior). +# sessions: +# auto_prune: true +# retention_days: 90 +# vacuum_after_prune: true +# min_vacuum_interval_days: 30 +# min_interval_hours: 24 + +# Optional direct endpoint for autonomous Bot Mode rooms spanning gateways. +# Leave unset for the safe default: Desktop coordinates cross-gateway rooms and +# same-gateway rooms can still continue on their own. Set this only to the +# public HTTPS base URL that another trusted Hermes gateway can reach. The API +# server key authorizes the initial invitation; scoped grants are signed by a +# separate installation-private secret and survive API-key rotation. Disbanding +# a Group Chat revokes its known peer routes; API-key rotation alone does not. +# Network access should still be limited to the peer gateways (for example with +# your VPN or firewall). +# Plain HTTP is accepted only for loopback testing; every reachable peer URL +# must use HTTPS. +# +# gateway: +# room_link_url: "https://hermes.example.net" +# +# The equivalent environment override is HERMES_ROOM_LINK_URL. After changing +# it, restart the gateway and reopen New Group Chat > Advanced; Hermes will +# re-check the selected gateways automatically. + +# Startup sweep of session rows orphaned by a dead gateway process. +# The normal disconnect cleanup runs on an in-process grace timer, so a +# gateway restart (update, crash, systemd) leaves those rows permanently +# "active". On every gateway boot — stdio TUI *and* the desktop/dashboard +# WS sidecar — tui/desktop/subagent rows whose start time AND newest +# message are both older than the session TTL (HERMES_TUI_SESSION_TTL_S, +# default 6h) are closed with end_reason "startup_orphan_reap". +# Messaging-platform sessions (Telegram, Discord, ...) are never touched; +# live in-memory sessions are excluded; swept sessions stay resumable. +# +# dashboard: +# startup_orphan_sweep: true + +# ───────────────────────────────────────────────────────────────────────────── +# API Server — per-client model routing +# ───────────────────────────────────────────────────────────────────────────── +# Route different API clients to different models/providers on a single +# Hermes deployment. Clients choose a backend by sending a specific string +# as the OpenAI ``model`` field. Unmapped model values fall back to the +# global model configured in the ``model:`` section above, and an explicit +# session /model override always wins over a route. +# +# Configure via the ``platforms.api_server.extra.model_routes`` gateway +# config block: +# +# platforms: +# api_server: +# enabled: true +# extra: +# key: "your-api-server-secret" +# model_routes: +# # Xiaozhi clients send model="minimax-m2" → routed to MiniMax via OpenRouter +# minimax-m2: +# model: "minimax/minimax-m1" +# provider: "openrouter" # optional — overrides global provider +# # api_key: "sk-..." # optional — per-route UPSTREAM provider +# # key (NOT caller auth; never logged) +# # base_url: "https://..." # optional — per-route base URL +# # GPT clients keep their own alias +# gpt-5: +# model: "openai/gpt-5" +# provider: "openrouter" +# +# Configured aliases are automatically listed by GET /v1/models so clients +# can discover them without manual coordination. Caller authentication is +# unchanged: every request still authenticates with the global API server +# key (``extra.key`` / API_SERVER_KEY). + +# ───────────────────────────────────────────────────────────────────────────── +# Gateway Streaming +# ───────────────────────────────────────────────────────────────────────────── +# Stream tokens to messaging platforms in real-time. The bot sends a message +# on first token, then progressively edits it as more tokens arrive. +# Disabled by default — enable to try the streaming UX on Telegram/Discord/Slack. +# For Telegram, partial edits are sent as plain text and only the final edit uses MarkdownV2. +streaming: + enabled: false + # transport: edit # "edit" = progressive editMessageText + # edit_interval: 0.3 # seconds between message edits + # buffer_threshold: 40 # chars before forcing an edit flush + # cursor: " ▉" # cursor shown during streaming + +# ============================================================================= +# Skills Configuration +# ============================================================================= +# Skills are reusable procedures the agent can load and follow. The agent can +# also create new skills after completing complex tasks. +# +skills: + # Nudge the agent to create skills after complex tasks. + # Every N tool-calling iterations, remind the model to consider saving a skill. + # Set to 0 to disable. + creation_nudge_interval: 15 + + # External skill directories — share skills across tools/agents without + # copying them into ~/.hermes/skills/. Each path is expanded (~ and ${VAR}) + # and resolved to an absolute path. External dirs are read-only: skill + # creation always writes to ~/.hermes/skills/. Local skills take precedence + # when names collide. + # external_dirs: + # - ~/.agents/skills + # - /home/shared/team-skills + +# ============================================================================= +# Agent Behavior +# ============================================================================= +agent: + # Maximum tool-calling iterations per conversation (default: 500) + # Higher = more room for complex tasks, but costs more tokens + # Recommended: 20-30 for focused tasks, 50-100 for open exploration + max_turns: 500 + + # Inactivity timeout for gateway agent runs (seconds, 0 = unlimited). + # The agent can run indefinitely when actively calling tools or receiving + # API responses. Only fires after the agent has been idle for this duration. + # gateway_timeout: 1800 + + # Maximum time an alias routing key waits for an active turn holding the same + # resolved session lease. On expiry Hermes rejects this inbound message and + # asks the user to resend rather than running it without serialization. + # Non-positive values fall back to the 5-second default. + # gateway_turn_lease_timeout: 5 + + # Staged warning: send a warning before escalating to full timeout. + # Fires once per run when inactivity reaches this threshold (seconds). + # Set to 0 to disable the warning. + # gateway_timeout_warning: 900 + + # Session stall watchdog (seconds). When a busy session has a pending + # inbound follow-up and the agent activity clock is idle this long, the + # gateway logs a WARNING and notifies the user to try /new. Does not kill + # the turn (see gateway_timeout). 0 = disable. Default 300. + # session_stall_timeout: 300 + + # Transcript-sanitiser repeated-heal escalation (#96870). The pre-send + # sanitiser silently repairs empty non-final turns on the wire copy; after + # this many heal passes within a 10-minute session window Hermes logs one + # ERROR (with session id + heal pattern) and sends a ONE-TIME out-of-band + # notice to the user suggesting /debug share or `hermes doctor`. The notice + # goes through the status channel only — conversation context and prompt + # caching are untouched. Set 0 to disable escalation (per-window WARNINGs + # still fire). Default 3. + # sanitizer_heal_escalation_threshold: 3 + + # Related in-agent compression timeouts (they live under the top-level + # compression: block, shown here for discoverability next to the stall + # watchdog they complement — a hung compression is a common stall cause): + # compression: + # context_timeout_seconds: 120 # inactivity budget for in-agent + # # compress_context (0 = disable) + # context_total_ceiling_seconds: 600 # absolute cap on the pre-commit + # # wait even while tokens stream + + # Graceful drain timeout for gateway stop/restart (seconds). + # Default 0 = no drain: a restart interrupts in-flight agents immediately, + # cleans up, and exits. Set a positive value only if you want a grace + # window on /restart, and keep it well under systemd's TimeoutStopSec. + # restart_drain_timeout: 0 + + # Cron-only floor under the same drain (seconds). Default 30. + # restart_drain_timeout above is written for chat turns, which are cheap to + # interrupt: the user is told the gateway is restarting and the session + # resumes on their next message. A cron run has no such safety net — it is + # recorded in jobs.json as a permanent failure, nobody is waiting on it, and + # a recurring job simply skips to its next schedule. So in-flight cron work + # gets its own grace window instead of inheriting the 0 above. + # Clamped at runtime to the shutdown-watchdog leash (restart_drain_timeout + # + 60s) minus teardown headroom, so values past ~50s need a matching + # TimeoutStopSec bump to take effect. Set 0 to opt out and drain cron on + # restart_drain_timeout like before. + # cron_drain_timeout: 30 + + # Upper bound (seconds) a submitted prompt waits for the deferred agent + # build (MCP discovery, model metadata, skills scan) before failing with a + # visible error. The wait is patient — the message is delivered as soon as + # the build completes, and a progress notice is shown past 30s — so this cap + # only fires on a genuinely hung build. Raise it for deployments with many + # slow or unreachable MCP servers. Default 600. + # build_wait_timeout: 600 + + # Max app-level retry attempts for API errors (connection drops, provider + # timeouts, 5xx, etc.) before the agent surfaces the failure. Lower this + # to 1 if you use fallback providers and want fast failover on flaky + # primaries (default 3). The OpenAI SDK does its own low-level retries + # underneath this wrapper — this is the Hermes-level loop. + # api_max_retries: 3 + + # After the agent edits code without fresh passing verification, nudge it to + # verify before finishing. The default "auto" enables it on interactive + # coding surfaces (CLI, TUI, desktop) and programmatic callers, and disables + # it on conversational messaging surfaces (Telegram, Discord, etc.) where the + # verification summary would reach a human as chat noise. Set true or false to + # force it on or off; the HERMES_VERIFY_ON_STOP env var (1/0) takes precedence. + # verify_on_stop: auto + + # Standing operator instructions for the coding posture (when Hermes is in a + # code workspace). Appended to the coding brief as an extra system block, so + # you can pin project-wide workflow rules without editing the shipped brief. + # Accepts a string or a list of strings. Takes effect next session. + # coding_instructions: + # - "For UI work, don't run tsc/lint until I approve the look." + # - "Clean the diff before you commit and push." + + # When verify-on-stop finds edited code without fresh verification evidence, + # append guidance for creative UI work (avoid broad tsc/lint/test before visual + # approval) and clean-diff expectations. Set false to keep that nudge terse. + # verify_guidance: true + + # A `pre_verify` hook (plugin or shell, see Event Hooks docs) can keep the + # agent going one more turn to verify/clean before finishing. This caps how + # many times one turn may be nudged to continue, so a hook can't trap the loop. + # Default 3. + # max_verify_nudges: 3 + + # Enable verbose logging + verbose: false + + # Reasoning effort level (OpenRouter and Nous Portal) + # Controls how much "thinking" the model does before responding. + # Options: "xhigh" (max), "high", "medium", "low", "minimal", "none" (disable) + reasoning_effort: "medium" + + # Per-model reasoning effort overrides (optional dict) + # Key: any sensible model spelling works (exact, dots↔dashes interchangeable, + # provider prefix optional). First match wins. + # Value: reasoning effort level (same options as reasoning_effort) + # Override the global reasoning_effort for that specific model. + # NOTE: no `hermes config set` support for this key -- edit YAML directly. + # reasoning_overrides: + # "openrouter/anthropic/claude-opus-4.5": "xhigh" + # "openai/gpt-5": "low" + # "claude-opus-4.6": "high" # bare model name also works + # "deepseek/deepseek-v4-pro": "xhigh" # dots and dashes are interchangeable + reasoning_overrides: {} + + # Fast mode (OpenAI Priority Processing / xAI Grok 4.6 / Anthropic Fast Mode + # on Opus 4.8+). Premium pricing; only sent to first-party endpoints. + # "" / "normal" - off (default) + # "fast" - every request + # "auto" - only the first fast_auto_seconds of every turn + # "cold" - that window on the first turn of a session only + # Also: /fast normal|fast|auto|cold [--global] + service_tier: "" + fast_auto_seconds: 60 + + # Custom personalities (use with /personality command). + # Built-ins (helpful, concise, technical, creative, teacher, kawaii, catgirl, + # pirate, shakespeare, surfer, noir, uwu, philosopher, hype) are always + # available on every surface — defined once in hermes_cli/personality.py. + # Entries here ADD new personalities or OVERRIDE a built-in by name. + # The active selection is stored in display.personality (never here, and + # never in agent.system_prompt — that field is your manual system prompt). + personalities: {} + # mentor: "You are a supportive mentor. Guide, don't lecture." + # reviewer: + # system_prompt: "You are a meticulous code reviewer." + # tone: "direct" + # style: "terse" + +# ============================================================================= +# Gateway Lifecycle +# ============================================================================= +gateway: + # Post-interrupt grace for running agents during an unexpected SIGTERM. + # Adapter, bridge, and database teardown starts after this many seconds even + # if an agent has not unwound. Keep it below the service-manager stop budget. + # signal_interrupt_grace_timeout: 1 + + # Let platform adapters honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY (and + # SSL_CERT_FILE) from the process environment, plus macOS system-proxy + # auto-detection. Set to false when the gateway inherits a proxy it must not + # use (e.g. a Windows Scheduled Task picking up a local Clash/V2Ray proxy that + # isn't running). Explicit per-platform vars like DISCORD_PROXY still apply. + # trust_env: true + +# ============================================================================= +# Toolsets +# ============================================================================= +# Control which tools the agent has access to. +# Use `hermes tools` to interactively enable/disable tools per platform. + +# ============================================================================= +# Platform Toolsets (per-platform tool configuration) +# ============================================================================= +# Override which toolsets are available on each platform. +# If a platform isn't listed here, its built-in default is used. +# +# You can use EITHER: +# - A preset like "hermes-cli" or "hermes-telegram" (curated tool set) +# - A list of individual toolsets to compose your own (see list below) +# +# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot, teams, google_chat +# +# Examples: +# +# # Use presets (same as defaults): +# platform_toolsets: +# cli: [hermes-cli] +# telegram: [hermes-telegram] +# +# # Custom: give Telegram only web + terminal + file + planning: +# platform_toolsets: +# telegram: [web, terminal, file, todo] +# +# # Custom: CLI without browser or image gen: +# platform_toolsets: +# cli: [web, terminal, file, skills, todo, tts, cronjob] +# +# # Restrictive: Discord gets read-only tools only: +# platform_toolsets: +# discord: [web, vision, skills, todo] +# +# If not set, defaults are: +# cli: hermes-cli (everything + cronjob management) +# telegram: hermes-telegram (terminal, file, web, vision, image, tts, browser, skills, todo, cronjob, messaging) +# discord: hermes-discord (same as telegram) +# whatsapp: hermes-whatsapp (same as telegram) +# slack: hermes-slack (same as telegram) +# signal: hermes-signal (same as telegram) +# homeassistant: hermes-homeassistant (same as telegram) +# qqbot: hermes-qqbot (same as telegram) +# teams: hermes-teams (same as telegram) +# google_chat: hermes-google_chat (same as telegram) +# +platform_toolsets: + cli: [hermes-cli] + telegram: [hermes-telegram] + discord: [hermes-discord] + whatsapp: [hermes-whatsapp] + slack: [hermes-slack] + signal: [hermes-signal] + homeassistant: [hermes-homeassistant] + qqbot: [hermes-qqbot] + yuanbao: [hermes-yuanbao] + teams: [hermes-teams] + google_chat: [hermes-google_chat] + +# ============================================================================= +# Gateway Platform Settings +# ============================================================================= +# Optional per-platform messaging settings. +# Platform-specific knobs live under `extra`. +# +# platforms: +# telegram: +# reply_to_mode: "first" # off | first | all +# # guest_mode lets explicit @mentions from non-allowlisted groups through. +# # Default false; ordinary messages, replies, and regex wake words stay blocked. +# guest_mode: false +# # allowed_chats: ["-1001234567890"] +# extra: +# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages +# rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default false for copyable legacy MarkdownV2, set true to opt in +# rich_drafts: false # Experimental rich draft previews during Telegram DM streaming; default false because Telegram Desktop/macOS can visually overlay draft frames +# command_menu: +# # Telegram allows up to 100 BotCommands; Hermes defaults to 60 so +# # all built-in commands plus common skill commands stay visible +# # while remaining under Telegram's payload-size limit. Clamped 1..100. +# max_commands: 60 +# # prepend = user priority first, then Hermes defaults +# # append = Hermes defaults first, then user priority +# # replace = only the list below defines priority +# priority_mode: prepend +# # Priority is applied across core + plugin + skill commands before +# # the cap, so a listed skill command always keeps a menu slot. +# priority: +# - my_plugin_command +# - my-important-skill +# slack: +# extra: +# # Render live tool calls as Slack-native plan/task cards. This explicit +# # opt-in works even though Slack text tool_progress defaults to off. +# native_task_cards: false +# # Slack user IDs whose Web-API posts (user token, e.g. your own +# # dashboard/mobile front-end) count as human instead of being dropped +# # as app traffic. Narrower than allow_bots: all. Users only — never apps. +# api_human_users: ["U0AAAAAAA", "U0BBBBBBB"] +# # Suppress automatic link-preview cards without removing clickable links. +# # Omit either key to preserve Slack's default for that preview type. +# unfurl_links: false +# unfurl_media: false +# webhook: +# extra: +# # Route scripts default to a 30 second timeout. Scripts must live under +# # the active profile's scripts directory and receive webhook JSON on stdin. +# script_timeout_seconds: 30 +# +# Discord-specific settings (config.yaml top-level, not under platforms:): +# +# discord: +# require_mention: true # Require @mention in server channels (default: true) +# auto_thread: true # Auto-create thread on @mention (default: true) +# free_response_channels: "" # Channel IDs where no mention is needed +# reactions: true # Show processing reactions (default: true) +# history_backfill: true # Recover missed channel messages on mention (default: true) +# history_backfill_limit: 50 # Max messages to scan backwards (default: 50) + +# ───────────────────────────────────────────────────────────────────────────── +# Available toolsets (use these names in platform_toolsets or the toolsets list) +# +# Run `hermes chat --list-toolsets` to see all toolsets and their tools. +# Run `hermes chat --list-tools` to see every individual tool with descriptions. +# ───────────────────────────────────────────────────────────────────────────── +# +# INDIVIDUAL TOOLSETS (compose your own): +# web - web_search, web_extract +# search - web_search only (no scraping) +# terminal - terminal, process +# file - read_file, write_file, patch, search +# browser - browser_navigate, browser_snapshot, browser_click, browser_type, +# browser_scroll, browser_back, browser_press, +# browser_get_images, browser_vision (requires BROWSERBASE_API_KEY) +# vision - vision_analyze (requires OPENROUTER_API_KEY) +# image_gen - image_generate (requires FAL_KEY) +# skills - skills_list, skill_view +# skills_hub - skill_hub (search/install/manage from online registries — user-driven only) +# todo - todo (in-memory task planning, no deps) +# tts - text_to_speech (Edge TTS free, or ELEVENLABS/OPENAI/MINIMAX/MISTRAL key) +# cronjob - cronjob (create/list/update/pause/resume/run/remove scheduled tasks) +# +# PRESETS (curated bundles): +# hermes-cli - All of the above except send_message +# hermes-telegram - terminal, file, web, vision, image_gen, tts, browser, +# skills, todo, cronjob, send_message +# hermes-discord - Same as hermes-telegram +# hermes-whatsapp - Same as hermes-telegram +# hermes-slack - Same as hermes-telegram +# +# COMPOSITE: +# debugging - terminal + web + file +# safe - web + vision (no terminal access) +# all - Everything available +# +# web - Web search and content extraction (web_search, web_extract) +# search - Web search only, no scraping (web_search) +# terminal - Command execution and process management (terminal, process) +# file - File operations: read, write, patch, search +# browser - Full browser automation (navigate, click, type, screenshot, etc.) +# vision - Image analysis (vision_analyze) +# image_gen - Image generation with FLUX (image_generate) +# skills - Load skill documents (skills_list, skill_view) +# todo - Task planning and tracking for multi-step work +# memory - Persistent memory across sessions (personal notes + user profile) +# session_search - Search and recall past conversations (FTS5 + Gemini Flash summarization) +# tts - Text-to-speech (Edge TTS free, ElevenLabs, OpenAI, MiniMax, Mistral) +# cronjob - Schedule and manage automated tasks (CLI-only) +# +# Composite toolsets: +# debugging - terminal + web + file (for troubleshooting) +# safe - web + vision (no terminal access) + +# NOTE: The top-level "toolsets" key is deprecated and ignored. +# Tool configuration is managed per-platform via platform_toolsets above. +# Use `hermes tools` to configure interactively, or edit platform_toolsets directly. +# +# CLI override: hermes chat --toolsets terminal,web,file + +# ============================================================================= +# MCP (Model Context Protocol) Servers +# ============================================================================= +# Connect to external MCP servers to add tools from the MCP ecosystem. +# Each server's tools are automatically discovered and registered. +# See website/docs/user-guide/features/mcp.md for full documentation. +# +# Stdio servers (spawn a subprocess): +# command: the executable to run +# args: command-line arguments +# env: environment variables (only these + safe defaults passed to subprocess) +# +# HTTP servers (connect to a URL): +# url: the MCP server endpoint +# headers: HTTP headers (e.g., for authentication) +# +# Optional per-server settings: +# timeout: tool call timeout in seconds (default: 120) +# connect_timeout: initial connection timeout (default: 60) +# keepalive_interval: liveness ping cadence in seconds (default: 180). +# Lower it below the server's session TTL for servers that expire idle +# sessions quickly (e.g. Unreal Engine editor MCP, ~15s), otherwise idle +# tool calls hit an expired session and pay a slow reconnect. Floored at 5s. +# +# mcp_servers: +# time: +# command: uvx +# args: ["mcp-server-time"] +# filesystem: +# command: npx +# args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user"] +# notion: +# url: https://mcp.notion.com/mcp +# github: +# command: npx +# args: ["-y", "@modelcontextprotocol/server-github"] +# env: +# GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_..." +# +# Sampling (server-initiated LLM requests) — enabled by default. +# Per-server config under the 'sampling' key: +# analysis: +# command: npx +# args: ["-y", "analysis-server"] +# sampling: +# enabled: true # default: true +# model: "gemini-3-flash" # override model (optional) +# max_tokens_cap: 4096 # max tokens per request +# timeout: 30 # LLM call timeout (seconds) +# max_rpm: 10 # max requests per minute +# allowed_models: [] # model whitelist (empty = all) +# 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" +# speed: 1.0 # global speed multiplier (provider-specific overrides this) +# gemini: +# model: "gemini-3.1-flash-tts-preview" +# voice: "Kore" +# audio_tags: false +# persona_prompt_file: "" # e.g. ~/.hermes/tts/radio-host.md +# xai: +# voice_id: "eve" # built-in or custom voice ID from xAI Console +# language: "en" # BCP-47 code ("en", "pt-BR") or "auto" +# speed: 1.0 # 0.7-1.5 playback speed +# auto_speech_tags: false # insert expressive audio tags via LLM rewrite +# text_normalization: false # normalize numbers/abbreviations/symbols +# optimize_streaming_latency: 0 # 0-2, trades quality for lower latency +# sample_rate: 24000 # 22050 / 24000 / 44100 / 48000 +# bit_rate: 128000 # MP3 bitrate (codec=mp3 only) + +# ============================================================================= +# Voice Transcription (Speech-to-Text) +# ============================================================================= +# Automatically transcribe voice messages on messaging platforms. +# Providers: local (free, faster-whisper) | groq (free tier) | openai (Whisper API) | mistral (Voxtral Transcribe) +# Set the corresponding API key in .env: GROQ_API_KEY, OPENAI_API_KEY, or MISTRAL_API_KEY. +stt: + enabled: true + # provider: "local" # auto-detected if omitted + # --- Cloud pre-upload silence trim (groq/openai/mistral/xai/elevenlabs/deepinfra) --- + # Local whisper gets Silero VAD; cloud endpoints otherwise receive raw audio — + # silence inflates upload time, per-audio-minute billing, and hallucination risk. + # Collapses pauses with ffmpeg client-side; clips under 12s skip the trim, and + # on any failure the original uploads untouched. + # cloud_trim_silence: true # set false to always upload the original audio + # cloud_trim_threshold_db: -40 # audio quieter than this counts as silence + # cloud_trim_keep_ms: 300 # how much of each pause survives (keeps natural pacing) + # Optional static transcription prompt: vocabulary/context hints for + # prompt-capable backends. Composition is deterministic: this config value + # is the base, then `pre_transcription` hooks run in registration order with + # last-writer-wins semantics per field. + # + # Privacy: the final prompt is sent to the configured STT provider alongside + # the audio. Do not include secrets or session context you would not send to + # that provider. + # + # Length / truncation contract: Whisper-family backends (local, openai, + # groq, deepinfra) only use the final ~224 prompt tokens, so Hermes + # truncates longer prompts client-side (keeping the tail) with a WARNING + # log — never an error. Other backends own their own validation. + # + # Provider behavior: + # local (faster-whisper) -> `initial_prompt`, forwarded unchanged + # openai / groq -> `prompt`, forwarded unchanged + # mistral -> `prompt`, forwarded unchanged + # deepinfra -> OpenAI-compatible `prompt`, unchanged + # local_command/xai/ + # elevenlabs -> unsupported; DEBUG log, then continue + # plugin providers -> `prompt` in `**extra`; plugin owns limits + # prompt: "Hermes, Teknium, Nous Research, kanban" + local: + model: "base" # tiny | base | small | medium | large-v3 | turbo + # language: "" # auto-detect; set to "en", "es", "fr", etc. to force + # initial_prompt: "" # Optional faster-whisper prompt, e.g. bias Chinese output to simplified Chinese + # --- Anti-hallucination hardening (whisper decodes junk from silence without these) --- + # vad: true # Silero VAD filter (default on) — silence never reaches whisper. + # # Set false to restore raw behavior (e.g. transcribing music/ambient audio). + # vad_min_silence_ms: 500 # min silence (ms) that splits speech chunks when vad is on + # no_speech_prob_threshold: 0.6 # drop a segment only if no_speech_prob > this... + # logprob_threshold: -1.0 # ...AND avg_logprob < this (both must hit — quiet real speech survives) + # unload_after_idle_seconds: 0 # 0=never unload (default); e.g. 300 releases the model after 5min idle (frees VRAM on GPU; next voice message reloads it) + language: "en" # GLOBAL language hint for every STT provider (per-provider language wins). Set "" for auto-detect. + # groq: + # model: "whisper-large-v3-turbo" + # language: "" # blank = stt.language > HERMES_LOCAL_STT_LANGUAGE > auto-detect + openai: + model: "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe | gpt-transcribe + language: "" # auto-detect; set to "en", "es", "fr", etc. to force + # mistral: + # model: "voxtral-mini-latest" # voxtral-mini-latest | voxtral-mini-2602 + # deepinfra: + # # Model id is discovered live from the DeepInfra catalog filtered + # # by the `stt` surface tag — leave `model` blank to take the first + # # live result. Pin only when you need a specific Whisper variant. + # model: "" + +# Text-to-speech. Only the deepinfra block is documented here — the +# remaining providers (edge, openai, xai, minimax, mistral, gemini, +# elevenlabs, neutts, kittentts, piper) inherit sensible defaults from +# DEFAULT_CONFIG in hermes_cli/config.py. +# tts: +# provider: "deepinfra" +# deepinfra: +# # Model id is discovered live from the DeepInfra catalog filtered +# # by the `tts` surface tag — leave `model` blank to take the first +# # live result. +# model: "" +# voice: "default" + +# "Hey Hermes" hands-free wake word (off by default; toggle with /wake). +# Full engine/provider options inherit from DEFAULT_CONFIG in +# hermes_cli/config_defaults.py — only capture placement is documented here. +# wake_word: +# enabled: false +# capture: "auto" # auto | local | client +# # auto: backend PortAudio mic when one exists; a remote +# # desktop on a mic-less (headless/VPS) backend +# # streams its own mic via the wake.feed RPC. +# # local: always the backend mic (wake_word.input_device). +# # client: always desktop-streamed PCM (detection stays +# # on the backend). + +# Image generation. Each provider plugin reads its own ``image_gen.`` +# block; deepinfra discovers models live from +# api.deepinfra.com/v1/openai/models filtered by the ``image-gen`` tag — +# no model id is hardcoded, so retired models disappear automatically. +# image_gen: +# provider: "deepinfra" +# deepinfra: +# # Leave `model` blank for the first live `image-gen`-tagged result. +# model: "" + +# ============================================================================= +# Response Pacing (Messaging Platforms) +# ============================================================================= +# Add human-like delays between message chunks. +# human_delay: +# mode: "off" # "off" | "natural" | "custom" +# min_ms: 800 # Min delay (custom mode only) +# max_ms: 2500 # Max delay (custom mode only) + +# ============================================================================= +# Session Logging +# ============================================================================= +# Session trajectories are automatically saved to logs/ directory. +# Each session creates: logs/session_YYYYMMDD_HHMMSS_UUID.json +# +# The session ID is displayed in the welcome banner for easy reference. +# Logs contain full conversation history in trajectory format: +# - System prompt, user messages, assistant responses +# - Tool calls with inputs/outputs +# - Timestamps for debugging +# +# No configuration needed - logging is always enabled. +# To disable, you would need to modify the source code. + +# ============================================================================= +# Code Execution Sandbox (Programmatic Tool Calling) +# ============================================================================= +# The execute_code tool runs Python scripts that call Hermes tools via RPC. +# Intermediate tool results stay out of the LLM's context window. +code_execution: + timeout: 300 # Max seconds per script before kill (default: 300 = 5 min) + max_tool_calls: 50 # Max RPC tool calls per execution (default: 50) + # Local execution uses a persistent session kernel: variables, imports, and + # loaded data survive across execute_code calls in one conversation, so + # multi-step data work stops re-loading its inputs every call. Pass + # reset=true on a call to discard state; a timed-out/interrupted cell kills + # the kernel (next call starts fresh). Remote terminal backends currently + # run per-call (remote kernel host is tracked follow-up work). Security + # scrubbing, tool whitelist, and output redaction are identical either way. + # kernel_idle_timeout: 1800 # Reap kernels idle longer than this (seconds) + # max_session_kernels: 4 # Process-wide LRU cap on live kernels + +# ============================================================================= +# Subagent Delegation +# ============================================================================= +# The delegate_task tool spawns child agents with isolated context. +# Supports single tasks and batch mode (default 3 parallel, configurable). +delegation: + max_iterations: 250 # Max tool-calling turns per child (default: 250) + # max_concurrent_children: 10 # Max parallel child agents per batch (default: 10, floor: 1, no ceiling). + # WARNING: values above 10 multiply API cost linearly. + # max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat). + # Raise to 2 to allow workers to spawn their own subagents. + # Requires role="orchestrator" on intermediate agents. + # orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true). + # subagent_auto_approve: false # When a subagent hits a dangerous-command approval prompt, auto-deny (default: false) + # or auto-approve "once" (true) instead of blocking on stdin. + # The parent TUI owns stdin, so blocking would deadlock; non-interactive resolution is required. + # Both choices emit a logger.warning audit line. Flip to true only for cron/batch pipelines. + # inherit_mcp_toolsets: true # When explicit child toolsets are narrowed, also keep the parent's MCP toolsets (default: true). Set false for strict intersection. + # model: "google/gemini-3-flash-preview" # Override model for subagents (empty = inherit parent) + # provider: "openrouter" # Override provider for subagents (empty = inherit parent) + # # Resolves full credentials (base_url, api_key) automatically. + # # Supported: openrouter, nous, zai, kimi-coding, minimax + # # Cost tip: keep the parent on a frontier model and pin + # # delegation.model to an inexpensive one — children carry the + # # vast majority of tokens, so this is where spend is cut while + # # planning quality stays with the frontier parent. + # fallback_providers: # Fallback chain for delegated children (same entry format as the + # - provider: "openrouter" # top-level list). Pinned children use only a declared child chain; + # model: "deepseek/deepseek-chat" # unpinned children inherit when unset. [] disables fallback (#65038). + +# ============================================================================= +# Honcho Integration (Cross-Session User Modeling) +# ============================================================================= +# AI-native persistent memory via Honcho (https://honcho.dev/). +# Builds a deeper understanding of the user across sessions and tools. +# Runs alongside USER.md — additive, not a replacement. +# +# Requires: pip install honcho-ai +# Config: ~/.honcho/config.json (shared with Claude Code, Cursor, etc.) +# API key: HONCHO_API_KEY in ~/.hermes/.env or ~/.honcho/config.json +# +# Hermes-specific overrides (optional — most config comes from ~/.honcho/config.json): +# honcho: {} + +# ============================================================================= +# Display +# ============================================================================= +display: + # Use compact banner mode (hides the ASCII-art banner, shows a single line). + # true: Compact single-line banner + # false: Full ASCII banner with tool/skill summary (default) + compact: false + + # Tool progress display level (CLI and gateway) + # off: Silent — no tool activity shown, just the final response + # new: Show a tool indicator only when the tool changes (skip repeats) + # all: Show every tool call with a short preview (default) + # verbose: Full args, results, and debug logs (same as /verbose) + # log: Silent in chat; append every tool call to ~/.hermes/logs/tool_calls.log (gateway only) + # Toggle at runtime with /verbose in the CLI + tool_progress: all + + # Per-platform defaults can be quieter than the global setting. Telegram + # tunes for mobile: tool_progress and busy_ack_detail default off (no + # per-tool breadcrumb stream, no "iteration 21/60" debug detail in busy + # acks or heartbeats), but interim_assistant_messages and + # long_running_notifications STAY ON so the user has real signal between + # turn start and final answer (mid-turn assistant commentary + a single + # edit-in-place "⏳ Working — N min" heartbeat). Override under + # display.platforms.telegram. + + # Auto-cleanup of temporary progress bubbles after the final response lands. + # On platforms that support message deletion (currently Telegram), this + # removes the tool-progress bubble, "⏳ Still working..." notices, and + # context-pressure status messages once the final reply has been delivered — + # keeping long-running turns visible live, then tidy afterward. Failed runs + # leave the bubbles in place as breadcrumbs. Off by default. + # Per-platform override: display.platforms.telegram.cleanup_progress + # true: Delete tracked progress/status bubbles on successful turn + # false: Leave everything in place (default) + # Example: + # display: + # platforms: + # telegram: + # cleanup_progress: true + cleanup_progress: false + + # Natural mid-turn assistant updates. + # On gateway platforms, when true, completed assistant status messages are + # sent as separate chat messages. On the Desktop app, when true, mid-turn + # assistant narration streamed between tool calls is kept in the transcript + # instead of the bubble collapsing to only the final message on completion. + # Independent of tool_progress and gateway streaming. + # true: Keep/send mid-turn assistant updates (default) + # false: Only keep/send the final response + interim_assistant_messages: true + + # Gateway-only long-running status heartbeats. + # When false, the platform does not receive periodic "⏳ Working — N min" + # notifications even if agent.gateway_notify_interval is non-zero. The + # heartbeat edits a single message in place (where the adapter supports + # editing) instead of posting a new bubble each interval. + # Default: true everywhere, including Telegram (silent agents are worse + # than a single edit-in-place heartbeat). + long_running_notifications: true + + # Include detailed iteration/tool/status context in busy acknowledgments + # and long-running heartbeats. When true, busy acks show "iteration 21/60, + # terminal, 10 min" and the heartbeat shows "⏳ Working — 12 min, + # iteration 21/60, terminal". When false (Telegram default), both stay + # terse: "Interrupting current task" and "⏳ Working — 12 min, terminal". + busy_ack_detail: true + + # What Enter does when Hermes is already busy (CLI and gateway platforms). + # interrupt: Interrupt the current run and redirect Hermes (default) + # queue: Queue your message for the next turn + # steer: Inject your message mid-run via /steer, arriving at the agent + # after the next tool call — no interrupt, no role violation. + # Falls back to 'queue' if the agent isn't running yet or if + # images are attached (steer only carries text). + # Ctrl+C (or /stop in gateway) always interrupts regardless of this setting. + # Toggle at runtime with /busy . + busy_input_mode: interrupt + + # Background process notifications (gateway/messaging only). + # Controls how chatty the process watcher is when you use + # terminal(background=true, notify_on_complete=true) from Telegram/Discord/etc. + # concise: One-line status message on completion; failures include a short + # output tail (default) + # off: No watcher messages at all + # result: Only the final raw-output completion message + # error: Only the final raw-output message when exit code != 0 + # all: Running output updates + final raw-output message + background_process_notifications: concise + + + # Play terminal bell when agent finishes a response. + # Useful for long-running tasks — your terminal will ding when the agent is done. + # Works over SSH. Most terminals can be configured to flash the taskbar or play a sound. + # true: Ring the terminal bell on each response + # false: Silent (default) + bell_on_complete: false + + # Play terminal bell when a blocking prompt opens and waits on you: + # clarify questions, dangerous-command approvals, sudo password, secret + # capture. Same mechanism as bell_on_complete (\a) — works over SSH. + # true: Ring whenever the agent is waiting for your input + # false: Silent (default) + bell_on_prompt: false + + # Show model reasoning/thinking before each response. + # When enabled, a dim box shows the model's thought process above the response. + # Toggle at runtime with /reasoning show or /reasoning hide. + # true: Show the reasoning box + # false: Hide reasoning (default) + show_reasoning: false + + # Stream tokens to the terminal as they arrive instead of waiting for the + # full response. The response box opens on first token and text appears + # line-by-line. Tool calls are still captured silently. + # true: Stream tokens as they arrive (default) + # false: Wait for the full response before rendering + streaming: true + + # Show [HH:MM] timestamps on user input and assistant response labels. + # timestamps: false + + # ─────────────────────────────────────────────────────────────────────────── + # Skin / Theme + # ─────────────────────────────────────────────────────────────────────────── + # Customize CLI visual appearance — banner colors, spinner faces, tool prefix, + # response box label, and branding text. Change at runtime with /skin . + # + # Built-in skins: + # default — Classic Hermes gold/kawaii + # ares — Crimson/bronze war-god theme with spinner wings + # mono — Clean grayscale monochrome + # slate — Cool blue developer-focused + # daylight — Bright light-mode theme + # warm-lightmode — Warm paper-tone light-mode theme + # poseidon — Sea-green/teal Olympian theme + # sisyphus — Earthy stone-and-moss theme + # charizard — Fiery orange dragon theme + # + # Custom skins: drop a YAML file in ~/.hermes/skins/.yaml + # Schema (all fields optional, missing values inherit from default): + # + # name: my-theme + # description: Short description + # colors: + # banner_border: "#HEX" # Panel border + # banner_title: "#HEX" # Panel title + # banner_accent: "#HEX" # Section headers (Available Tools, etc.) + # banner_dim: "#HEX" # Dim/muted text + # banner_text: "#HEX" # Body text (tool names, skill names) + # ui_accent: "#HEX" # UI accent color + # response_border: "#HEX" # Response box border color + # spinner: + # waiting_faces: ["(⚔)", "(⛨)"] # Faces shown while waiting + # thinking_faces: ["(⚔)", "(⌁)"] # Faces shown while thinking + # thinking_verbs: ["forging", "plotting"] # Verbs for spinner messages + # wings: # Optional left/right spinner decorations + # - ["⟪⚔", "⚔⟫"] + # - ["⟪▲", "▲⟫"] + # branding: + # agent_name: "My Agent" # Banner title and branding + # welcome: "Welcome message" # Shown at CLI startup + # response_label: " ⚔ Agent " # Response box header label + # prompt_symbol: "⚔" # Prompt symbol (bare token; renderers add trailing space) + # tool_prefix: "╎" # Tool output line prefix (default: ┊) + # + skin: default + +# ============================================================================= +# Model Aliases — short names for /model command +# ============================================================================= +# Map short aliases to exact (model, provider, base_url) tuples. +# Used by /model tab completion and resolve_alias(). +# Aliases are checked BEFORE the models.dev catalog, so they can route +# to endpoints not in the catalog (e.g. Ollama Cloud, local servers). +# +# An alias pointing at its own endpoint can carry that endpoint's +# credential with `api_key` (a literal, or a "${VAR}" reference) or +# `key_env` (an env var name). `api_key` wins if both are set. When +# neither is set the key is resolved from the alias HOST — never from +# whatever provider was active before the switch, which would send that +# provider's secret to an unrelated third party. +# +# model_aliases: +# opus: +# model: claude-opus-4-6 +# provider: anthropic +# qwen: +# model: "qwen3.5:397b" +# provider: custom +# base_url: "https://ollama.com/v1" +# glm: +# model: glm-4.7 +# provider: custom +# base_url: "https://ollama.com/v1" +# theta: +# model: theta-1 +# provider: custom +# base_url: "https://theta.example.com/v1" +# key_env: THETA_API_KEY # or: api_key: "${THETA_API_KEY}" + +# ============================================================================= +# Privacy +# ============================================================================= +# privacy: +# # Redact PII from the LLM context prompt. +# # When true, phone numbers are stripped and user/chat IDs are replaced +# # with deterministic hashes before being sent to the model. +# # Names and usernames are NOT affected (user-chosen, publicly visible). +# # Routing/delivery still uses the original values internally. +# redact_pii: false + +# ============================================================================= +# Shell-script hooks +# ============================================================================= +# Register shell scripts as plugin-hook callbacks. Each entry is executed as +# a subprocess (shell=False, shlex.split) with a JSON payload on stdin. On +# stdout the script may return JSON that either blocks the tool call or +# injects context into the next LLM call. +# +# Valid events (mirror hermes_cli.plugins.VALID_HOOKS): +# pre_tool_call, post_tool_call, pre_llm_call, post_llm_call, +# pre_api_request, post_api_request, on_session_start, on_session_end, +# on_session_finalize, on_session_reset, subagent_stop +# +# First-use consent: each (event, command) pair prompts once on a TTY, then +# is persisted to ~/.hermes/shell-hooks-allowlist.json. Non-interactive +# runs (gateway, cron) need --accept-hooks, HERMES_ACCEPT_HOOKS=1, or the +# hooks_auto_accept key below. +# +# See website/docs/user-guide/features/hooks.md for the full JSON wire +# protocol and worked examples. +# +# hooks: +# pre_tool_call: +# - matcher: "terminal" +# command: "~/.hermes/agent-hooks/block-rm-rf.sh" +# timeout: 10 +# post_tool_call: +# - matcher: "write_file|patch" +# command: "~/.hermes/agent-hooks/auto-format.sh" +# pre_llm_call: +# - command: "~/.hermes/agent-hooks/inject-cwd-context.sh" +# subagent_stop: +# - command: "~/.hermes/agent-hooks/log-orchestration.sh" +# +# hooks_auto_accept: false + + +# ============================================================================= +# Telemetry +# ============================================================================= +# Shared metrics are disabled by default. When enabled, Hermes writes only +# allowlisted aggregate counters and immutable JSON +# packages under $HERMES_HOME/telemetry/shared_metrics. +# Packages include a random profile-scoped ID that stays stable until this +# directory is deleted. It is not derived from hardware, account, or host data. +# Successfully exported local history is retained for 30 days; pending deltas +# are retained until they can be exported. +# This profile-owned choice is not overridden by managed-scope configuration. +# +# Nothing is uploaded unless you also set `send: true`. That is a separate +# opt-in and requires `enabled`; it never turns collection on by itself. +# When sending is on: +# * only packages whose entire collection period falls within one +# continuous recorded consent window are ever sent. Consent windows open +# when you enable sending and close when you disable it, so data +# collected before you opted in — or during any gap between opt-ins — +# stays on this machine; +# * each package carries the profile-scoped ID as-is. It is a random UUID +# with no hardware, account, or host-derived content, and deleting the +# shared-metrics directory resets it. +# See docs/observability/relay-shared-metrics.md (Appendix A) for the full +# consent, identity, retention, and deletion decisions. +telemetry: + shared_metrics: + enabled: false + send: false + # endpoint: https://telemetry.nousresearch.com/v1/telemetry + + +# ============================================================================= +# Update Behavior +# ============================================================================= +updates: + # Disable passive CLI/banner update checks with `hermes config set updates.check false`. + # Explicit `hermes update --check` and `hermes update` remain available. + check: true + # Create a full HERMES_HOME zip before every `hermes update`. + # Backups land in ~/.hermes/backups/ and can be restored with `hermes import`. + # Off by default because large homes can add minutes to every update. + pre_update_backup: false + + # Number of pre-update backup zips to retain. + backup_keep: 5 + + # What non-interactive updates do with local source edits in the Hermes repo. + # Interactive terminal updates always prompt before restoring the autostash. + # + # stash - auto-stash before pull, then auto-restore after success (default) + # discard - drop the update-created stash after success; use only on managed + # installs where local source edits should not persist + non_interactive_local_changes: "stash" + + +# ============================================================================= +# Web Dashboard +# ============================================================================= +# OAuth gate configuration for `hermes dashboard --host `. +# The bundled Nous Portal plugin reads these on startup; settings here are +# the canonical surface. Each can be overridden by an environment variable: +# +# dashboard.oauth.client_id <- HERMES_DASHBOARD_OAUTH_CLIENT_ID +# dashboard.oauth.portal_url <- HERMES_DASHBOARD_PORTAL_URL +# dashboard.public_url <- HERMES_DASHBOARD_PUBLIC_URL +# +# Env wins when set to a non-empty value. This is what Fly.io's platform- +# secret injection uses to push per-deploy client_ids without needing to +# bake a config.yaml into the image. Empty env values are treated as unset +# so a provisioned-but-not-populated secret can't shadow a valid entry here. +# +# Local dev / on-prem deploys should typically set these via config.yaml +# (the ~/.hermes/.env file is reserved for API keys and secrets). +# +# dashboard: +# oauth: +# client_id: "" # agent:{instance_id}; Portal provisions this at deploy +# portal_url: "" # blank → default https://portal.nousresearch.com +# +# # Force the absolute base URL the OAuth callback (and any other public +# # URL the dashboard hands to external systems) is built from. Set this +# # for deploys behind reverse proxies that don't reliably forward +# # X-Forwarded-Host / X-Forwarded-Proto / X-Forwarded-Prefix (manual +# # nginx setups, on-prem ingresses, custom-domain Fly deploys without +# # full proxy header chains). +# # +# # When set, the value is the complete authority: scheme + host + +# # optional path prefix (e.g. "https://example.com/hermes"). The OAuth +# # callback URL becomes "/auth/callback" — X-Forwarded-Prefix +# # is IGNORED on this code path because the operator has explicitly +# # declared the public URL and we no longer need to guess. +# # +# # Leave empty to use the existing proxy-header reconstruction (the +# # default — works on Fly.io out of the box). +# # +# # public_url: "https://example.com/hermes" +# # +# # Reverse proxies connecting from another container or host are not +# # trusted by default. Add only the proxy's exact IP address, or a bounded +# # CIDR for a dedicated proxy network, so X-Forwarded-Proto and +# # X-Forwarded-For can be honored. Loopback is always trusted. Wildcards +# # and /0 networks are rejected. +# # +# # trusted_proxies: +# # - "172.20.0.5" +# # # - "172.20.0.0/24" # dedicated network, if the IP is dynamic +# +# ----------------------------------------------------------------------------- +# Self-hosted OIDC dashboard auth (generic OpenID Connect — Authentik, +# Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …). Use this INSTEAD of the +# nous block above when gating the dashboard with your own identity provider. +# Each setting can be overridden by an environment variable: +# +# dashboard.oauth.self_hosted.issuer <- HERMES_DASHBOARD_OIDC_ISSUER +# dashboard.oauth.self_hosted.client_id <- HERMES_DASHBOARD_OIDC_CLIENT_ID +# dashboard.oauth.self_hosted.scopes <- HERMES_DASHBOARD_OIDC_SCOPES +# dashboard.oauth.self_hosted.client_secret <- HERMES_DASHBOARD_OIDC_CLIENT_SECRET +# +# dashboard: +# oauth: +# provider: self-hosted +# self_hosted: +# issuer: "https://auth.example.com/application/o/hermes/" # required +# client_id: "hermes-dashboard" # required +# scopes: "openid profile email" # optional +# +# # OPTIONAL — set ONLY if your IDP registered the client as +# # *confidential* (Authentik / Keycloak often default to this). When +# # set, Hermes authenticates the client at the token endpoint +# # (client_secret_basic or client_secret_post, auto-selected from the +# # IDP's discovery doc) IN ADDITION to PKCE. Leave unset for a public +# # (PKCE-only) client — the common case. +# # +# # This is a CREDENTIAL: prefer setting HERMES_DASHBOARD_OIDC_CLIENT_SECRET +# # in ~/.hermes/.env over putting it here in config.yaml. +# # client_secret: "" + +# ============================================================================= +# External secret sources +# ============================================================================= +# Pull provider credentials from external secret managers at process startup +# instead of storing them in ~/.hermes/.env. Only the manager's bootstrap +# credential (e.g. BWS_ACCESS_TOKEN / OP_SERVICE_ACCOUNT_TOKEN) lives in .env +# (or your shell / desktop session); everything else rotates centrally. +# Failures never block startup — Hermes warns once and continues with +# whatever .env already had. +# +# Multiple sources can be enabled at once: +# - "mapped" sources (explicit VAR -> ref bindings, e.g. 1Password's env: +# map) beat "bulk" sources (whole-project dumps like Bitwarden BSM) +# - within a shape, the first source to claim a var wins; later claims +# are skipped with a startup warning (never a silent clobber) +# - a source's override_existing lets it beat .env/shell values, but +# never another secret source's claim +# Docs: https://hermes-agent.nousresearch.com/docs/user-guide/secrets/ +# +# secrets: +# # Optional explicit ordering of enabled sources. +# # sources: [onepassword, bitwarden] +# +# # ---- Bitwarden Secrets Manager (bws CLI) -------------------------------- +# bitwarden: +# enabled: false +# access_token_env: BWS_ACCESS_TOKEN # bootstrap token, sourced from .env +# project_id: "" # UUID of the BSM project to sync +# server_url: "" # "" = US Cloud; EU/self-hosted URL otherwise +# cache_ttl_seconds: 300 # 0 disables fresh caching +# encrypted_cache: # optional encrypted stale fallback +# enabled: false +# max_stale_seconds: 0 # 0 disables stale fallback +# override_existing: true # BSM values win over existing env +# auto_install: true # lazy-download bws into ~/.hermes/bin +# +# # ---- 1Password (op CLI) ------------------------------------------------- +# onepassword: +# enabled: false +# # Map env-var names to op:// secret references. Each is resolved with a +# # single `op read` at startup. +# env: +# OPENAI_API_KEY: "op://Private/OpenAI/api key" +# ANTHROPIC_API_KEY: "op://Private/Anthropic/credential" +# account: "" # op --account shorthand; "" = default +# service_account_token_env: OP_SERVICE_ACCOUNT_TOKEN # headless auth; unset = desktop session +# binary_path: "" # "" = resolve op via PATH; else absolute path +# cache_ttl_seconds: 300 # 0 disables BOTH cache layers +# override_existing: true # resolved values win over existing env +# +# # ---- Command helper (any CLI vault) -------------------------------------- +# # Run a user-configured helper that prints KEY=VALUE lines on stdout — +# # works with any secret store that has a CLI: keepassxc-cli, secret-tool, +# # pass, gpg, or a script that cats a tmpfs env file. Composes with the +# # sources above (enable any combination). POSIX-only (needs /bin/sh). +# # The helper must be fast and NON-interactive (hard timeout, 1 MiB cap); +# # its stderr is discarded so diagnostics can't leak secret material. +# command: +# enabled: false +# command: "cat /run/user/1000/hermes-secrets.env" +# helper_timeout_seconds: 3 +# override_existing: false # .env/shell win by default +# # This runs ONCE per process at startup, so it cannot replace a credential +# # that expires mid-session. For a single provider whose token needs +# # re-minting during a session, use `providers..key_cmd` instead. diff --git a/constraints-termux.txt b/constraints-termux.txt new file mode 100644 index 0000000000000000000000000000000000000000..dcc1becf64abfcc3fb92723176bbb0b76b50030b --- /dev/null +++ b/constraints-termux.txt @@ -0,0 +1,15 @@ +# Termux / Android dependency constraints for Hermes Agent. +# +# Usage: +# python -m pip install -e '.[termux]' -c constraints-termux.txt +# +# These pins keep the tested Android install path stable when upstream packages +# move faster than Termux-compatible wheels / sdists. + +ipython<10 +jedi>=0.18.1,<0.20 +parso>=0.8.4,<0.9 +stack-data>=0.6,<0.7 +pexpect>4.3,<5 +matplotlib-inline>=0.1.7,<0.2 +asttokens>=2.1,<3 diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml new file mode 100644 index 0000000000000000000000000000000000000000..31362ddd973b0f9bdc598698a6866ca3ee3b24b5 --- /dev/null +++ b/docker-compose.windows.yml @@ -0,0 +1,38 @@ +# +# docker-compose.windows.yml — Windows Docker Desktop compatible +# +# Differences from docker-compose.yml: +# - Removes `network_mode: host` (not supported on Docker Desktop for Windows) +# - Uses explicit port mappings instead +# - Uses Windows-style volume path for ~/.hermes +# +# Usage: +# docker compose -f docker-compose.windows.yml up -d +# +services: + gateway: + image: nousresearch/hermes-agent:latest + container_name: hermes + restart: unless-stopped + volumes: + - ${USERPROFILE}/.hermes:/opt/data + environment: + - HERMES_UID=10000 + - HERMES_GID=10000 + command: ["gateway", "run"] + + dashboard: + image: nousresearch/hermes-agent:latest + container_name: hermes-dashboard + restart: unless-stopped + depends_on: + - gateway + volumes: + - ${USERPROFILE}/.hermes:/opt/data + environment: + - HERMES_UID=10000 + - HERMES_GID=10000 + - HERMES_DASHBOARD_HOST=0.0.0.0 + ports: + - "127.0.0.1:9119:9119" + command: ["dashboard", "--host", "0.0.0.0", "--port", "9119", "--no-open", "--insecure"] diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000000000000000000000000000000000..5e59b460ce6adaf44e726c9ad57e8d491035543c --- /dev/null +++ b/flake.lock @@ -0,0 +1,162 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1782949081, + "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1786487128, + "narHash": "sha256-ad60hrRVhH/bo3Jl1YLzO+QcS3mUNZr9LNJdzhMO2P4=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "f404edbfa4117810c96b97048299242fc50e5362", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1785318670, + "narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "npm-lockfile-fix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1775903712, + "narHash": "sha256-2GV79U6iVH4gKAPWYrxUReB0S41ty/Y3dBLquU8AlaA=", + "owner": "jeslie0", + "repo": "npm-lockfile-fix", + "rev": "c6093acb0c0548e0f9b8b3d82918823721930fe8", + "type": "github" + }, + "original": { + "owner": "jeslie0", + "repo": "npm-lockfile-fix", + "type": "github" + } + }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ], + "uv2nix": [ + "uv2nix" + ] + }, + "locked": { + "lastModified": 1785115949, + "narHash": "sha256-8AM37BfyGaL2v/SZyg4PupRxJ01Y4htvM+WrTjWrPpo=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "62c0d86027edb1c4f39a5facc09876348144f7c9", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1784591072, + "narHash": "sha256-zP/WaDxrRu8GANZM61+V2LT/7ycEEdoyLWn7M6WzU7M=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "e3b599ca2e7fcf93d4edf65d7f19bbf6491724f3", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-parts": "flake-parts", + "home-manager": "home-manager", + "nixpkgs": "nixpkgs", + "npm-lockfile-fix": "npm-lockfile-fix", + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "uv2nix": "uv2nix" + } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1785277507, + "narHash": "sha256-9Tq3UDX2hD/aveW/HvkBlAmEwJTOlY5HQXJM+L5BGmE=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "5a836d395cbf5fc22670eb98dd4aa4fc4d406977", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/hermes_bootstrap.py b/hermes_bootstrap.py new file mode 100644 index 0000000000000000000000000000000000000000..38913cf2411a7896d53c62fa2cb477dd7db85319 --- /dev/null +++ b/hermes_bootstrap.py @@ -0,0 +1,119 @@ +"""Windows UTF-8 bootstrap for Hermes entry points (no-op on POSIX). + +Windows binds stdio to the console code page (cp1252), so ``print("café")`` raises +``UnicodeEncodeError``, and Python children inherit the same default unless +``PYTHONUTF8``/``PYTHONIOENCODING`` are set. Import this module first in every entry +point (``hermes``, ``hermes-agent``, ``hermes-acp``, ``gateway.run``, ``batch_runner``, +``cron/scheduler``). It does NOT re-exec with ``-X utf8``: ``open()`` in the current +process still needs an explicit ``encoding="utf-8"`` (ruff ``PLW1514``). POSIX is left +alone deliberately — users' ``LANG``/``LC_*`` choices are respected. +""" + +from __future__ import annotations + +import os +import sys + +_IS_WINDOWS = sys.platform == "win32" +_bootstrap_applied = False + + +def apply_windows_utf8_bootstrap() -> bool: + """Apply the Windows UTF-8 bootstrap once; True only when it was applied this call.""" + global _bootstrap_applied + + if not _IS_WINDOWS or _bootstrap_applied: + return False + + # setdefault() so a user can opt out with PYTHONUTF8=0 / PYTHONIOENCODING=... + os.environ.setdefault("PYTHONUTF8", "1") + os.environ.setdefault("PYTHONIOENCODING", "utf-8") + + # os.environ changes don't rebind streams bound at interpreter startup, so + # reconfigure them in-process. errors="replace" keeps a non-UTF-8 legacy + # pipe on stdin from crashing us (U+FFFD instead of an exception). + # Non-TextIOWrapper streams (BytesIO in tests, embedded hosts) have no + # reconfigure(): skip — the env-var fix for children is the bigger win. + for stream_name in ("stdout", "stderr", "stdin"): + reconfigure = getattr(getattr(sys, stream_name, None), "reconfigure", None) + if reconfigure is None: + continue + try: + reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError): + pass # closed, or replaced with something non-reconfigurable + + _bootstrap_applied = True + return True + + +def suppress_platform_ver_console() -> None: + """Stub ``platform._syscmd_ver`` on Windows — decode-crash + console-flash guard. + + ``platform.win32_ver()`` (reached via ``platform.platform()``, which the OpenAI SDK + calls) shells out ``cmd /c ver`` with ``shell=True`` and no ``CREATE_NO_WINDOW``: a + windowless parent (pythonw gateway, slash/kanban workers) flashes a console per call, + and Python 3.11.0/3.11.1 (no ``encoding="locale"`` fix) strict-utf-8-decodes the OEM + code page output under PEP 540 mode and raises (#69413). Returning the inputs makes + ``win32_ver()`` fall back to ``sys.getwindowsversion()`` — same data, no subprocess. + Mirrors ``hermes_cli._subprocess_compat.suppress_platform_ver_console`` for callers + that never import ``hermes_cli.main``; double application is harmless. + """ + if not _IS_WINDOWS: + return + try: + import platform + + if hasattr(platform, "_syscmd_ver"): + def _quiet_syscmd_ver(system="", release="", version="", + supported_platforms=("win32", "win16", "dos")): + return system, release, version + + platform._syscmd_ver = _quiet_syscmd_ver + except Exception: + pass # hardening only — never break an entry point + + +def harden_import_path(src_root: str | None = None) -> None: + """Stop a package in the current directory from shadowing Hermes modules. + + Hermes ships top-level modules with common names (``utils``, ``proxy``, ``ui``); a + project with its own ``utils/`` launched from its directory would win the import. + The cwd reaches ``sys.path`` as ``""``/``"."`` (script/``-m`` launches) AND as an + absolute path (venv activation, PYTHONPATH), so both are handled: relative forms are + dropped and the Hermes root is *relocated* to the front, not merely inserted when + absent. ``src_root`` defaults to this module's directory (the repo root for every + shipped entry point), so no spawner env var is required. + """ + root = src_root or os.environ.get("HERMES_PYTHON_SRC_ROOT") or os.path.dirname( + os.path.abspath(__file__) + ) + + sys.path[:] = [p for p in sys.path if p not in ("", ".")] + + root_abs = os.path.abspath(root) + sys.path[:] = [p for p in sys.path if os.path.abspath(p) != root_abs] + sys.path.insert(0, root) + + +def activate_durable_lazy_target() -> None: + """Put the durable lazy-install dir (``HERMES_LAZY_INSTALL_TARGET``) on ``sys.path``. + + Immutable Docker images seal the venv and redirect lazy installs to the data volume; + packages installed there on a previous run must be importable before any backend + imports its SDK. Appends to the END of ``sys.path`` so the core venv always wins name + collisions (see ``tools.lazy_deps``). Never raises; unset target is a no-op. + """ + if not os.environ.get("HERMES_LAZY_INSTALL_TARGET", "").strip(): + return + try: + from tools import lazy_deps + lazy_deps.activate_durable_lazy_target() + except Exception: + pass # a failed activation just leaves the backend reporting itself unavailable + + +# Apply on import — entry points only need ``import hermes_bootstrap`` first. +apply_windows_utf8_bootstrap() +suppress_platform_ver_console() +activate_durable_lazy_target() diff --git a/hermes_cli/_scan_venv_blockers.py b/hermes_cli/_scan_venv_blockers.py new file mode 100644 index 0000000000000000000000000000000000000000..eb60c79222334f792907395ede98575b903677cd --- /dev/null +++ b/hermes_cli/_scan_venv_blockers.py @@ -0,0 +1,321 @@ +"""Standalone venv-process scan for JSON consumption (``python -m hermes_cli._scan_venv_blockers``). + +Exits 0 for valid clear or blocked results. Non-zero exit signals probe failure (the detector itself +crashed, psutil unavailable, etc.). Exactly one JSON document on stdout; diagnostics on stderr only. +""" + +from __future__ import annotations + +import json +import math +import sys +from pathlib import PureWindowsPath +from typing import NoReturn + +# Long CLI flags whose argument value must be redacted from the cmdline. Short flags (-t, -k, -p) +# are intentionally not redacted — ambiguous and useful diagnostics (toolset, port, profile). +_SENSITIVE_LONG_FLAGS: list[str] = [ + "--token", "--api-key", "--password", "--secret", "--authorization", "--access-key", + "--private-key", "--session-key", +] + +_PYTHON_PROCESS_NAMES = {"python.exe", "pythonw.exe", "python", "pythonw"} +_UPDATER_STOPPABLE_PURPOSES = ("serve", "dashboard") + + +def _probe_fail_json(diagnostic: str = "probe failed") -> str: + """The standard probe-failure JSON document. + + ``ok: false`` plus ``probe_failed: true`` means the detector itself could not run — this is + *not* a clear scan. Callers must treat ``ok is not True`` / non-zero exit as probe failure, + never as ``blocked: false`` "clear". + + See #83149. + """ + return json.dumps({"ok": False, "probe_failed": True, "blocked": False, "processes": [], + "error": diagnostic}) + + +def _emit_probe_fail(diagnostic: str) -> NoReturn: + """Print one JSON to stdout, diagnostic to stderr, exit non-zero.""" + print(_probe_fail_json(diagnostic)) + print(diagnostic, file=sys.stderr) + sys.exit(1) + + +def _find_flag(text: str, flag: str) -> int: + """Index of *flag* (case-insensitive) at string start or after a space; -1 if absent.""" + low, fl, pos = text.lower(), flag.lower(), 0 + while True: + idx = low.find(fl, pos) + if idx == -1 or idx == 0 or text[idx - 1] == " ": + return idx + pos = idx + 1 + + +def _redact_sensitive_cmdline(cmdline: str) -> str: + """Apply the shared secret redactor, then replace everything after a sensitive long flag.""" + try: + from agent.redact import redact_sensitive_text # noqa: PLC0415 + + cmdline = redact_sensitive_text(cmdline, force=True) + except Exception: + return "" + # --flag=value → preserve "--flag="; --flag value → preserve "--flag ". + earliest = len(cmdline) + for flag in _SENSITIVE_LONG_FLAGS: + for suffix in ("=", " "): + idx = _find_flag(cmdline, flag + suffix) + if idx != -1 and idx + len(flag) + 1 < earliest: + earliest = idx + len(flag) + 1 + if earliest < len(cmdline): + return cmdline[:earliest] + "" + return cmdline + + +def _classify_local_preview_args(args: object) -> dict[str, object]: + """Safe UI metadata for an exact ``python -m http.server`` argv; ``{}`` otherwise. + + The general holder detector truncates its diagnostic command line; reading argv separately + preserves a useful directory label without exposing an unbounded command line to the renderer. + """ + if not isinstance(args, (list, tuple)) or not all(isinstance(arg, str) for arg in args): + return {} + # ``-m`` must be the first argument after the executable: a later ``-m http.server`` can be + # data passed to an unrelated script and must never authorize termination. + if len(args) < 3 or args[1] != "-m" or args[2].lower() != "http.server": + return {} + port = 8000 + if len(args) > 3 and args[3].isdigit() and 0 < int(args[3]) <= 65535: + port = int(args[3]) + label = "" + try: + directory_index = args.index("--directory") + if directory_index + 1 < len(args): + label = PureWindowsPath(args[directory_index + 1]).name + except ValueError: + pass + metadata: dict[str, object] = {"kind": "local-preview", "safeToStop": True, "port": port} + if label: + metadata["label"] = label + return metadata + + +def _local_preview_metadata(pid: int, name: str) -> dict[str, object]: + if name.lower() not in _PYTHON_PROCESS_NAMES: + return {} + try: + import psutil # noqa: PLC0415 + + process = psutil.Process(pid) + metadata = _classify_local_preview_args(process.cmdline()) + if metadata: + metadata["createTime"] = process.create_time() + return metadata + except Exception: + return {} + + +def _terminate_safe_preview( + pid: int, expected_create_time: float, *, psutil_module: object | None = None, +) -> tuple[bool, str | None]: + """Terminate one verified local preview process tree. + + A fresh ``psutil.Process`` identity check and exact argv classification occur immediately before + termination; psutil guards mutating Process methods against PID reuse (no taskkill stale-PID + race). + """ + try: + if psutil_module is None: + import psutil as psutil_module # type: ignore[no-redef] # noqa: PLC0415 + + process = psutil_module.Process(pid) # type: ignore[attr-defined] + if abs(process.create_time() - expected_create_time) > 0.001: + return False, "process identity changed" + if not _classify_local_preview_args(process.cmdline()): + return False, "process is no longer a local preview" + targets = [*reversed(process.children(recursive=True)), process] + for target in targets: + target.terminate() + _gone, alive = psutil_module.wait_procs(targets, timeout=3) # type: ignore[attr-defined] + for target in alive: + target.kill() + if alive: + psutil_module.wait_procs(alive, timeout=2) # type: ignore[attr-defined] + return True, None + except Exception as exc: + return False, f"termination failed: {type(exc).__name__}" + + +def _is_pausable_gateway(cmdline: str) -> bool: + """True when *cmdline* is a gateway process the updater can pause. + + Only gateway invocations are exempted; anything else running from the venv has no pause + machinery downstream and must keep blocking the handoff. Delegates to the canonical + ``gateway run`` matcher so this exemption, pause discovery and the updater's guard share one + parser. + """ + try: + from gateway.status import looks_like_gateway_command_line # noqa: PLC0415 + except Exception: + return False + return looks_like_gateway_command_line(cmdline) + + +def _is_updater_owned_backend(pid: int, cmdline: str) -> bool: + """True when *pid* is a Hermes backend the CLI updater can stop (positive ledger identity). + + The gateway exemption above keeps ``gateway run`` holders out of the blocker list because the updater's + own pause machinery stops and resumes them. ``hermes serve`` / ``hermes dashboard`` backends had no such + deferral, so a leaked serve child (or a Desktop-owned backend the teardown lost track of) dead-ended the + hand-off with ``venv-blocked`` — or, worse, survived the hand-off and made the shim quarantine fail with + ``os error 32`` (#98336) — even though the updater downstream owns exactly this case with its ledger + rungs (`_ledger_reapable_backend_pids` reaps dead-spawner orphans; `_ledger_manual_serve_holders` stops + manual serves and relaunches them on their recorded host/port). + Positive identity only — never name/substring matching (#90778, and the 99558 identity-guard contract): + """ + return _updater_owned_backend_entry(pid, cmdline) is not None + + +def _updater_owned_backend_entry(pid: int, cmdline: str) -> dict | None: + """Ledger entry for a deferred serve/dashboard backend, or ``None`` when it must block. + + Returning the entry lets ``main()`` emit sanitized decision evidence — structured identity + fields only, never argv, which can carry tokens or private endpoints. + + See #98350. + """ + try: + from hermes_cli.update_cmd import _hermes_holder_subcommand # noqa: PLC0415 + + purpose = _hermes_holder_subcommand(cmdline) + except Exception: + return None + if purpose not in _UPDATER_STOPPABLE_PURPOSES: + return None + try: + from hermes_cli.process_identity import ledger_entries, spawner_is_dead # noqa: PLC0415 + + entries = ledger_entries() + except Exception: + return None + for entry in entries: + if entry.get("pid") != pid: + continue + if entry.get("purpose") not in _UPDATER_STOPPABLE_PURPOSES: + return None + # Spawner dead, unrecorded, or unprovable-but-registered: the updater's ledger rungs own + # this holder (reap or stop+relaunch). + if spawner_is_dead(entry) is not False or _spawner_is_this_handoff_desktop(entry): + return entry + return None + return None + + +def _deferred_backend_evidence(entries: list[dict]) -> list[dict]: + """Sanitized evidence (pid, purpose, recorded port — never argv) for deferred backends. + + Structured ledger fields only — pid, purpose, recorded port — never the command line, which can carry + tokens or private endpoints. Lets the scan result explain *why* a holder disappeared from ``processes`` + without echoing argv (#98350). + """ + return [{"pid": entry.get("pid"), "purpose": entry.get("purpose"), "port": entry.get("port")} + for entry in entries if isinstance(entry.get("pid"), int)] + + +def _spawner_is_this_handoff_desktop(entry: dict) -> bool: + """True when the entry's live spawner is an ancestor of this scan. + + The scan is spawned by the Desktop app's update preflight, so the Desktop performing the + hand-off is in our ancestor chain. Identity is ``(pid, create_time)`` — a recycled PID cannot + forge the pair. + """ + spawner_pid = entry.get("spawner_pid") + if not isinstance(spawner_pid, int) or spawner_pid <= 0: + return False + try: + import psutil # noqa: PLC0415 + + for ancestor in psutil.Process().parents(): + if ancestor.pid != spawner_pid: + continue + expected = entry.get("spawner_create") + if expected is None: + return True + return abs(float(ancestor.create_time()) - float(expected)) < 2.0 + except Exception: + return False + return False + + +def main() -> None: + """Entry point. Prints one JSON doc to stdout. Exits 0 for valid scan.""" + try: + import psutil # noqa: PLC0415, F401 + except Exception as exc: + _emit_probe_fail(f"psutil is not available: {exc}") + try: + from hermes_cli.update_cmd import _detect_venv_python_processes + + matches = _detect_venv_python_processes() + except Exception as exc: + _emit_probe_fail(f"scan aborted: {exc}") + + processes = [] + exempted_gateways = 0 + deferred_entries: list[dict] = [] + for pid, name, cmdline in matches: + if _is_pausable_gateway(cmdline): + exempted_gateways += 1 + continue + deferred_entry = _updater_owned_backend_entry(pid, cmdline) + if deferred_entry is not None: + # Ledger-verified backend the updater's own rungs stop (and relaunch) downstream — + # reporting it here would dead-end the hand-off before that machinery can run. + # See #98336. + deferred_entries.append(deferred_entry) + continue + # Truncate for display AFTER the gateway exemption has seen the full cmdline (long + # managed-runtime interpreter paths would otherwise swallow the `gateway run` argv). + process = {"pid": pid, "name": name, "cmdline": _redact_sensitive_cmdline(cmdline)[:120]} + process.update(_local_preview_metadata(pid, name)) + processes.append(process) + + # pausable_gateways / deferred_backends / deferred_backend_evidence are diagnostic only. + data = { + "ok": True, + "blocked": bool(processes), + "processes": processes, + "pausable_gateways": exempted_gateways, + # Diagnostic only: ledger-verified serve/dashboard backends deferred to the updater's stop/relaunch + # rungs (#98336). + "deferred_backends": len(deferred_entries), + # Diagnostic only: sanitized evidence (structured ledger identity, never argv) explaining which + # holders the deferral consumed (#98350). + "deferred_backend_evidence": _deferred_backend_evidence(deferred_entries), + } + print(json.dumps(data)) + sys.exit(0) + + +def _terminate_safe_main(argv: list[str]) -> NoReturn: + if len(argv) != 2: + print(json.dumps({"ok": False, "error": "expected pid and create time"})) + raise SystemExit(2) + try: + pid = int(argv[0]) + create_time = float(argv[1]) + if pid <= 0 or not math.isfinite(create_time) or create_time <= 0: + raise ValueError + except ValueError: + print(json.dumps({"ok": False, "error": "invalid process identity"})) + raise SystemExit(2) + stopped, error = _terminate_safe_preview(pid, create_time) + print(json.dumps({"ok": stopped, "pid": pid, "error": error})) + raise SystemExit(0 if stopped else 1) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--terminate-safe": + _terminate_safe_main(sys.argv[2:]) + main() diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..6cb01493f9c1b7a2cd5560037204287f743ebabb --- /dev/null +++ b/hermes_cli/anon_auth.py @@ -0,0 +1,869 @@ +"""Nous free-tier identity: the ``anonymous`` auth method of the ``nous`` provider. + +The identity is created in exactly one place, at boot (``hermes_cli.free_tier_bootstrap``), and only +while ``HERMES_GUEST_ONBOARDING=1`` (see ``guest_enabled``). The bootstrap mints an anonymous Nous +account (``POST /api/anonymous/create``); its ``anon_`` credential is later exchanged for short-lived +JWTs (``POST /api/anonymous/token``). The result is persisted as the singleton ``providers.nous``; it +becomes ``active_provider`` only when the bootstrap's inventory found nothing else usable, so an +install with its own key keeps that key for inference and uses the identity for connectors only. In +the resolver ladder (``resolve_provider``) an existing free-tier identity sits directly above the +implicit AWS Bedrock chain (NS-829): any explicit provider (env key, ``model.provider``, OpenRouter +pool, a logged-in ``active_provider``) beats it, and the ladder never creates one. + +Only two mechanics differ from an OAuth login and both are isolated behind ``is_guest_state``: +token acquisition (re-exchange the ``anon_`` credential; there is no refresh token) and routing +(the welcome inference host, single model ``nous/welcome``). + +Users are never shown the words guest / anonymous / account for this state: surfaces say +"Nous · free tier". Two user-facing verbs reach the same flow, both keeping the identity's +connectors: ``hermes auth upgrade`` in a terminal and ``/login`` inside a chat. + +Lifecycle lives in ONE primitive, :func:`ensure_portal_identity`: adopt what the shared store already +holds, else mint under the shared-store lock. It is the only minter; nothing else calls +:func:`mint_guest`. +""" + +from __future__ import annotations + +import logging +import os +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Callable, Dict, Optional + +from agent.retry_utils import parse_retry_after_seconds +from hermes_cli.auth_constants import ( + AuthError, DEFAULT_NOUS_PORTAL_URL, DEFAULT_NOUS_WELCOME_URL, _decode_jwt_claims, httpx) + +logger = logging.getLogger("hermes_cli.auth") + +ANON_AUTH_METHOD = "anonymous" +ANON_CLIENT_ID = "nas-anonymous" +ANON_ACCOUNT_TIER = "anonymous" +GUEST_MODEL = "nous/welcome" +ANON_SECRET_HEADER = "x-anonymous-api-secret" +# The shared secret gates the anonymous surface during its integration phase. It is a deployment +# secret (Sid's), read from the environment only. +ANON_SECRET_ENV = "HERMES_ANON_API_SECRET" +# Launch gate for the whole free tier while it is pre-GA: exactly "1" turns it on for this process +# (CLI, gateway, serve backend alike); anything else leaves every surface behaving as if the free +# tier did not exist. ``guest_enabled`` is the only reader. Not a user preference: never written to +# config.yaml or .env, never shown in setup. Deleted at GA together with this comment. +GUEST_ONBOARDING_ENV = "HERMES_GUEST_ONBOARDING" +GUEST_MINT_TIMEOUT_SECONDS = 5.0 +# Copy shared by every surface that names the free tier (R-USR-1): never guest / anonymous / account. +FREE_TIER_LABEL = "Nous · free tier" +UPGRADE_HINT = "Run `hermes auth upgrade` to sign in with a Nous account, or /login inside a chat." +FREE_TIER_NOT_SIGNED_IN = ( + "You're not signed in. Free inference and connectors are always on. " + "Run `hermes auth` to sign in with a Nous account.") + + +class AnonCredentialDead(AuthError): + """NAS no longer knows this ``anon_`` credential (reaped, or claimed into a real account). + + The one client rule for reap AND claim: mark dead, re-mint on the next need. + """ + + +def _anon_err(message: str, code: str) -> AuthError: + return AuthError(message, code=code) + + +def guest_enabled() -> bool: + """The free tier is on for this process: the launch gate is set AND ``nous.guest`` (default + True) has not switched it off. The only place either is read.""" + if (os.environ.get(GUEST_ONBOARDING_ENV) or "").strip() != "1": + return False + try: + from hermes_cli.config import load_config_readonly + nous_cfg = load_config_readonly().get("nous") + except Exception as exc: # config unreadable: keep today's behaviour (no guest) rather than mint + logger.debug("guest: config unreadable, treating nous.guest as false: %s", exc) + return False + if not isinstance(nous_cfg, dict): + return True + return bool(nous_cfg.get("guest", True)) + + +def is_guest_state(state: Any) -> bool: + return isinstance(state, dict) and state.get("auth_method") == ANON_AUTH_METHOD + + +def current_nous_state() -> Optional[Dict[str, Any]]: + """The profile's ``providers.nous`` state without locking or network (status/picker reads).""" + from hermes_cli.auth import _load_auth_store, _load_provider_state + try: + return _load_provider_state(_load_auth_store(), "nous") + except Exception as exc: + logger.debug("guest: auth store unreadable: %s", exc) + return None + + +def has_guest() -> bool: + return is_guest_state(current_nous_state()) + + +def guest_carries_inference() -> bool: + """True when the profile's Nous identity is the free tier and the free tier is on. + + Profile-level: use for status, picker and notice surfaces. Routing decisions (which model a + request may carry) must use :func:`route_is_welcome_host` on the SELECTED runtime instead: a + credential-pool entry can pick a paid Nous key while the profile singleton is still a guest. + """ + return guest_enabled() and has_guest() + + +WELCOME_HOSTS = frozenset({"welcome-api.nousresearch.com"}) + + +def pin_model_for_route(provider: Any, base_url: Any, model: Any) -> Any: + """Model policy at agent START: on the Nous welcome host the model is ``nous/welcome``; anywhere + else the caller's model stands. Used once, when the route is first finalized. Mid-conversation + route changes go through :func:`route_can_serve_model` instead: a conversation's model is never + silently rewritten by a credential rotation. + """ + if provider == "nous" and route_is_welcome_host(base_url): + if model and model != GUEST_MODEL: + logger.info("Nous free tier: using %s instead of configured model %s", GUEST_MODEL, model) + return GUEST_MODEL + return model + + +def route_can_serve_model(provider: Any, base_url: Any, model: Any) -> bool: + """Eligibility for a credential ROTATION: the welcome host serves only ``nous/welcome``, so a + conversation on any other model must not be rotated onto it (and a ``nous/welcome`` conversation + may move to the portal host, which serves it too). Non-Nous routes are always eligible.""" + if provider != "nous" or not route_is_welcome_host(base_url): + return True + return not model or model == GUEST_MODEL + + +def route_is_welcome_host(base_url: Any) -> bool: + """The routing predicate for the free tier: the welcome host serves exactly ``nous/welcome``. + + Keyed on the resolved endpoint, never on profile state, so a paid pool credential routed to the + portal host keeps its model even when a guest singleton exists beside it. + """ + from urllib.parse import urlparse + try: + host = (urlparse(str(base_url or "")).hostname or "").lower() + except ValueError: + return False + return host in WELCOME_HOSTS + + +def anon_secret() -> str: + return (os.environ.get(ANON_SECRET_ENV) or "").strip() + + +def _anon_headers() -> Dict[str, str]: + headers = {"content-type": "application/json"} + if secret := anon_secret(): + headers[ANON_SECRET_HEADER] = secret + return headers + + +def _raise_for_anon_status(response: httpx.Response, *, action: str) -> Dict[str, Any]: + try: + payload = response.json() + except ValueError: + payload = {} + if not isinstance(payload, dict): + payload = {} + error = str(payload.get("error") or "") + if response.status_code in (200, 201): + return payload + if response.status_code == 404 and error == "unknown_token": + raise AnonCredentialDead("Nous free-tier credential is no longer valid.", code="anon_credential_dead") + if response.status_code == 401 and error == "invalid_shared_secret": + raise _anon_err("Nous free tier is not open on this portal.", "anon_gate_closed") + if response.status_code == 401: + raise AnonCredentialDead("Nous free-tier credential was revoked.", code="anon_credential_dead") + if response.status_code == 429: + raise _anon_err("Nous free tier is rate limited; try again shortly.", "anon_rate_limited") + if response.status_code == 403 and error in {"anonymous_accounts_disabled", "circuit_open"}: + raise _anon_err("Nous free tier is currently disabled.", "anon_gate_closed") + raise _anon_err( + f"Nous free tier {action} failed ({response.status_code}{': ' + error if error else ''}).", + "anon_server_error") + + +def mint_guest(client: httpx.Client, portal_base_url: str) -> Dict[str, Any]: + """``POST /api/anonymous/create`` -> ``{user_id, org_id, token, idle_ttl_days}``. Token shown once.""" + response = client.post(f"{portal_base_url.rstrip('/')}/api/anonymous/create", headers=_anon_headers(), json={}) + payload = _raise_for_anon_status(response, action="sign-up") + token = payload.get("token") + if not isinstance(token, str) or not token.startswith("anon_"): + raise _anon_err("Nous free tier sign-up returned no credential.", "anon_server_error") + return payload + + +def exchange_anon_jwt(client: httpx.Client, portal_base_url: str, anon_token: str) -> Dict[str, Any]: + """``POST /api/anonymous/token {token}`` -> ``{access_token, expires_in, inference_base_url, ...}``. + + Raises :class:`AnonCredentialDead` on 404 ``unknown_token`` / 401 (reaped or claimed). + """ + response = client.post( + f"{portal_base_url.rstrip('/')}/api/anonymous/token", headers=_anon_headers(), json={"token": anon_token}) + payload = _raise_for_anon_status(response, action="token exchange") + if not isinstance(payload.get("access_token"), str) or not payload["access_token"]: + raise _anon_err("Nous free tier token exchange returned no token.", "anon_server_error") + return payload + + +def apply_exchange_to_state(state: Dict[str, Any], exchanged: Dict[str, Any]) -> None: + """Write a fresh exchange result into a guest state in place (token, expiry, routing).""" + from hermes_cli.auth_nous import _validate_nous_inference_url_from_network + access_token = exchanged["access_token"] + claims = _decode_jwt_claims(access_token) + now = datetime.now(timezone.utc) + exp = claims.get("exp") + if isinstance(exp, (int, float)): + expires_at = datetime.fromtimestamp(float(exp), tz=timezone.utc) + else: + expires_at = now + timedelta(seconds=int(exchanged.get("expires_in") or 900)) + # NAS names the welcome host on every exchange; absent (older NAS) or outside the allowlist + # (a staging host without NOUS_INFERENCE_BASE_URL set), the literal stands in. Never the paid + # host: the gateway cross-refuses an anonymous JWT there. + inference_url = (_validate_nous_inference_url_from_network(exchanged.get("inference_base_url")) + or DEFAULT_NOUS_WELCOME_URL) + scope = claims.get("scope") or claims.get("scp") or state.get("scope") + if isinstance(scope, (list, tuple)): + scope = " ".join(str(s) for s in scope) + state.update( + access_token=access_token, token_type="Bearer", scope=scope, + obtained_at=now.isoformat(), expires_at=expires_at.isoformat(), + expires_in=max(0, int((expires_at - now).total_seconds())), + account_tier=str(claims.get("account_tier") or ANON_ACCOUNT_TIER)) + state["inference_base_url"] = inference_url + for key in ("user_id", "org_id"): + if exchanged.get(key): + state[key] = exchanged[key] + state.pop("refresh_token", None) + + +def _portal_base_url() -> str: + from hermes_cli.auth_nous import _nous_portal_env_override + return (_nous_portal_env_override() or DEFAULT_NOUS_PORTAL_URL).rstrip("/") + + +def _shared_identity_key(state: Any) -> Optional[str]: + """Stable identity of a Nous credential: the anon_ token for a guest, the refresh token for an + account. Used to decide whether two stores hold the SAME identity.""" + if not isinstance(state, dict): + return None + return state.get("anon_token") if is_guest_state(state) else state.get("refresh_token") + + +def _mint_locked( + client: httpx.Client, portal: str, auth_store: Dict[str, Any], *, carries_inference: bool = True, +) -> Dict[str, Any]: + """Mint under the caller's locks. The identity is persisted as soon as ``create`` succeeds, BEFORE + the exchange: a 429 or timeout on the exchange must not lose a credential NAS still honours (the + next attempt exchanges the stored one instead of minting again). + + ``carries_inference`` decides whether the new identity also becomes ``active_provider``. The + bootstrap passes False when its inventory found another usable provider: the identity exists for + connectors, the user's own provider keeps carrying inference (NS-845 Q1.3).""" + from hermes_cli.auth import _store_provider_state, _save_auth_store + from hermes_cli.auth_nous import _write_shared_nous_state + minted = mint_guest(client, portal) + state: Dict[str, Any] = { + "auth_method": ANON_AUTH_METHOD, "account_tier": ANON_ACCOUNT_TIER, + "anon_token": minted["token"], "client_id": ANON_CLIENT_ID, + "portal_base_url": portal.rstrip("/"), + "user_id": minted.get("user_id"), "org_id": minted.get("org_id"), + "idle_ttl_days": minted.get("idle_ttl_days"), + } + _store_provider_state(auth_store, "nous", state, set_active=carries_inference) + _save_auth_store(auth_store) + _write_shared_nous_state(state) + logger.info("Nous free tier ready (identity minted)") + return state + + +# Per-process memo: one failed mint is enough for a process (a 429 or a closed gate must not be hit +# twice); ``clear_dead_guest`` resets it because a retired credential is a reason to mint again. +# The bool is the unscoped (launch profile) slot; routed multiplex profiles each get their own entry +# in the set — profile A's 429 must not stop profile B from ever getting an identity. +_mint_failed = False +_mint_failed_homes: set[str] = set() + + +def _mint_failed_for_profile() -> bool: + from hermes_constants import get_hermes_home_override, hermes_home_key + if get_hermes_home_override() is None: + return _mint_failed + return hermes_home_key() in _mint_failed_homes + + +def _set_mint_failed(failed: bool) -> None: + global _mint_failed + from hermes_constants import get_hermes_home_override, hermes_home_key + if get_hermes_home_override() is None: + _mint_failed = failed + elif failed: + _mint_failed_homes.add(hermes_home_key()) + else: + _mint_failed_homes.discard(hermes_home_key()) + + +def _reconcile_and_provision(*, timeout_seconds: float, carries_inference: bool = True) -> Optional[Dict[str, Any]]: + """The lifecycle body, run under profile lock THEN shared lock (the documented order). + + 1. The shared store is the identity of record for this Hermes root. If it holds an identity + that differs from the profile's, the profile adopts it (a stale guest never outlives a + sibling profile's sign-in, and never overwrites it). An adopted free-tier identity claims + ``active_provider`` under the same rule as a mint; an adopted ACCOUNT always does (the user + signed in somewhere on this machine). + 2. Otherwise the profile's own identity stands. + 3. Nothing anywhere: mint, persisting the credential before exchanging it. + """ + from hermes_cli.auth import ( + _auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store, + _store_provider_state, _resolve_verify) + from hermes_cli.auth_nous import ( + _nous_http_client, _nous_shared_store_lock, _read_shared_nous_state, _write_shared_nous_state) + portal = _portal_base_url() + with _auth_store_lock(): + auth_store = _load_auth_store() + profile_state = _load_provider_state(auth_store, "nous") + with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds, 5.0)): + shared = _read_shared_nous_state() + if shared and _shared_identity_key(shared) != _shared_identity_key(profile_state): + state = dict(shared) + _store_provider_state( + auth_store, "nous", state, + set_active=carries_inference or not is_guest_state(state)) + _save_auth_store(auth_store) + logger.debug("Nous identity adopted from the shared store") + return state + if profile_state: + if not shared: + _write_shared_nous_state(profile_state) + return profile_state + verify = _resolve_verify(insecure=None, ca_bundle=None, auth_state=None) + with _nous_http_client(timeout_seconds, verify) as client: + return _mint_locked(client, portal, auth_store, carries_inference=carries_inference) + + +def ensure_portal_identity( + *, explicit: bool, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS, + carries_inference: bool = True, +) -> Optional[Dict[str, Any]]: + """Make sure this profile has a Nous identity (guest or account); mint a guest only if the shared + store has none. Returns the ``providers.nous`` state, or None (disabled / failed once already). + + ``explicit`` is required and must be True: the only callers are the boot bootstrap + (``free_tier_bootstrap.run_bootstrap``), the desktop's ``free_tier.provision`` retry, and the + dead-credential replacements (``auth_nous.resolve_nous_runtime_credentials``, + ``managed_tool_gateway._replace_dead_guest_token``). Nothing creates an identity as a side effect + of reading status, resolving a provider or fetching a connector bearer (NS-845 Q1.2). + + Order: ``guest_enabled`` gate -> reconcile with the shared store -> mint. Locks are taken profile + first, then shared, matching every other Nous path. ``carries_inference=False`` leaves + ``active_provider`` alone (the identity is for connectors; another provider does inference). + Blocking, bounded by ``timeout_seconds``; the bootstrap puts it on its own thread. + """ + if not explicit: + raise ValueError("ensure_portal_identity: only explicit creators may call this (explicit=True)") + if not guest_enabled(): + return None + if _mint_failed_for_profile() and not current_nous_state(): + return None # this profile already tried and failed in this process; do not hammer the portal + try: + return _reconcile_and_provision( + timeout_seconds=timeout_seconds, carries_inference=carries_inference) + except Exception: + _set_mint_failed(True) + raise + + +def refresh_guest_state(state: Dict[str, Any], client: httpx.Client) -> None: + """Token-acquisition seam for a guest: re-exchange the ``anon_`` credential in place. + + The portal URL is the resolver's canonical one (env override, else the validated stored URL, + else the default), never a raw stored value on its own. + Raises :class:`AnonCredentialDead` when NAS no longer knows the credential; the caller owns + re-minting (:func:`ensure_portal_identity` after :func:`clear_dead_guest`). + """ + anon_token = state.get("anon_token") + if not isinstance(anon_token, str) or not anon_token: + raise AnonCredentialDead("Nous free-tier credential is missing.", code="anon_credential_dead") + from hermes_cli.auth import _nous_portal_base_url + apply_exchange_to_state(state, exchange_anon_jwt(client, _nous_portal_base_url(state), anon_token)) + + +def clear_dead_guest(reason: str, *, dead_token: Optional[str] = None) -> None: + """Drop a dead guest so the next need re-mints. + + Only the identity that actually failed is removed: a stale profile whose credential NAS rejected + must not erase a sibling profile's newer sign-in or replacement guest from the shared store. When + *dead_token* is None the profile's current guest is treated as the failed one. + """ + from hermes_cli.auth import ( + _auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store, _store_section) + from hermes_cli.auth_nous import _clear_shared_nous_state, _nous_shared_store_lock, _read_shared_nous_state + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "nous") + if is_guest_state(state): + token = dead_token or state.get("anon_token") + if state.get("anon_token") == token: + _store_section(auth_store, "providers").pop("nous", None) + _store_section(auth_store, "credential_pool").pop("nous", None) + if auth_store.get("active_provider") == "nous": + auth_store["active_provider"] = None + _save_auth_store(auth_store) + else: + token = dead_token + with _nous_shared_store_lock(): + shared = _read_shared_nous_state() + if token and is_guest_state(shared) and shared.get("anon_token") == token: + _clear_shared_nous_state(reason) + _set_mint_failed(False) + logger.info("Nous free-tier identity retired (%s); a new one is set up on next use", reason) + + +# --- Gateway welcome-tier contract: structured refusals and the model-switch header ------------------ +# +# The inference gateway answers a welcome-tier request it will not serve with a structured 429 +# (``{status, message, reason, retry_after, alternates?, upgrade_url?}``), and a request on the wrong +# host with a 400 (or a 403 while the tier is dark) whose message names the right host. A NAMED +# account that still asks for ``nous/welcome`` is served the id's backing model and told what to +# switch to in the ``x-nous-model-switch`` response header. Every rule for reading those lives here; +# the error classifier and the turn loop only call in. + +MODEL_SWITCH_HEADER = "x-nous-model-switch" +# Fairshare refusal reasons the welcome tier can answer with (api ``FairshareRefusalReason``). +WELCOME_REFUSAL_REASONS = frozenset( + {"model_not_free", "feature_not_free", "at_capacity", "admission_closed", "rate_limited"}) +# Reasons that mean "not on this tier, ever": no retry helps, only a sign-in or another provider. +WELCOME_TIER_GATE_REASONS = frozenset({"model_not_free", "feature_not_free"}) +# Gateway messages (lowercased substrings) for a request on the wrong host or a dark tier. +_WELCOME_ROUTE_REFUSALS = ( + ("anonymous accounts must use", "anon_on_paid_host"), + ("serves anonymous hermes agent accounts only", "named_on_welcome_host"), + ("anonymous accounts are not accepted", "tier_disabled"), +) +_WELCOME_ROUTE_COPY = { + "anon_on_paid_host": "The Nous free tier must use its own inference host ({host}); " + "Hermes is pointed at the paid one. Restart Hermes to re-read the route, " + "or unset NOUS_INFERENCE_BASE_URL if you set it.", + "named_on_welcome_host": "This Nous account must use the Nous Portal inference host, " + "not the free tier's. Run /model and pick the Nous row again.", + "tier_disabled": "The Nous free tier is switched off right now. {signin}", +} +_SIGNIN_CHAT = "Sign in with a Nous account for the full catalog: /login." +_SIGNIN_TERMINAL = "Sign in with a Nous account for the full catalog: `hermes auth upgrade`." + + +def parse_welcome_refusal(body: Any) -> Optional[Dict[str, Any]]: + """The structured welcome-tier refusal in a gateway 429 body, or None for any other shape. + + Returns ``{"reason", "retry_after", "alternates", "upgrade_url"}`` with ``retry_after`` an int + of whole seconds (0 when the gateway sent none) and ``alternates`` a list of model ids. + """ + if not isinstance(body, dict): + return None + reason = body.get("reason") + if not isinstance(reason, str) or reason not in WELCOME_REFUSAL_REASONS: + return None + raw_retry = body.get("retry_after") + try: + retry_after = max(0, int(float(raw_retry))) if raw_retry not in (None, "") else 0 + except (TypeError, ValueError): + retry_after = 0 + raw_alternates = body.get("alternates") + alternates = [str(a) for a in raw_alternates if isinstance(a, str) and a] if isinstance(raw_alternates, list) else [] + upgrade_url = body.get("upgrade_url") + return {"reason": reason, "retry_after": retry_after, "alternates": alternates, + "upgrade_url": upgrade_url if isinstance(upgrade_url, str) else ""} + + +def welcome_refusal_copy(refusal: Dict[str, Any], *, model: str = "", in_chat: bool = True) -> str: + """User copy for a structured welcome-tier refusal: what happened and the one way forward. + + Never guest / anonymous / claim; ``in_chat`` picks ``/login`` over the terminal verb.""" + signin = _SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL + reason = str(refusal.get("reason") or "") + alternates = refusal.get("alternates") or [] + serves = alternates[0] if alternates else GUEST_MODEL + retry = int(refusal.get("retry_after") or 0) + wait = f"Retrying in {retry}s." if retry > 0 else "Try again shortly." + if reason == "model_not_free": + what = f"{model} isn't on the Nous free tier" if model else "That model isn't on the Nous free tier" + return f"{what}; it serves {serves} only. {signin}" + if reason == "feature_not_free": + return f"This feature isn't on the Nous free tier. {signin}" + if reason == "at_capacity": + return f"The Nous free tier is at capacity and briefly paused. {wait} {signin}" + if reason == "admission_closed": + return f"The Nous free tier isn't admitting new sessions right now. {wait} {signin}" + if reason == "rate_limited": + return f"Nous free tier rate limit active \u2014 resets in {retry}s. {signin}" + return f"The Nous free tier refused this request ({reason}). {signin}" + + +def welcome_route_refusal(status: Any, message: Any) -> Optional[str]: + """Which host cross-refusal a gateway 400/403 is, by its message; None for any other error. + + ``"anon_on_paid_host"``: a free-tier JWT reached the paid host. ``"named_on_welcome_host"``: an + account or API key reached the free tier's host. ``"tier_disabled"``: the tier is dark + (``WELCOME_MODE=off``). Each is deterministic for the request: retrying cannot help.""" + if status not in (400, 403): + return None + text = str(message or "").lower() + return next((kind for needle, kind in _WELCOME_ROUTE_REFUSALS if needle in text), None) + + +def welcome_route_refusal_copy(kind: str, *, in_chat: bool = True) -> str: + template = _WELCOME_ROUTE_COPY.get(kind) or "The Nous inference gateway refused this route." + return template.format( + host=DEFAULT_NOUS_WELCOME_URL, signin=_SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL) + + +def note_model_switch(agent: Any, headers: Any) -> Optional[str]: + """Record the gateway's ``x-nous-model-switch`` header on *agent* for the next call, if present. + + The header arrives on a NAMED account's response that asked for ``nous/welcome`` (the gateway + served the backing model and billed it normally): the free tier's model no longer belongs in + this install's configuration. Recorded here, applied by :func:`apply_model_switch` between + calls so a response still streaming is never re-labelled under itself. Returns the backing id. + """ + if headers is None: + return None + value = None + try: + value = headers.get(MODEL_SWITCH_HEADER) + if value is None and hasattr(headers, "items"): + value = next((v for k, v in headers.items() if str(k).lower() == MODEL_SWITCH_HEADER), None) + except Exception: + return None + backing = str(value or "").strip() + if not backing: + return None + requested = str(getattr(agent, "model", "") or "") + if backing == requested: + return None + try: + agent._nous_pending_model_switch = (requested, backing) + except Exception: + return None + return backing + + +def apply_model_switch(agent: Any) -> Optional[str]: + """Move *agent* (and the config default, when it still names the switched id) to the backing + model the gateway named. Returns the new model, or None when nothing was pending. + + Runs once per recorded header, between calls. The conversation keeps its history; only the id + the next request carries changes, so a promoted account stops relying on the gateway's reverse + map. The config write is the same one a sign-in completion uses, so ``hermes model`` and the + gateway's config re-read agree with the live session. + """ + pending = getattr(agent, "_nous_pending_model_switch", None) + if not pending: + return None + agent._nous_pending_model_switch = None + requested, backing = pending + if str(getattr(agent, "model", "") or "") != requested: + return None # the session already moved (a /model, a sign-in sweep) + agent.model = backing + # The gateway's cache check compares agent.model with the config default and evicts on a + # mismatch it did not cause; this pair names the move so the check can recognise exactly this + # server-driven switch even when the config write below did not land. + agent._nous_model_switch = (requested, backing) + logger.info("Nous gateway asked to switch %s -> %s; applied for this session", requested, backing) + try: + from hermes_cli.config import load_config_readonly + raw = load_config_readonly().get("model") + model_cfg = raw if isinstance(raw, dict) else ({"default": raw} if isinstance(raw, str) else {}) + if str(model_cfg.get("default") or "").strip() == requested: + from hermes_cli.auth import _update_config_for_provider + _update_config_for_provider( + "nous", str(getattr(agent, "base_url", "") or ""), default_model=backing) + logger.info("Config default model moved %s -> %s", requested, backing) + except Exception as exc: + logger.debug("model switch: config default left as is: %s", exc) + status = getattr(agent, "_buffer_status", None) + if callable(status): + try: + status(f"Model is now {backing} (your account's model; {requested} is the free tier's).") + except Exception: + pass + return backing + + +# One-time CLI notice: an install whose inference is carried by an explicit provider learns once that +# the free tier (inference + connectors) now exists. The flag lives on the guest state itself so it +# dies with the identity; a fresh guest (re-mint, new profile) may announce itself once more. +GUEST_NOTICE_FLAG = "guest_notice_shown" +FREE_TIER_AVAILABLE_NOTICE = ( + "Free Nous inference and connectors are now available. " + "/model to try them, /login to sign in.") + + +def guest_notice_pending() -> bool: + """True when a guest identity exists and the one-time availability notice has not been shown.""" + state = current_nous_state() + return is_guest_state(state) and not bool(state.get(GUEST_NOTICE_FLAG)) + + +def mark_guest_notice_shown() -> bool: + """Persist ``guest_notice_shown`` on the guest's ``providers.nous`` state (whichever store holds it). + + Returns True when a flag was written; False when there is no guest to mark.""" + from hermes_cli.auth import ( + _auth_file_path, _load_auth_store, _provider_state_transaction, _same_path, _save_auth_store, + _store_section) + with _provider_state_transaction("nous") as (auth_store, state, source_path): + if not is_guest_state(state) or source_path is None: + return False + if state.get(GUEST_NOTICE_FLAG): + return True + state = dict(state) + state[GUEST_NOTICE_FLAG] = True + if _same_path(source_path, _auth_file_path()): + _store_section(auth_store, "providers")["nous"] = state + _save_auth_store(auth_store) + else: + source_store = _load_auth_store(source_path) + _store_section(source_store, "providers")["nous"] = state + _save_auth_store(source_store, target_path=source_path) + return True + + +# --- ``hermes auth upgrade``: sign the guest into a real Nous account, keeping its connectors --------- +# +# Wire: the normal device-code flow, with a promotion intent registered on NAS BETWEEN the code +# request and the token poll (``POST /api/anonymous/promotion-intent {token, user_code, device_code}``). +# NAS then transfers the guest's connectors into whichever account approves that device code. We +# watch ``POST /api/anonymous/promotion-status {claim_code}`` until it leaves ``pending``; only a +# ``completed`` promotion is followed by the token grant, which ``persist_nous_credentials`` writes +# over the guest singleton and the shared store. The server never reports expiry: our own +# ``expires_in`` clock ends the wait. User-facing copy never says guest / anonymous / claim. + +UPGRADED_AUTH_METHOD = "oauth_device_code" + + +def register_promotion_intent( + client: httpx.Client, portal_base_url: str, anon_token: str, *, user_code: str, device_code: str, +) -> Dict[str, Any]: + """``POST /api/anonymous/promotion-intent`` -> ``{claim_code, claim_url, expires_in, interval}``.""" + response = client.post( + f"{portal_base_url.rstrip('/')}/api/anonymous/promotion-intent", headers=_anon_headers(), + json={"token": anon_token, "user_code": user_code, "device_code": device_code}) + payload = _raise_for_anon_status(response, action="sign-in") + if not isinstance(payload.get("claim_code"), str) or not payload["claim_code"]: + raise _anon_err("Nous free tier sign-in returned no transfer code.", "anon_server_error") + return payload + + +def _retry_after_seconds(response: httpx.Response, default: float) -> float: + seconds = parse_retry_after_seconds(response.headers) + return default if seconds is None else seconds + + +def _sleep_until(wake: float, cancelled: Optional[Callable[[], bool]]) -> bool: + """Sleep until the monotonic time *wake*. Returns True when *cancelled* fired first. + + Without a hook this is one plain :func:`time.sleep`. With one the sleep is cut into <= 1 s + ticks so an attempt stopped from outside ends in about a second instead of blocking to the + sign-in code's own expiry. + """ + if cancelled is None: + remaining = wake - time.monotonic() + if remaining > 0: + time.sleep(remaining) + return False + while True: + if cancelled(): + return True + remaining = wake - time.monotonic() + if remaining <= 0: + return False + time.sleep(min(1.0, remaining)) + + +def wait_for_promotion( + client: httpx.Client, portal_base_url: str, claim_code: str, *, expires_in: int, interval: int, + cancelled: Optional[Callable[[], bool]] = None, +) -> Dict[str, Any]: + """Poll ``POST /api/anonymous/promotion-status`` until it leaves ``pending`` or our clock runs out. + + Returns the final status payload; ``{"status": "timeout"}`` when ``expires_in`` elapsed. 429 honours + ``Retry-After``; other non-2xx statuses raise through :func:`_raise_for_anon_status`. + + *cancelled* is an optional hook a surface passes to stop an attempt it no longer wants (a newer + sign-in replaced it, the user cancelled, the process is shutting down). It is polled at the top + of every iteration and on a <= 1 s tick while sleeping; once it has fired this call returns + ``{"status": "cancelled"}`` for every outcome except a ``completed`` transfer already in hand, + which is reported so the caller's ``cancel_wins_after_promotion`` ruling can decide it. + Passing nothing is today's behaviour. + """ + deadline = time.monotonic() + max(1, int(expires_in)) + wait = max(0, int(interval)) + while time.monotonic() < deadline: + if cancelled is not None and cancelled(): + return {"status": "cancelled"} + response = client.post( + f"{portal_base_url.rstrip('/')}/api/anonymous/promotion-status", headers=_anon_headers(), + json={"claim_code": claim_code}) + if response.status_code == 429: + retry = min(_retry_after_seconds(response, default=max(1, wait)), + max(0.0, deadline - time.monotonic())) + if _sleep_until(time.monotonic() + retry, cancelled): + return {"status": "cancelled"} + continue + payload = _raise_for_anon_status(response, action="sign-in") + status = str(payload.get("status") or "unknown") + if status != "pending": + # A completed transfer is already committed on the account service; report it even when + # the hook fired during this request. run_sign_in's cancel_wins_after_promotion + # rules what each surface does with it. Every other terminal outcome loses to a cancel. + if status != "completed" and cancelled is not None and cancelled(): + return {"status": "cancelled"} + return payload + if _sleep_until(time.monotonic() + wait, cancelled): + return {"status": "cancelled"} + return {"status": "timeout"} + + +def _account_state_from_token( + token_data: Dict[str, Any], *, portal_base_url: str, client_id: str, scope: Optional[str], verify: Any, + timeout_seconds: float, +) -> Dict[str, Any]: + """The ``providers.nous`` shape for the signed-in account (same fields the device-code login writes).""" + from hermes_cli.auth import PROVIDER_REGISTRY, _coerce_ttl_seconds, _optional_base_url, _tls_state_from_verify + from hermes_cli.auth_nous import _NOUS_EMPTY_AGENT_KEY_FIELDS, _iso_after, refresh_nous_oauth_from_state + now = datetime.now(timezone.utc) + ttl = _coerce_ttl_seconds(token_data.get("expires_in", 0)) + inference_url = ( + _optional_base_url(token_data.get("inference_base_url")) + or PROVIDER_REGISTRY["nous"].inference_base_url.rstrip("/")) + state = { + "portal_base_url": portal_base_url, "inference_base_url": inference_url, + "client_id": client_id, "scope": token_data.get("scope") or scope, + "token_type": token_data.get("token_type", "Bearer"), + "access_token": token_data["access_token"], "refresh_token": token_data.get("refresh_token"), + "obtained_at": now.isoformat(), "expires_at": _iso_after(now, ttl), "expires_in": ttl, + "tls": _tls_state_from_verify(verify), **_NOUS_EMPTY_AGENT_KEY_FIELDS} + state = refresh_nous_oauth_from_state(state, timeout_seconds=timeout_seconds, force_refresh=False) + state["auth_method"] = UPGRADED_AUTH_METHOD + return state + + +def settle_after_upgrade(account_state: Dict[str, Any]) -> Dict[str, Any]: + """After a sign-in from the free tier persisted the account: move the config off the free tier's route. + + Picking the free-tier row may have written ``model.default: nous/welcome`` and ``model.base_url`` + = welcome host. An account cannot keep either: the welcome host refuses account tokens, and the + portal host serves ``nous/welcome`` as a paid model. When the config is on the free tier's route, + ``model.base_url`` becomes the account's inference host and ``model.default`` the recommended + default for the account's tier (:func:`hermes_cli.models.recommended_nous_default_model`, the + same pick as ``GET /api/model/recommended-default``), through the same config write a plain Nous + login uses. A config on the user's own model and host is left alone. + + Every sign-in completion (CLI ``hermes auth upgrade``, the desktop poller) calls this once, after + ``persist_nous_credentials``. Returns ``{"model": str, "changed": bool}``: ``model`` is the default + the config now carries (``""`` when it carries none); ``changed`` says whether this call wrote it. + Never raises: a failed pick or write is logged and reported as ``changed: False`` so the sign-in + itself still counts. + """ + from hermes_cli.config import load_config_readonly + try: + raw = load_config_readonly().get("model") + except Exception as exc: + logger.warning("sign-in completion: config unreadable, default model left as is: %s", exc) + return {"model": "", "changed": False} + model_cfg = raw if isinstance(raw, dict) else ({"default": raw} if isinstance(raw, str) else {}) + current = str(model_cfg.get("default") or "").strip() + on_welcome_model = current == GUEST_MODEL + on_welcome_host = route_is_welcome_host(model_cfg.get("base_url")) + if not (on_welcome_model or on_welcome_host): + return {"model": current, "changed": False} + model = current + if on_welcome_model: + from hermes_cli.models import recommended_nous_default_model + try: + model = str(recommended_nous_default_model().get("model") or "") + except Exception as exc: + logger.debug("sign-in completion: recommended default unavailable: %s", exc) + model = "" + try: + from hermes_cli.auth import _update_config_for_provider + # One write: host and default move together, so a failure leaves the config as it was + # rather than the account host paired with the welcome model. No eligible recommendation + # (Portal unreachable, or the plan and org policy admit nothing) clears the default in that + # same write; the runtime's silent default applies until the user picks one with `hermes model`. + _update_config_for_provider( + "nous", str(account_state.get("inference_base_url") or ""), + default_model=model if on_welcome_model else None, + clear_default=on_welcome_model and not model) + except Exception as exc: + logger.warning("sign-in completion: could not update the default model: %s", exc) + return {"model": current, "changed": False} + return {"model": model, "changed": True} + + +def _poll_for_token(*args, **kwargs) -> Dict[str, Any]: + """Keep both the sign-in module seam and the device-flow seam live at call time.""" + from hermes_cli.auth_device_flow import _poll_for_token as poll + return poll(*args, **kwargs) + + +def persist_nous_credentials(*args, **kwargs): + """Keep the existing auth_nous persistence seam behind the sign-in entry point.""" + from hermes_cli.auth_nous import persist_nous_credentials as persist + return persist(*args, **kwargs) + + +# Public sign-in imports remain here for existing callers and module-attribute patches. +# The flow imports this module only inside calls, so either module can be imported first. +from hermes_cli.anon_sign_in import ( # noqa: E402 + AlreadySignedIn as AlreadySignedIn, + Code as Code, + Completed as Completed, + Declined as Declined, + FREE_TIER_RATE_LIMIT_CHAT as FREE_TIER_RATE_LIMIT_CHAT, + Failed as Failed, + LOGIN_BUSY_ELSEWHERE as LOGIN_BUSY_ELSEWHERE, + LOGIN_COMMAND as LOGIN_COMMAND, + LOGIN_DM_ONLY as LOGIN_DM_ONLY, + LOGIN_NOT_ALLOWED as LOGIN_NOT_ALLOWED, + LOGIN_STARTING as LOGIN_STARTING, + Retired as Retired, + SignInState as SignInState, + Superseded as Superseded, + TimedOut as TimedOut, + UPGRADE_ALREADY_SIGNED_IN as UPGRADE_ALREADY_SIGNED_IN, + UPGRADE_CANCELLED as UPGRADE_CANCELLED, + UPGRADE_DO_NOT_SHARE as UPGRADE_DO_NOT_SHARE, + UPGRADE_NOT_COMPLETED as UPGRADE_NOT_COMPLETED, + UPGRADE_NO_DEFAULT_CHAT as UPGRADE_NO_DEFAULT_CHAT, + UPGRADE_NO_DEFAULT_TERMINAL as UPGRADE_NO_DEFAULT_TERMINAL, + UPGRADE_REASON_COPY as UPGRADE_REASON_COPY, + UPGRADE_START as UPGRADE_START, + UPGRADE_TIMED_OUT as UPGRADE_TIMED_OUT, + UPGRADE_UNAVAILABLE as UPGRADE_UNAVAILABLE, + UPGRADE_UNAVAILABLE_CHAT as UPGRADE_UNAVAILABLE_CHAT, + UPGRADE_WAITING as UPGRADE_WAITING, + UPGRADE_WAITING_UP_TO as UPGRADE_WAITING_UP_TO, + Unavailable as Unavailable, + Waiting as Waiting, + _RETIRED_REASONS as _RETIRED_REASONS, + _default_persist_guard as _default_persist_guard, + _outcome_state as _outcome_state, + format_wait_line as format_wait_line, + run_sign_in as run_sign_in, +) +from hermes_cli.anon_sign_in_cli import ( # noqa: E402 + drain_sign_in_copy as drain_sign_in_copy, + render_sign_in_cli as render_sign_in_cli, + render_sign_in_cli_code as render_sign_in_cli_code, + upgrade_guest as upgrade_guest, +) + +FREE_TIER_STATUS_LINE = f"{FREE_TIER_LABEL} \u00b7 {GUEST_MODEL} \u00b7 {LOGIN_COMMAND} to sign in" diff --git a/hermes_cli/auth_xai.py b/hermes_cli/auth_xai.py new file mode 100644 index 0000000000000000000000000000000000000000..34bb7cb1771aacf8c01d5312cd565937994f62fa --- /dev/null +++ b/hermes_cli/auth_xai.py @@ -0,0 +1,582 @@ +"""xAI Grok OAuth: token store, discovery, refresh, device-code login. + +Split out of ``hermes_cli/auth.py``; origin helpers are imported lazily per function so +``hermes_cli.auth.`` patches still intercept and no cycle forms. +""" + +from __future__ import annotations + +import logging +import base64 +import json +import os +import time +from pathlib import Path +from typing import Any, Dict, Optional, TYPE_CHECKING +from urllib.parse import urlparse +from hermes_cli.auth_codex import _load_auth_store_maybe_locked, _refresh_payload_access_token +from hermes_cli.auth_constants import ( + AUTH_LOCK_TIMEOUT_SECONDS, AuthError, DEFAULT_XAI_OAUTH_BASE_URL, DEVICE_CODE_GRANT_TYPE, + XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, XAI_OAUTH_CLIENT_ID, XAI_OAUTH_DEVICE_CODE_URL, + XAI_OAUTH_DISCOVERY_URL, XAI_OAUTH_SCOPE, _FORM_JSON_HEADERS, _xai_err, httpx, +) +from utils import env_float + +if TYPE_CHECKING: # annotation-only; the runtime import would be a cycle + from hermes_cli.auth import ProviderConfig +logger = logging.getLogger("hermes_cli.auth") + +_RELOGIN = "Re-authenticate with `hermes model`." + + +def _clean(value: Any) -> str: + return str(value or "").strip() + + +def _token_pair(tokens: Any) -> tuple[str, str]: + """``(access_token, refresh_token)`` stripped; empty strings when *tokens* is not a dict.""" + if not isinstance(tokens, dict): + return "", "" + return _clean(tokens.get("access_token")), _clean(tokens.get("refresh_token")) + + +def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Return usable xAI OAuth state from provider state or credential pool.""" + from hermes_cli.auth import _load_provider_state + state = _load_provider_state(auth_store, "xai-oauth") + if isinstance(state, dict) and all(_token_pair(state.get("tokens"))): + return state + + credential_pool = auth_store.get("credential_pool") + entries = credential_pool.get("xai-oauth") if isinstance(credential_pool, dict) else None + for entry in entries if isinstance(entries, list) else (): + access_token, refresh_token = _token_pair(entry) + if not access_token or not refresh_token: + continue + merged = dict(state or {}) + merged["tokens"] = { + "access_token": access_token, "refresh_token": refresh_token, + "token_type": str(entry.get("token_type") or "Bearer"), + } + if entry.get("last_refresh"): + merged["last_refresh"] = entry.get("last_refresh") + merged.setdefault("auth_mode", "oauth_pkce") + return merged + + return state if isinstance(state, dict) else None + + +def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool: + return isinstance(state, dict) and all(_token_pair(state.get("tokens"))) + + +def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]: + from hermes_cli.auth import _load_global_auth_store + state = _xai_oauth_state_from_store(_load_auth_store_maybe_locked(_lock)) + if not _xai_oauth_state_has_usable_tokens(state): + global_state = _xai_oauth_state_from_store(_load_global_auth_store()) + if _xai_oauth_state_has_usable_tokens(global_state): + state = global_state + if not state: + raise _xai_err( + "No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.", + "xai_auth_missing", relogin=True, + ) + tokens = state.get("tokens") + if not isinstance(tokens, dict): + raise _xai_err(f"xAI OAuth state is missing tokens. {_RELOGIN}", "xai_auth_invalid_shape", relogin=True) + access_token, refresh_token = _token_pair(tokens) + for value, field in ((access_token, "access_token"), (refresh_token, "refresh_token")): + if not value: + raise _xai_err( + f"xAI OAuth state is missing {field}. {_RELOGIN}", f"xai_auth_missing_{field}", relogin=True, + ) + return { + "tokens": tokens, "last_refresh": state.get("last_refresh"), + "discovery": state.get("discovery") or {}, "redirect_uri": state.get("redirect_uri"), + } + + +def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None: + """Best-effort persist of a rotated xAI grant into the global-root auth.json. + + xAI rotates refresh_token on every refresh, so a profile that refreshed a root-resolved grant + must write the chain back to root. Touches only root ``providers.xai-oauth``; swallows all + errors (root-stale is better than breaking the profile's own save). + """ + from hermes_cli.auth import _global_auth_file_path, _persist_provider_state_to_store + global_path = _global_auth_file_path() + if global_path is None: # classic mode (profile == root); the profile save already hit root + return + # Seat belt: under pytest never write the real ~/.hermes/auth.json (mirrors the read-side guard + # in _load_global_auth_store). Uses raw HOME, not Path.home(), which fixtures may monkeypatch. + real_home_env = os.environ.get("HOME", "") if os.environ.get("PYTEST_CURRENT_TEST") else "" + if real_home_env: + real_root = Path(real_home_env) / ".hermes" / "auth.json" + try: + if global_path.resolve(strict=False) == real_root.resolve(strict=False): + return + except Exception: + return + try: + _persist_provider_state_to_store("xai-oauth", state, global_path, set_active=False) + except Exception as exc: # pragma: no cover - best effort + logger.debug("xAI OAuth: write-through to global root failed: %s", exc) + + +def _save_xai_oauth_tokens( + tokens: Dict[str, Any], *, discovery: Optional[Dict[str, Any]] = None, redirect_uri: str = "", + last_refresh: Optional[str] = None, auth_mode: str = "oauth_device_code", + set_active: bool = True, +) -> None: + """Persist xAI OAuth tokens; *set_active* also promotes ``xai-oauth`` to ``active_provider``. + + Pass ``set_active=False`` for side-tool bootstrap (TTS/setup, tools config, dashboard, refresh) + so inference routing is unchanged. + """ + from hermes_cli.auth import _auth_store_lock, _global_auth_file_path, _load_auth_store, _load_provider_state_with_source, _same_path, _save_auth_store, _store_provider_state, _utc_now_z, _write_through_xai_oauth_to_global_root + if last_refresh is None: + last_refresh = _utc_now_z() + with _auth_store_lock(): + auth_store = _load_auth_store() + # A profile lacking its own xai-oauth block reads root's grant via fallback; refreshing it + # must write the rotated chain back to root or root keeps a revoked refresh token. Decide by + # where the grant was resolved FROM (key presence lies: _store_provider_state creates it). + state, source_path = _load_provider_state_with_source(auth_store, "xai-oauth") + state = state if state is not None else {} + state.update(tokens=tokens, last_refresh=last_refresh, auth_mode=auth_mode) + if discovery: + state["discovery"] = discovery + if redirect_uri: + state["redirect_uri"] = redirect_uri + global_root = _global_auth_file_path() + if source_path is not None and global_root is not None and _same_path(source_path, global_root): + # Root-only write-back: a profile copy would shadow root and disable write-through. + _write_through_xai_oauth_to_global_root(state) + else: + _store_provider_state(auth_store, "xai-oauth", state, set_active=set_active) + _save_auth_store(auth_store) + + +def _xai_jwt_exp(access_token: Any) -> Optional[float]: + """``exp`` claim of a JWT-shaped access token, or None when absent/undecodable.""" + if not isinstance(access_token, str) or "." not in access_token: + return None + try: + parts = access_token.split(".") + if len(parts) < 2: + return None + payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4) + exp = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8")).get("exp") + return float(exp) if isinstance(exp, (int, float)) else None + except Exception: + return None + + +def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool: + exp = _xai_jwt_exp(access_token) + return exp is not None and exp <= time.time() + max(0, int(skew_seconds)) + + +def _xai_proactive_refresh_skew_seconds(access_token: str) -> int: + """Proactive-refresh lead time before JWT ``exp``. + + Device-code logins often return ~15-minute JWTs; the full hour-long skew would refresh on every + resolution, burning single-use refresh tokens and racing callers into ``invalid_grant``. + """ + max_skew = XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS + exp = _xai_jwt_exp(access_token) + if exp is None: + return max_skew + remaining = exp - time.time() + return min(120, max_skew) if 0 < remaining <= 45 * 60 else max_skew + + +def _is_xai_origin_host(host: str) -> bool: + """``x.ai`` is the bare apex, so an exact match or any ``.x.ai`` suffix is accepted.""" + return host == "x.ai" or host.endswith(".x.ai") + + +def _xai_url_problem(url: str) -> tuple[Optional[str], str]: + """``(problem, host)`` — problem is ``"scheme"``, ``"host"``, ``"origin"`` or None when *url* is HTTPS on x.ai.""" + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + if parsed.scheme != "https": + return "scheme", host + if not host: + return "host", host + if not _is_xai_origin_host(host): + return "origin", host + return None, host + + +def _xai_validate_oauth_endpoint(url: str, *, field: str) -> str: + """Refuse a discovery endpoint that isn't HTTPS on the xAI origin. + + Discovery is cached in auth.json, so one MITM at login could plant a ``token_endpoint`` that + receives the refresh_token forever; pinning scheme + host (RFC 8414 §2) removes that. + """ + problem, host = _xai_url_problem(url) + if problem is None: + return url + message = { + "scheme": f"xAI OIDC discovery returned a non-HTTPS {field}: {url!r}.", + "host": f"xAI OIDC discovery {field} is missing a hostname: {url!r}.", + "origin": ( + f"xAI OIDC discovery {field} host {host!r} is not on the xAI origin " + f"(expected x.ai or a *.x.ai subdomain). Refusing to use a cached " + f"endpoint that may have been substituted by a MITM during initial " + f"discovery; re-authenticate with `hermes model` to re-fetch." + ), + }[problem] + raise _xai_err(message, "xai_discovery_invalid") + + +def _xai_validate_inference_base_url(value: str, *, fallback: str) -> str: + """Pin the OAuth inference base_url to ``*.x.ai``; warn and use *fallback* on rejection. + + Warn-not-raise: a bad env var must not deadlock auth, but the bearer must never leak elsewhere. + """ + candidate = (value or "").strip().rstrip("/") + if not candidate: + return fallback + try: + problem, host = _xai_url_problem(candidate) + except Exception: + logger.warning("Ignoring malformed xAI base_url override %r; using %s instead.", candidate, fallback) + return fallback + if problem is None: + return candidate + if problem == "scheme": + logger.warning( + "Refusing non-HTTPS xAI base_url override %r (xai-oauth bearer would " + "be sent in cleartext); falling back to %s.", + candidate, fallback, + ) + elif problem == "host": + logger.warning("Ignoring xAI base_url override %r with no hostname; using %s instead.", candidate, fallback) + else: + logger.warning( + "Refusing xAI base_url override %r — host %r is not on the xAI origin " + "(expected x.ai or a *.x.ai subdomain). The xai-oauth bearer is only " + "valid against xAI's inference API; sending it elsewhere would leak " + "the credential. Falling back to %s.", + candidate, host, fallback, + ) + return fallback + + +def _xai_oauth_discovery(timeout_seconds: float = 15.0) -> Dict[str, str]: + try: + response = httpx.get(XAI_OAUTH_DISCOVERY_URL, headers={"Accept": "application/json"}, timeout=timeout_seconds) + except Exception as exc: + raise _xai_err(f"xAI OIDC discovery failed: {exc}", "xai_discovery_failed") from exc + if response.status_code != 200: + raise _xai_err(f"xAI OIDC discovery returned status {response.status_code}.", "xai_discovery_failed") + try: + payload = response.json() + except Exception as exc: + raise _xai_err(f"xAI OIDC discovery returned invalid JSON: {exc}", "xai_discovery_invalid_json") from exc + if not isinstance(payload, dict): + raise _xai_err("xAI OIDC discovery response was not a JSON object.", "xai_discovery_incomplete") + endpoints = {k: _clean(payload.get(k)) for k in ("authorization_endpoint", "token_endpoint")} + if not all(endpoints.values()): + raise _xai_err("xAI OIDC discovery response was missing required endpoints.", "xai_discovery_incomplete") + for field, url in endpoints.items(): + _xai_validate_oauth_endpoint(url, field=field) + return endpoints + + +def _xai_tokens_from_payload(payload: Dict[str, Any], access_token: str, fallback_refresh: str) -> Dict[str, Any]: + """Token block persisted for xAI OAuth; falls back to *fallback_refresh* when none is rotated in.""" + return { + "access_token": access_token, + "refresh_token": str(payload.get("refresh_token") or fallback_refresh).strip(), + "id_token": _clean(payload.get("id_token")), "expires_in": payload.get("expires_in"), + "token_type": _clean(payload.get("token_type") or "Bearer") or "Bearer", + } + + +def refresh_xai_oauth_pure( + access_token: str, refresh_token: str, *, token_endpoint: str = "", + timeout_seconds: float = 20.0, +) -> Dict[str, Any]: + from hermes_cli.auth import _nonempty_str, _utc_now_z, _xai_oauth_discovery + del access_token + if not _nonempty_str(refresh_token): + raise _xai_err( + f"xAI OAuth is missing refresh_token. {_RELOGIN}", "xai_auth_missing_refresh_token", relogin=True, + ) + endpoint = token_endpoint.strip() or _xai_oauth_discovery(timeout_seconds)["token_endpoint"] + # Re-validate cached endpoints: an old/hand-edited auth.json may carry a non-xAI token_endpoint + # that would otherwise receive every future refresh_token. + _xai_validate_oauth_endpoint(endpoint, field="token_endpoint") + timeout = httpx.Timeout(max(5.0, float(timeout_seconds))) + with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client: + response = client.post( + endpoint, headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={"grant_type": "refresh_token", "client_id": XAI_OAUTH_CLIENT_ID, "refresh_token": refresh_token}, + ) + if response.status_code != 200: + detail = response.text.strip() + suffix = f" Response: {detail}" if detail else "" + # 403 is almost always a tier/entitlement gate; re-login won't fix it, so use a separate + # code and format_auth_error skips the re-authenticate hint. + # ``403`` from xAI's token endpoint is almost always a tier / entitlement gate (the OAuth grant + # exists but the account isn't on the allowlist for API access). Re-running ``hermes model`` won't + # fix that — surface a separate error code so ``format_auth_error`` doesn't append a misleading + # re-authenticate hint, and point users at the ``XAI_API_KEY`` fallback. See #26847. + if response.status_code == 403: + raise _xai_err( + "xAI token refresh failed with HTTP 403." + suffix + + " This OAuth account is not authorized for xAI API" + " access — xAI may be restricting API/OAuth use to" + " specific SuperGrok tiers despite the in-app" + " subscription being active. Re-logging in won't" + " change that; set ``XAI_API_KEY`` and switch to" + " ``provider: xai`` (API-key path) if available, or" + " upgrade your subscription at https://x.ai/grok.", + "xai_oauth_tier_denied", relogin=False, + ) + raise _xai_err( + "xAI token refresh failed." + suffix, "xai_refresh_failed", relogin=response.status_code in {400, 401}, + ) + payload, refreshed_access = _refresh_payload_access_token( + response, provider="xai-oauth", + invalid_json=("xAI token refresh returned invalid JSON: {exc}", "xai_refresh_invalid_json"), + invalid_json_relogin=False, strict_str=False, + invalid_response=("xAI token refresh response was not a JSON object.", "xai_refresh_invalid_response"), + missing_access=("xAI token refresh response was missing access_token.", "xai_refresh_missing_access_token"), + ) + return {**_xai_tokens_from_payload(payload, refreshed_access, refresh_token), "last_refresh": _utc_now_z()} + + +def _refresh_xai_oauth_tokens( + tokens: Dict[str, Any], *, token_endpoint: str, redirect_uri: str = "", timeout_seconds: float +) -> Dict[str, Any]: + # Keep the stored auth_mode (legacy logins may carry ``oauth_pkce``): refresh must not relabel it. + from hermes_cli.auth import _load_auth_store, _load_provider_state, refresh_xai_oauth_pure + try: + state = _load_provider_state(_load_auth_store(), "xai-oauth") or {} + auth_mode = str(state.get("auth_mode") or "oauth_device_code") + except Exception: + auth_mode = "oauth_device_code" + refreshed = refresh_xai_oauth_pure( + _clean(tokens.get("access_token")), _clean(tokens.get("refresh_token")), + token_endpoint=token_endpoint, timeout_seconds=timeout_seconds, + ) + updated_tokens = dict(tokens) + updated_tokens["access_token"] = refreshed["access_token"] + updated_tokens["refresh_token"] = refreshed["refresh_token"] + if refreshed.get("id_token"): + updated_tokens["id_token"] = refreshed["id_token"] + if refreshed.get("expires_in") is not None: + updated_tokens["expires_in"] = refreshed["expires_in"] + if refreshed.get("token_type"): + updated_tokens["token_type"] = refreshed["token_type"] + # set_active=False: side tools (TTS) refresh xAI tokens while chat routes elsewhere. + _save_xai_oauth_tokens( + updated_tokens, discovery={"token_endpoint": token_endpoint}, redirect_uri=redirect_uri, + last_refresh=refreshed["last_refresh"], auth_mode=auth_mode, set_active=False, + ) + return updated_tokens + + +def _quarantine_xai_oauth_tokens(exc: AuthError) -> None: + """Clear dead xAI tokens after a terminal (400/401/403) refresh failure so later sessions fail fast. + + Best-effort: persistence failures are logged and swallowed; the caller re-raises regardless. + """ + from hermes_cli.auth import _last_auth_error_marker, _load_auth_store, _load_provider_state, _save_auth_store, _store_provider_state + try: + store = _load_auth_store() + state = _load_provider_state(store, "xai-oauth") or {} + tokens = dict(state.get("tokens") or {}) + tokens.pop("access_token", None) + tokens.pop("refresh_token", None) + # Capture the previous singleton tokens BEFORE overwriting them. The pool-sync step uses this to + # distinguish legacy singleton-aliases (which should be refreshed) from independent accounts that + # ``hermes auth add openai-codex`` created (which must not be overwritten — see #39236). + state["tokens"] = tokens + state["last_auth_error"] = _last_auth_error_marker( + "xai-oauth", exc, reason="runtime_refresh_failure", default_code="xai_refresh_failed", + ) + _store_provider_state(store, "xai-oauth", state, set_active=False) + _save_auth_store(store) + except Exception as save_exc: + logger.debug("xAI OAuth: failed to persist quarantined state: %s", save_exc) + + +def _xai_oauth_inference_base_url() -> str: + return _xai_validate_inference_base_url( + os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/") or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"), + fallback=DEFAULT_XAI_OAUTH_BASE_URL, + ) + + +def resolve_xai_oauth_runtime_credentials( + *, force_refresh: bool = False, refresh_if_expiring: bool = True, + refresh_skew_seconds: Optional[int] = None, +) -> Dict[str, Any]: + from hermes_cli.auth import _auth_store_lock, _is_terminal_xai_oauth_refresh_error, _refresh_xai_oauth_tokens, _xai_oauth_discovery + + def _should_refresh(data: Dict[str, Any]) -> bool: + access_token = _clean(data["tokens"].get("access_token")) + skew = ( + int(refresh_skew_seconds) if refresh_skew_seconds is not None + else _xai_proactive_refresh_skew_seconds(access_token) + ) + return bool(force_refresh) or bool( + refresh_if_expiring and _xai_access_token_is_expiring(access_token, skew) + ) + + data = _read_xai_oauth_tokens() + tokens = dict(data["tokens"]) + refresh_timeout_seconds = env_float("HERMES_XAI_REFRESH_TIMEOUT_SECONDS", 20) + if _should_refresh(data): + with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)): + # Re-read under the lock: a concurrent caller may already have rotated the grant. + data = _read_xai_oauth_tokens(_lock=False) + tokens = dict(data["tokens"]) + if _should_refresh(data): + token_endpoint = ( + _clean(dict(data.get("discovery") or {}).get("token_endpoint")) + or _xai_oauth_discovery(refresh_timeout_seconds)["token_endpoint"] + ) + try: + tokens = _refresh_xai_oauth_tokens( + tokens, token_endpoint=token_endpoint, redirect_uri=_clean(data.get("redirect_uri")), + timeout_seconds=refresh_timeout_seconds, + ) + except AuthError as exc: + if _is_terminal_xai_oauth_refresh_error(exc): + _quarantine_xai_oauth_tokens(exc) + raise + + return { + "provider": "xai-oauth", + "base_url": _xai_oauth_inference_base_url(), + "api_key": _clean(tokens.get("access_token")), + "source": "hermes-auth-store", + "last_refresh": data.get("last_refresh"), + # Display only; auth.json may still carry a legacy ``oauth_pkce`` label. + "auth_mode": "oauth_device_code", + } + + +def _login_xai_oauth(args, pconfig: ProviderConfig, *, force_new_login: bool = False) -> None: + from hermes_cli.auth import _is_remote_session, _offer_existing_oauth_credentials, _print_login_success, _update_config_for_provider, _xai_oauth_device_code_login, resolve_xai_oauth_runtime_credentials, unsuppress_credential_source + del pconfig + + if not force_new_login and _offer_existing_oauth_credentials( + "xai-oauth", + resolve=resolve_xai_oauth_runtime_credentials, + is_expiring=_xai_access_token_is_expiring, + display_name="xAI OAuth", + default_base_url=DEFAULT_XAI_OAUTH_BASE_URL, + ): + return + + print() + print("Signing in to xAI Grok OAuth (SuperGrok / Premium+)...") + print("(Hermes creates its own local OAuth session)") + print() + + timeout_seconds = float(getattr(args, "timeout", None) or 20.0) + open_browser = not getattr(args, "no_browser", False) + if _is_remote_session(): + open_browser = False + + creds = _xai_oauth_device_code_login(timeout_seconds=timeout_seconds, open_browser=open_browser) + _save_xai_oauth_tokens( + creds["tokens"], discovery=creds.get("discovery"), + redirect_uri=creds.get("redirect_uri", ""), last_refresh=creds.get("last_refresh"), + auth_mode="oauth_device_code", + ) + # Explicit re-login re-enables the credential: clear the ``device_code`` suppression marker left + # by ``hermes auth remove xai-oauth``. Deliberately NOT inside _save_xai_oauth_tokens — the + # refresh hot path shares that helper and must never mutate suppression state. + unsuppress_credential_source("xai-oauth", "device_code") + config_path = _update_config_for_provider("xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL)) + _print_login_success("xai-oauth", config_path, show_auth_state=True) + + +def _xai_oauth_request_device_code(client: httpx.Client, *, scope: str = XAI_OAUTH_SCOPE) -> Dict[str, Any]: + response = client.post( + XAI_OAUTH_DEVICE_CODE_URL, headers=_FORM_JSON_HEADERS, data={"client_id": XAI_OAUTH_CLIENT_ID, "scope": scope}, + ) + if response.status_code != 200: + raise _xai_err( + f"xAI device-code request failed (HTTP {response.status_code})." + + (f" Response: {response.text.strip()}" if response.text else ""), + "device_code_request_failed", + ) + payload = response.json() + required = ("device_code", "user_code", "verification_uri", "verification_uri_complete", "expires_in", "interval") + missing = [key for key in required if key not in payload] + if missing: + raise _xai_err(f"xAI device-code response missing fields: {', '.join(missing)}", "device_code_invalid") + return payload + + +def _xai_oauth_poll_device_token( + client: httpx.Client, *, token_endpoint: str, device_code: str, expires_in: int, + poll_interval: int, +) -> Dict[str, Any]: + from hermes_cli.auth import _poll_device_token_generic + def _validate(payload: Dict[str, Any]) -> None: + for field_name, article in (("access_token", "an"), ("refresh_token", "a")): + if not payload.get(field_name): + raise _xai_err( + f"xAI device-code token response did not include {article} {field_name}.", + "xai_device_token_invalid", + ) + + def _error(response, error_payload) -> Exception: + description = error_payload.get("error_description") or error_payload.get("error") or response.text + return _xai_err(f"xAI device-code token polling failed: {description}", "xai_device_token_failed") + + return _poll_device_token_generic( + lambda: client.post( + token_endpoint, headers=_FORM_JSON_HEADERS, + data={"grant_type": DEVICE_CODE_GRANT_TYPE, "client_id": XAI_OAUTH_CLIENT_ID, "device_code": device_code}, + ), + expires_in=int(expires_in), + poll_interval=max(1, int(poll_interval)), + validate_success=_validate, + on_non_json_error=lambda _r: _xai_err( + "xAI device-code token polling returned a non-JSON error response.", "xai_device_token_failed", + ), + on_error=_error, + on_timeout=lambda: _xai_err("Timed out waiting for xAI device authorization.", "device_code_timeout"), + ) + + +def _xai_oauth_device_code_login(*, timeout_seconds: float = 20.0, open_browser: bool = True) -> Dict[str, Any]: + from hermes_cli.auth import _can_open_graphical_browser, _is_remote_session, _print_device_code_instructions, _utc_now_z, _xai_oauth_discovery, _xai_oauth_poll_device_token + discovery = _xai_oauth_discovery(timeout_seconds) + timeout = httpx.Timeout(max(20.0, timeout_seconds)) + with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client: + device_data = _xai_oauth_request_device_code(client) + interval = int(device_data["interval"]) + _print_device_code_instructions( + str(device_data.get("verification_uri_complete") or device_data["verification_uri"]), + str(device_data["user_code"]), + open_browser=open_browser and not _is_remote_session() and _can_open_graphical_browser(), + swallow_open_errors=True, + ) + print(f"Waiting for approval (polling every {max(1, interval)}s)...") + payload = _xai_oauth_poll_device_token( + client, token_endpoint=discovery["token_endpoint"], + device_code=str(device_data["device_code"]), expires_in=int(device_data["expires_in"]), + poll_interval=interval, + ) + + access_token, refresh_token = _token_pair(payload) + if not access_token or not refresh_token: + raise _xai_err("xAI device-code token response was missing required tokens.", "xai_device_token_invalid") + return { + "tokens": _xai_tokens_from_payload(payload, access_token, refresh_token), + "discovery": discovery, "redirect_uri": "", "base_url": _xai_oauth_inference_base_url(), + "last_refresh": _utc_now_z(), "source": "oauth-device-code", + } diff --git a/hermes_cli/cli_output.py b/hermes_cli/cli_output.py new file mode 100644 index 0000000000000000000000000000000000000000..6c85046f24ab357c9d2b15dd6f7354186bd215d9 --- /dev/null +++ b/hermes_cli/cli_output.py @@ -0,0 +1,70 @@ +"""Shared CLI output helpers (``print_*`` + ``prompt()``) for the setup/config wizards.""" + +import sys + +from hermes_cli.colors import Colors, color +from hermes_cli.secret_prompt import masked_secret_prompt + + +def print_info(text: str) -> None: + print(color(f" {text}", Colors.DIM)) + + +def print_success(text: str) -> None: + print(color(f"✓ {text}", Colors.GREEN)) + + +def print_warning(text: str) -> None: + print(color(f"⚠ {text}", Colors.YELLOW)) + + +def print_error(text: str) -> None: + print(color(f"✗ {text}", Colors.RED)) + + +def print_header(text: str) -> None: + print(color(f"\n {text}", Colors.YELLOW)) + + +def line_input(prompt_text: str) -> str: + """Read non-secret text with normal cursor-editing keys on a real TTY. + + Setup/model-selection commands run outside the chat's prompt_toolkit application, so a + short-lived prompt is safe here. Redirected stdin/stdout keep the built-in ``input`` used by + scripts, tests and numbered fallbacks. + """ + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return input(prompt_text) + try: + from prompt_toolkit import prompt as prompt_toolkit_prompt + from prompt_toolkit.formatted_text import ANSI + except ImportError: + return input(prompt_text) + try: + return prompt_toolkit_prompt(ANSI(prompt_text)) + except (KeyboardInterrupt, EOFError): + raise + except Exception: + # Some terminals report isatty() yet reject registering stdin with the asyncio selector + # (macOS kqueue raises EINVAL for fd 0). Any prompt_toolkit runtime failure degrades to + # the built-in reader, which needs no selector — the wizard proceeds instead of crashing. + return input(prompt_text) + + +def prompt(question: str, default: str | None = None, password: bool = False) -> str: + """Prompt for input (stripped), or ``default`` on plain Enter; "" on Ctrl-C/EOF.""" + suffix = f" [{default}]" if default else "" + display = color(f" {question}{suffix}: ", Colors.YELLOW) + try: + value = (masked_secret_prompt(display) if password else line_input(display)).strip() + return value if value else (default or "") + except (KeyboardInterrupt, EOFError): + print() + return "" + + +def prompt_yes_no(question: str, default: bool = True) -> bool: + answer = prompt(f"{question} ({'Y/n' if default else 'y/N'})") + if not answer: + return default + return answer.lower().startswith("y") diff --git a/hermes_cli/colors.py b/hermes_cli/colors.py new file mode 100644 index 0000000000000000000000000000000000000000..51186e37fea2eb354e6206f6969a4ff1bd09c566 --- /dev/null +++ b/hermes_cli/colors.py @@ -0,0 +1,30 @@ +"""Shared ANSI color utilities for Hermes CLI modules.""" + +import os +import sys + + +def should_use_color() -> bool: + """Return True when colored output is appropriate.""" + if os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb": + return False + return bool(sys.stdout.isatty()) + + +class Colors: + 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: + """Apply color codes to text (only when color output is appropriate).""" + if not should_use_color(): + return text + return "".join(codes) + text + Colors.RESET diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py new file mode 100644 index 0000000000000000000000000000000000000000..15b8048e4bc52d96a5e86efb3c4b1f2d687ff349 --- /dev/null +++ b/hermes_cli/curator.py @@ -0,0 +1,710 @@ +"""CLI subcommand: `hermes curator `.""" + +from __future__ import annotations + +import argparse +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + + +def _parse_ts(ts) -> Optional[datetime]: + """ISO timestamp -> aware UTC datetime, or None when unparseable.""" + try: + dt = datetime.fromisoformat(ts) + except (TypeError, ValueError): + return None + return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt + + +def _fmt_ts(ts: Optional[str]) -> str: + if not ts: + return "never" + dt = _parse_ts(ts) + if dt is None: + return str(ts) + secs = int((datetime.now(timezone.utc) - dt).total_seconds()) + for unit, div, limit in (("s", 1, 60), ("m", 60, 3600), ("h", 3600, 86400)): + if secs < limit: + return f"{secs // div}{unit} ago" + return f"{secs // 86400}d ago" + + +def _confirm(prompt: str, cancel: str = "cancelled", eof_prefix: str = "\n") -> bool: + """Ask ``prompt``; print ``cancel`` (prefixed on EOF/Ctrl-C) and return False unless y/yes.""" + try: + if input(prompt).strip().lower() in {"y", "yes"}: + return True + except (EOFError, KeyboardInterrupt): + print(eof_prefix, end="") + print(cancel) + return False + + +def _print_skill_rows(title: str, rows: list) -> None: + print(f"\n{title}:") + for r in rows: + print( + f" {r['name']:40s} " + f"activity={r.get('activity_count', 0):3d} " + f"use={r.get('use_count', 0):3d} " + f"view={r.get('view_count', 0):3d} " + f"patches={r.get('patch_count', 0):3d} " + f"last_activity={_fmt_ts(r.get('last_activity_at'))}") + + +def _print_unmanaged_summary() -> None: + """Report curation-eligible skills that carry no provenance marker: only background-review + creations get ``created_by: agent``; older skills and every foreground ``skill_manage(create)`` + are eligible but unmanaged, so no automatic transition touches them.""" + from tools import skill_usage + try: + unmanaged = skill_usage.unmanaged_report() + except Exception: + return + if not unmanaged: + return + legacy = sum(1 for r in unmanaged if not r.get("has_provenance_key")) + foreground = len(unmanaged) - legacy + print(f"\nunmanaged (no provenance marker): {len(unmanaged)} total") + print(f" pre-dates marker {legacy}") + print(f" foreground-created {foreground}") + print(" never auto-staled or archived — `hermes curator adopt ` hands one over") + + +def _print_curator_config(curator) -> None: + state = curator.load_state() + status_line = ( + "PAUSED" if state.get("paused", False) + else "ENABLED" if curator.is_enabled() else "DISABLED") + print(f"curator: {status_line}") + print(f" runs: {state.get('run_count', 0)}") + print(f" last run: {_fmt_ts(state.get('last_run_at'))}") + # Multi-line when the curator archived skills (rename map appended as `name → umbrella` + # lines); indent continuation lines so the block reads as one field. + summary = state.get("last_run_summary") or "(none)" + first, *rest = summary.splitlines() if "\n" in summary else [summary] + print(f" last summary: {first}") + for line in rest: + print(f" {line}") + report = state.get("last_report_path") + if report: + print(f" last report: {report}{'' if Path(report).exists() else ' (missing)'}") + ih = curator.get_interval_hours() + print(f" interval: every {f'{ih // 24}d' if ih % 24 == 0 and ih >= 24 else f'{ih}h'}") + print(f" stale after: {curator.get_stale_after_days()}d unused") + print(f" archive after: {curator.get_archive_after_days()}d unused") + consolidate = curator.get_consolidate() + print( + f" consolidate: {'on' if consolidate else 'off'}" + f"{'' if consolidate else ' (prune-only; LLM merge pass opt-in)'}") + + +def _cmd_status(args) -> int: + from agent import curator + from tools import skill_usage + _print_curator_config(curator) + rows = skill_usage.curated_report() + if not rows: + print("\nno curator-managed skills") + _print_unmanaged_summary() + return 0 + by_state: dict = {} + for r in rows: + by_state.setdefault(r.get("state", "active"), []).append(r) + pinned = [r["name"] for r in rows if r.get("pinned")] + provenance = [r.get("provenance", "agent") for r in rows] + print(f"\ncurator-managed skills: {len(rows)} total " + f"(agent-created={provenance.count('agent')} bundled={provenance.count('bundled')})") + for state_name in ("active", "stale", "archived"): + print(f" {state_name:10s} {len(by_state.get(state_name, []))}") + if pinned: + print(f"\npinned ({len(pinned)}): {', '.join(pinned)}") + _print_unmanaged_summary() # the curation blind spot matters on the managed path too + # Views and edits count as activity: a skill must not read as "never used" right after + # skill_view()/skill_manage() touched it. Recency (last_activity_at) and frequency + # (activity_count) are different signals, so both top-5s are shown. + active_all = by_state.get("active", []) + if not active_all: + return 0 + recency = sorted( + active_all, key=lambda r: r.get("last_activity_at") or r.get("created_at") or "") + _print_skill_rows("least recently active (top 5)", recency[:5]) + + def _freq(r): + return (r.get("activity_count") or 0, r.get("last_activity_at") or "") + + most_active = sorted(active_all, key=_freq, reverse=True)[:5] + if (most_active[0].get("activity_count") or 0) > 0: + _print_skill_rows("most active (top 5)", most_active) + _print_skill_rows("least active (top 5)", sorted(active_all, key=_freq)[:5]) + return 0 + + +def _cmd_run(args) -> int: + from agent import curator + if not curator.is_enabled(): + print("curator: disabled via config; enable with `curator.enabled: true`") + return 1 + dry = bool(getattr(args, "dry_run", False)) + background = bool(getattr(args, "background", False)) + synchronous = bool(getattr(args, "synchronous", False)) or not background + # --consolidate forces the LLM umbrella pass on; absent => None so run_curator_review reads + # curator.consolidate from config. + consolidate = True if getattr(args, "consolidate", False) else None + print( + "curator: running DRY-RUN (report only, no mutations)..." if dry + else "curator: running review pass...") + if consolidate is None and not curator.get_consolidate(): + print( + "curator: consolidation is off — running prune-only " + "(deterministic stale/archive). Pass --consolidate or set " + "`curator.consolidate: true` to enable the LLM merge pass.") + result = curator.run_curator_review( + on_summary=print, synchronous=synchronous, dry_run=dry, consolidate=consolidate) + auto = result.get("auto_transitions", {}) + if auto and dry: + print( + f"auto (preview): {auto.get('checked', 0)} candidate skill(s) " + "— no transitions applied in dry-run") + elif auto: + print( + f"auto: checked={auto.get('checked', 0)} " + f"stale={auto.get('marked_stale', 0)} " + f"archived={auto.get('archived', 0)} " + f"reactivated={auto.get('reactivated', 0)}") + if not synchronous: + print("llm pass running in background — check `hermes curator status` later") + if dry: + print( + "dry-run: no changes applied. Read the report with " + "`hermes curator status` and run `hermes curator run` (no flag) to apply." + if synchronous else + "dry-run: no changes applied. When the report lands, read it with " + "`hermes curator status` and run `hermes curator run` (no flag) to apply.") + return 0 + + +def _set_paused(paused: bool) -> int: + from agent import curator + curator.set_paused(paused) + print("curator: paused" if paused else "curator: resumed") + return 0 + + +def _cmd_pause(args) -> int: return _set_paused(True) +def _cmd_resume(args) -> int: return _set_paused(False) + + +_PIN_MESSAGES = { + True: ( + "cannot pin (only agent-created skills participate in curation)", + "could not pin '{skill}' — the skill is not curation-eligible (protected built-in or " + "external). `hermes curator list-unmanaged` shows which skills the curator tracks.", + # Unmanaged skills are never auto-transitioned, so the pin is recorded but only + # becomes protective once the skill is adopted — say so and point at `adopt`. + "pinned '{skill}' (recorded; this skill is unmanaged — auto-transitions never consider " + "it. Run `hermes curator adopt {skill}` to put it under curator management)", + "pinned '{skill}' (will bypass auto-transitions)"), + False: ( + "there's nothing to unpin (curator only tracks agent-created skills)", + "could not unpin '{skill}' — the skill is not curation-eligible (protected built-in or " + "external).", + "unpinned '{skill}' (recorded; this skill is unmanaged — it was never under " + "auto-transitions to begin with)", + "unpinned '{skill}'")} + + +def _set_pin(args, pinned: bool) -> int: + from tools import skill_usage + not_agent, not_eligible, unmanaged, done = _PIN_MESSAGES[pinned] + skill = args.skill + if not skill_usage.is_agent_created(skill): + print(f"curator: '{skill}' is bundled or hub-installed — {not_agent}") + return 1 + if not skill_usage.set_pinned(skill, pinned): + print("curator: " + not_eligible.replace("{skill}", skill)) + return 1 + if not skill_usage.is_curator_managed(skill): + print("curator: " + unmanaged.replace("{skill}", skill)) + return 0 + print("curator: " + done.replace("{skill}", skill)) + return 0 + + +def _cmd_pin(args) -> int: return _set_pin(args, True) +def _cmd_unpin(args) -> int: return _set_pin(args, False) + + +def _cmd_list_unmanaged(args) -> int: + """Itemize the unmanaged population that `status` summarizes (input for `adopt`).""" + from tools import skill_usage + rows = skill_usage.unmanaged_report() + if not rows: + print("curator: no unmanaged skills — every eligible skill is managed") + return 0 + print(f"unmanaged skills ({len(rows)}):") + for r in sorted(rows, key=lambda x: x["name"]): + why = "created_by:null" if r.get("has_provenance_key") else "no marker" + print( + f" {r['name']:44s} activity={r.get('activity_count', 0):4d} " + f"last_activity={_fmt_ts(r.get('last_activity_at')):14s} ({why})") + print("\nadopt one with `hermes curator adopt `, " + "or all with `hermes curator adopt --all-unmanaged`") + return 0 + + +def _cmd_adopt(args) -> int: + """Hand unmanaged skills to the curator by explicit user declaration: provenance cannot be + inferred from telemetry (a high patch count proves the agent MAINTAINS a skill, not that it + AUTHORED it).""" + from tools import skill_usage + names = list(getattr(args, "skill", None) or []) + adopt_all = bool(getattr(args, "all_unmanaged", False)) + if adopt_all: + if names: + print("curator: pass either skill names or --all-unmanaged, not both") + return 1 + names = skill_usage.list_unmanaged_skill_names() + if not names: + print("curator: no unmanaged skills to adopt") + return 0 + if not names: + print("curator: name a skill to adopt, or pass --all-unmanaged") + return 1 + if getattr(args, "dry_run", False): + print(f"curator: would adopt {len(names)} skill(s) (dry run):") + for n in names: + print(f" + {n}") + return 0 + # Bulk adoption is a lifecycle change (adopted skills become archivable): confirm. + if adopt_all and not getattr(args, "yes", False): + print(f"curator: adopt {len(names)} unmanaged skill(s) into curator management?") + print(" they become eligible for automatic staleness + archival") + if not _confirm(" proceed? [y/N] ", "curator: aborted", eof_prefix=""): + return 1 + failed = 0 + for n in names: + ok, msg = skill_usage.adopt_skill(n) + print(f"curator: {msg}") + failed += not ok + if len(names) > 1: + print(f"curator: adopted {len(names) - failed}/{len(names)}") + return 1 if failed else 0 + + +def _as_user(fn, skill: str) -> int: + """Run a skill mutation with the ledger actor set to ``user``; print and map its result.""" + from tools import skill_ledger + tok = skill_ledger.set_ledger_actor("user") + try: + ok, msg = fn(skill) + finally: + skill_ledger.reset_ledger_actor(tok) + print(f"curator: {msg}") + return 0 if ok else 1 + + +def _cmd_restore(args) -> int: + from tools import skill_usage + return _as_user(skill_usage.restore_skill, args.skill) + + +def _cmd_archive(args) -> int: + """Manually archive an agent-created skill. Refuses if pinned.""" + from tools import skill_usage + if skill_usage.get_record(args.skill).get("pinned"): + print( + f"curator: '{args.skill}' is pinned — unpin first with " + f"`hermes curator unpin {args.skill}`") + return 1 + return _as_user(skill_usage.archive_skill, args.skill) + + +def _idle_days(record: dict) -> Optional[int]: + """Days since last activity, falling back to ``created_at`` so never-used skills aren't + immortal; None only when both fields are missing or unparseable.""" + ts = record.get("last_activity_at") or record.get("created_at") + dt = _parse_ts(str(ts)) if ts else None + return None if dt is None else max(0, (datetime.now(timezone.utc) - dt).days) + + +def _cmd_prune(args) -> int: + """Bulk-archive curator-managed skills idle for >= N days (pinned exempt, archived skipped).""" + from agent import curator + from tools import skill_usage + days = getattr(args, "days", None) + if days is None: + days = curator.get_archive_after_days() + if days < 1: + print(f"curator: --days must be >= 1 (got {days})", file=sys.stderr) + return 2 + candidates = [ + (r["name"], idle) for r in skill_usage.curated_report() + if not (r.get("pinned") or r.get("state") == skill_usage.STATE_ARCHIVED) + and (idle := _idle_days(r)) is not None and idle >= days] + if not candidates: + print(f"curator: nothing to prune (no unpinned skills idle >= {days}d)") + return 0 + candidates.sort(key=lambda c: -c[1]) + print(f"curator: {len(candidates)} skill(s) idle >= {days}d:") + for name, idle in candidates: + print(f" {name:40s} idle {idle}d") + if getattr(args, "dry_run", False): + print("\n(dry run — no changes made)") + return 0 + if not getattr(args, "yes", False) and not _confirm( + f"\nArchive {len(candidates)} skill(s)? [y/N] ", "curator: aborted"): + return 1 + results = [(name, *skill_usage.archive_skill(name)) for name, _ in candidates] + failures = [(name, msg) for name, ok, msg in results if not ok] + print(f"\ncurator: archived {len(results) - len(failures)}/{len(candidates)}") + if failures: + print("failures:") + for name, msg in failures: + print(f" {name}: {msg}") + return 1 + return 0 + + +def _cmd_backup(args) -> int: + """Manual skills-tree snapshot (same mechanism as the automatic pre-run snapshot).""" + from agent import curator_backup + if not curator_backup.is_enabled(): + print( + "curator: backups are disabled via config " + "(`curator.backup.enabled: false`); re-enable to snapshot") + return 1 + snap = curator_backup.snapshot_skills(reason=getattr(args, "reason", None) or "manual") + if snap is None: + print("curator: snapshot failed — check logs (backup disabled or IO error)") + return 1 + print(f"curator: snapshot created at ~/.hermes/skills/.curator_backups/{snap.name}") + return 0 + + +def _cmd_ledger(args) -> int: + """List per-mutation audit ledger entries (newest first).""" + from tools import skill_ledger + rows = skill_ledger.list_entries( + skill=getattr(args, "skill", None), limit=getattr(args, "limit", None) or 20) + if not rows: + print("curator: ledger is empty (or skills.ledger is disabled).") + return 0 + print(f"{'id':<14} {'when':<12} {'actor':<8} {'action':<12} skill") + for r in rows: + evidence = r.get("evidence") or {} + extra = "" + if evidence.get("absorbed_into"): + extra = f" → absorbed into '{evidence['absorbed_into']}'" + elif evidence.get("rollback_target"): + extra = f" → rollback of {evidence['rollback_target']}" + print( + f"{r.get('id', '?'):<14} {_fmt_ts(r.get('ts')):<12} " + f"{r.get('actor', '?'):<8} {r.get('action', '?'):<12} " + f"{r.get('skill', '?')}{extra}") + print( + "\nRoll back a single mutation with `hermes curator rollback `; " + "whole-tree snapshots remain available via `hermes curator rollback --list`.") + return 0 + + +def _cmd_purge(args) -> int: + """Delete archived skills older than curator.archive_ttl_days. Explicit only — never + automatic; each purged skill is captured (before-blobs) and recorded as a 'purge' ledger + entry, so even a purge is auditable and blob-recoverable.""" + import shutil + import time + from hermes_cli.config import cfg_get, load_config + from tools import skill_ledger + from tools.skill_usage import _archive_dir + ttl_days = getattr(args, "days", None) + if ttl_days is None: + ttl_days = int(cfg_get(load_config(), "curator", "archive_ttl_days", default=0) or 0) + if ttl_days <= 0: + print( + "curator: purge disabled (curator.archive_ttl_days is 0). Set the " + "config key or pass --days N to purge archives older than N days.") + return 1 + archive_root = _archive_dir() + if not archive_root.exists(): + print("curator: no archive directory — nothing to purge.") + return 0 + cutoff = time.time() - ttl_days * 86400 + candidates = sorted( + p for p in archive_root.iterdir() if p.is_dir() and p.stat().st_mtime < cutoff) + if not candidates: + print(f"curator: no archived skills older than {ttl_days}d.") + return 0 + print(f"Archived skills older than {ttl_days}d:") + for p in candidates: + print(f" {p.name}") + if getattr(args, "dry_run", False): + print("(dry run — nothing deleted)") + return 0 + if not getattr(args, "yes", False) and not _confirm( + f"Permanently delete {len(candidates)} archived skill(s)? [y/N] "): + return 1 + purged = 0 + for p in candidates: + before = skill_ledger.capture_before(p, complete_package=True, skill=p.name) + try: + shutil.rmtree(p) + except OSError as e: + print(f"curator: failed to purge {p.name}: {e}") + continue + skill_ledger.append_entry( + "purge", p.name, before=before or [], after=[], actor="user", + evidence={"ttl_days": ttl_days}) + purged += 1 + print(f"curator: purged {purged} archived skill(s). Ledger entries recorded.") + return 0 + + +def _report_rollback(ok: bool, msg: str) -> int: + print(f"curator: {msg}" if ok else f"curator: rollback failed — {msg}") + return 0 if ok else 1 + + +def _rollback_ledger_entry(args, entry_id: str) -> int: + """Restore exactly the files touched by one ledger entry (from content-addressed blobs); a + pre-rollback safety ledger entry is taken first and it fails closed if that capture fails.""" + from tools import skill_ledger + entry = skill_ledger.get_entry(entry_id) + if entry is None: + print( + f"curator: no ledger entry '{entry_id}'. " + "See `hermes curator ledger` for entry ids, or use " + "`--id ` for whole-tree snapshot rollback.") + return 1 + print(f"Rollback target: ledger entry {entry_id}") + print(f" action: {entry.get('action', '?')}") + print(f" skill: {entry.get('skill', '?')}") + print(f" actor: {entry.get('actor', '?')}") + print(f" when: {entry.get('ts', '?')}") + touched = {i.get("path") for i in (entry.get("before") or []) + (entry.get("after") or [])} + print(f" files: {len(touched)}") + if not getattr(args, "yes", False) and not _confirm( + "Restore this mutation's before-state? [y/N] "): + return 1 + return _report_rollback(*skill_ledger.rollback_entry(entry_id)) + + +def _cmd_rollback(args) -> int: + """Restore the skills tree from a snapshot, or a single mutation from the audit ledger.""" + from agent import curator_backup + entry_id = getattr(args, "entry_id", None) + if entry_id: + return _rollback_ledger_entry(args, entry_id) + if getattr(args, "list", False): + print(curator_backup.summarize_backups()) + return 0 + backup_id = getattr(args, "backup_id", None) + target_path = curator_backup._resolve_backup(backup_id) + if target_path is None: + if not curator_backup.list_backups(): + print( + "curator: no snapshots exist yet. Take one with " + "`hermes curator backup` or wait for the next curator run.") + else: + print( + f"curator: no snapshot matching " + f"{'id ' + repr(backup_id) if backup_id else 'your query'}.") + print("Available:") + print(curator_backup.summarize_backups()) + return 1 + manifest = curator_backup._read_manifest(target_path) + print(f"Rollback target: {target_path.name}") + if manifest: + print(f" reason: {manifest.get('reason', '?')}") + print(f" created_at: {manifest.get('created_at', '?')}") + print(f" skill files: {manifest.get('skill_files', '?')}") + cron = manifest.get("cron_jobs") or {} + if isinstance(cron, dict): + if cron.get("backed_up"): + print( + f" cron jobs: {cron.get('jobs_count', 0)} " + f"(will be restored for skill-link fields only)") + else: + print(f" cron jobs: not in snapshot ({cron.get('reason', 'not captured')})") + print( + "\nThis will replace the current ~/.hermes/skills/ tree (a safety " + "snapshot of the current state is taken first so this is undoable). " + "Cron jobs that still exist will have their skills/skill fields " + "restored from the snapshot; all other cron fields are left alone.") + if not getattr(args, "yes", False) and not _confirm("Proceed? [y/N] "): + return 1 + ok, msg, _ = curator_backup.rollback(backup_id=target_path.name) + return _report_rollback(ok, msg) + + +def _cmd_list_archived(args) -> int: + """List archived (recoverable) skills.""" + from tools import skill_usage + names = skill_usage.list_archived_skill_names() + print("\n".join(names) if names else "curator: no archived skills") + return 0 + + +_USAGE_SORTS = { + "name": (lambda r: r["name"], False), + "recent": (lambda r: r.get("last_activity_at") or "", True), + "activity": (lambda r: r.get("activity_count", 0), True)} + + +def _cmd_usage(args) -> int: + """Usage telemetry for ALL skills on disk (bundled + hub included), with provenance.""" + import json as _json + from tools import skill_usage + rows = skill_usage.usage_report() + prov_filter = getattr(args, "provenance", None) + if prov_filter: + rows = [r for r in rows if r.get("provenance") == prov_filter] + # name: alphabetical; recent: most-recently-active first (never-active sinks); activity + # (default): most-used first. + key, reverse = _USAGE_SORTS.get(getattr(args, "sort", "activity"), _USAGE_SORTS["activity"]) + rows.sort(key=key, reverse=reverse) + if getattr(args, "json", False): + print(_json.dumps(rows, indent=2, ensure_ascii=False)) + return 0 + if not rows: + print("curator: no skills found") + return 0 + provenance = [r.get("provenance", "agent") for r in rows] + counts = {k: provenance.count(k) for k in ("agent", "bundled", "hub")} + print( + f"skills: {len(rows)} total " + f"(agent={counts['agent']} bundled={counts['bundled']} hub={counts['hub']})\n") + print( + f" {'skill':40s} {'origin':8s} " + f"{'use':>4s} {'view':>4s} {'patch':>5s} {'act':>4s} last_activity") + for r in rows: + print( + f" {r['name'][:40]:40s} " + f"{r.get('provenance', 'agent'):8s} " + f"{r.get('use_count', 0):>4d} " + f"{r.get('view_count', 0):>4d} " + f"{r.get('patch_count', 0):>5d} " + f"{r.get('activity_count', 0):>4d} " + f"{_fmt_ts(r.get('last_activity_at'))}") + return 0 + + +def _arg(*flags, **kwargs): + return flags, kwargs + + +_SKILL = _arg("skill", help="Skill name") +_YES = _arg("-y", "--yes", action="store_true", help="Skip the confirmation prompt") +_STORE_TRUE = dict(action="store_true") + +# argparse wiring: (name, help, handler, *(flags, add_argument kwargs)) +_SUBCOMMANDS = ( + ("status", "Show curator status and skill stats", _cmd_status), + ( + "usage", "Show usage telemetry for ALL skills (built-in, hub, agent) with provenance", + _cmd_usage, + _arg("--sort", choices=("activity", "recent", "name"), default="activity", + help="Sort order: activity (most-used first, default), recent " + "(most-recently-active first), or name (alphabetical)"), + _arg("--provenance", choices=("agent", "bundled", "hub"), default=None, + help="Only show skills of this origin"), + _arg("--json", **_STORE_TRUE, help="Emit the full report as JSON instead of a table")), + ( + "run", "Trigger a curator review now", _cmd_run, + _arg("--sync", "--synchronous", dest="synchronous", **_STORE_TRUE, + help="Wait for the LLM review pass to finish (default for manual runs)"), + _arg("--background", dest="background", **_STORE_TRUE, + help="Start the LLM review pass in a background thread and return immediately"), + _arg("--dry-run", dest="dry_run", **_STORE_TRUE, + help="Report only — no state changes, no archives, no consolidation " + "(use this to preview what curator would do)"), + _arg("--consolidate", dest="consolidate", **_STORE_TRUE, + help="Force the LLM umbrella-building consolidation pass on for this " + "run, overriding the config default (off). Without this flag the " + "run is prune-only unless `curator.consolidate: true` is set.")), + ("pause", "Pause the curator until resumed", _cmd_pause), + ("resume", "Resume a paused curator", _cmd_resume), + ("pin", "Pin a skill so the curator never auto-transitions it", _cmd_pin, _SKILL), + ("unpin", "Unpin a skill", _cmd_unpin, _SKILL), + ("list-unmanaged", "List curation-eligible skills with no provenance marker", + _cmd_list_unmanaged), + ( + "adopt", "Hand unmanaged skills to the curator (provenance is a user declaration)", + _cmd_adopt, + _arg("skill", nargs="*", help="Skill name(s) to adopt. Omit when using --all-unmanaged."), + _arg("--all-unmanaged", **_STORE_TRUE, + help="Adopt every curation-eligible skill that has no provenance marker"), + _arg("--dry-run", **_STORE_TRUE, + help="List what would be adopted without writing anything"), + _arg("--yes", **_STORE_TRUE, help="Skip the confirmation prompt for --all-unmanaged")), + ("restore", "Restore an archived skill", _cmd_restore, _SKILL), + ("list-archived", "List archived skills", _cmd_list_archived), + ("archive", "Manually archive a skill (move to .archive/, excluded from prompt)", _cmd_archive, + _SKILL), + ( + "prune", "Bulk-archive curator-managed skills idle for >= N days (default: curator.archive_after_days)", + _cmd_prune, + _arg("--days", type=int, default=None, + help="Archive skills idle for at least N days (default: curator.archive_after_days, 30)"), + _YES, + _arg("--dry-run", dest="dry_run", **_STORE_TRUE, + help="Show what would be archived without doing it")), + ( + "backup", + "Take a manual tar.gz snapshot of ~/.hermes/skills/ " + "(curator also does this automatically before every real run)", + _cmd_backup, + _arg("--reason", default=None, + help="Free-text label stored in manifest.json (default: 'manual')")), + ( + "rollback", + "Restore ~/.hermes/skills/ from a curator snapshot, or a single " + "mutation by ledger entry id (see `hermes curator ledger`)", + _cmd_rollback, + _arg("entry_id", nargs="?", default=None, + help="Ledger entry id for single-mutation rollback (from " + "`hermes curator ledger`). Omit for whole-tree snapshot rollback."), + _arg("--list", **_STORE_TRUE, help="List available snapshots and exit without restoring"), + _arg("--id", dest="backup_id", default=None, + help="Snapshot id to restore (see `--list`); default: newest"), + _arg("-y", "--yes", **_STORE_TRUE, help="Skip confirmation prompt")), + ( + "ledger", "List the per-mutation skill audit ledger (all actors: curator/agent/user)", + _cmd_ledger, + _arg("--skill", default=None, help="Only show entries for this skill"), + _arg("--limit", type=int, default=20, help="Max entries to show (default: 20)")), + ( + "purge", + "Delete archived skills older than curator.archive_ttl_days " + "(explicit only — never automatic; recorded in the ledger)", + _cmd_purge, + _arg("--days", type=int, default=None, + help="Override curator.archive_ttl_days for this invocation"), + _arg("--dry-run", dest="dry_run", **_STORE_TRUE, + help="Show what would be purged without deleting"), + _YES)) + + +def register_cli(parent: argparse.ArgumentParser) -> None: + """Attach `curator` subcommands to *parent*.""" + parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1]) + subs = parent.add_subparsers(dest="curator_command") + for name, help_text, handler, *arguments in _SUBCOMMANDS: + sub = subs.add_parser(name, help=help_text) + for flags, arg_kwargs in arguments: + sub.add_argument(*flags, **arg_kwargs) + sub.set_defaults(func=handler) + + +def cli_main(argv=None) -> int: + """Standalone entry (also usable by hermes_cli.main fallthrough).""" + parser = argparse.ArgumentParser(prog="hermes curator") + register_cli(parser) + args = parser.parse_args(argv) + return int(args.func(args) or 0) + + +if __name__ == "__main__": # pragma: no cover + sys.exit(cli_main()) diff --git a/hermes_cli/desktop_update_verify.py b/hermes_cli/desktop_update_verify.py new file mode 100644 index 0000000000000000000000000000000000000000..a847f889cfce9f1f03a9087b0d02f44022d770b5 --- /dev/null +++ b/hermes_cli/desktop_update_verify.py @@ -0,0 +1,95 @@ +"""Read-only verification at the Windows Desktop handoff receipt boundary.""" +import json +from pathlib import Path, PurePosixPath +import re +import struct + +from hermes_cli.main_desktop import ( + _HTML_TAG_WITH_URL, + _MODULE_TAG, + _desktop_build_needed, + _desktop_exe_integrity_error, + _desktop_packaged_executable, +) + + +def _verify_packaged_entry(resources: Path) -> None: + """Read ASAR's Pickle header and the entry declared by packaged package.json. + + dist/** is unpacked by electron-builder. No Node install or application + launch is needed at this boundary; this is not a full dependency audit. + """ + archive = resources / "app.asar" + try: + with archive.open("rb") as stream: + size, header_size, payload_size, json_size = struct.unpack("<4I", stream.read(16)) + if (size != 4 or header_size != payload_size + 4 + or payload_size != 4 + ((json_size + 3) // 4) * 4 + or not 0 < json_size <= 64 * 1024 * 1024 + or 8 + header_size > archive.stat().st_size): + raise ValueError("invalid ASAR header") + header = json.loads(stream.read(json_size)) + + def read_member(name: str) -> bytes: + path = PurePosixPath(name) + if not name or path.is_absolute() or ".." in path.parts or "\\" in name or ":" in name: + raise ValueError("invalid ASAR entry path") + node = header + for part in path.parts: + node = node["files"][part] + length = node["size"] + if not isinstance(length, int) or length <= 0: + raise ValueError(f"empty ASAR entry: {name}") + if node.get("unpacked"): + data = (resources / "app.asar.unpacked" / path).read_bytes() + else: + offset = int(node["offset"]) + if offset < 0 or 8 + header_size + offset + length > archive.stat().st_size: + raise ValueError(f"truncated ASAR entry: {name}") + stream.seek(8 + header_size + offset) + data = stream.read(length) + if len(data) != length or not data.strip(): + raise ValueError(f"incomplete ASAR entry: {name}") + return data + + package = json.loads(read_member("package.json")) + read_member(package["main"]).decode("utf-8") + except (OSError, ValueError, KeyError, TypeError, struct.error) as exc: + raise RuntimeError(f"The updated Desktop archive or main entry is invalid: {exc}") from exc + + index = resources / "app.asar.unpacked" / "dist" / "index.html" + try: + html = index.read_text(encoding="utf-8") + if not any(_MODULE_TAG.search(match.group(0)) + and match.group(0).lower().startswith(" Path: + """The checkout this module was imported from: the only root the receipt may describe.""" + return Path(__file__).resolve().parent.parent + + +def verify_windows_desktop_update(project_root: Path | None = None) -> None: + """Raise when a zero-exit updater left an incomplete or stale packaged app. + + The root defaults to the imported checkout, never the caller's cwd: the hand-off + is spawned from HERMES_HOME by the pre-update Desktop, and a cwd-derived root + reported a healthy install as "Desktop executable is missing" (Sep 2026). + """ + if project_root is None: + project_root = checkout_root() + desktop = project_root / "apps" / "desktop" + executable = _desktop_packaged_executable(desktop) + if executable is None: + raise RuntimeError("The updated Desktop executable is missing") + error = _desktop_exe_integrity_error(executable) + if error: + raise RuntimeError(f"The updated Desktop executable is invalid: {error}") + _verify_packaged_entry(executable.parent / "resources") + if _desktop_build_needed(desktop, project_root, source_mode=False): + raise RuntimeError("The updated Desktop build is stale, unstamped, or incomplete") diff --git a/hermes_cli/focus_view.py b/hermes_cli/focus_view.py new file mode 100644 index 0000000000000000000000000000000000000000..0f7799ac5529f6deee083e08420bba7283057dfd --- /dev/null +++ b/hermes_cli/focus_view.py @@ -0,0 +1,104 @@ +"""Focus view — display-only reduced output: "just my prompt and the answer, and tell me what you hid". +ON snaps ``tool_progress_mode`` to ``"off"`` and remembers the configured mode so the *existing* suppression +path does the hiding; OFF restores it verbatim. Focus adds a per-turn hidden-line count with a recovery +hint and a persistent ``focus`` status-bar segment.""" + +from __future__ import annotations + +from typing import Optional + +FOCUS_CONFIG_KEY = "display.focus_view" # plain boolean under ``display``, like /battery /timestamps /footer +FOCUS_TOOL_PROGRESS_MODE = "off" # the SAME value ``/verbose off`` uses so both features share one suppression path +# Modes in which the CLI commits a per-tool scrollback line. Mirrors the gate in +# ``HermesCLI._on_tool_progress`` so the hidden-line counter and the renderer never drift apart. +TOOL_PROGRESS_VISIBLE_MODES = frozenset({"new", "all", "verbose"}) +TOOL_PROGRESS_MODES = ("off", "new", "all", "verbose") # ``log`` is a gateway-only extra step +FOCUS_STATUSBAR_LABEL = "◉ focus" # short on purpose — the bar is width-constrained + +# /focus argument words -> (action, target); bare /focus toggles like /footer, /battery, /timestamps. +_FOCUS_WORDS = { + **dict.fromkeys(("status", "show", "?"), ("status", None)), + **dict.fromkeys(("on", "enable", "enabled", "true", "yes", "1"), ("set", True)), + **dict.fromkeys(("off", "disable", "disabled", "false", "no", "0"), ("set", False)), +} + + +def normalize_tool_progress_mode(mode: object, default: str = "all") -> str: + """Coerce a raw config/attr value into a known tool-progress mode.""" + if mode is False: + return "off" + if mode is True: + return "all" + text = str(mode or "").strip().lower() + # ``log`` is a real gateway mode; any other unknown value becomes the default. + return text if text in TOOL_PROGRESS_MODES or text == "log" else default + + +def resolve_focus_arg(arg: str, current: bool) -> tuple[str, Optional[bool]]: + """Map a ``/focus`` argument onto ``(action, target)``: action is ``"set"``, ``"status"`` or ``"usage"``; + target is the requested enabled-state for ``"set"`` and ``None`` otherwise.""" + text = str(arg or "").strip().lower() + if text in ("", "toggle"): + return "set", not bool(current) + return _FOCUS_WORDS.get(text, ("usage", None)) + + +def would_display_tool_line(mode: object, function_name: str, last_tool_name: Optional[str] = None) -> bool: + """Would the CLI have committed a scrollback line for this tool call? Counts honestly: with ``/verbose off`` + focus view hides nothing extra and must not claim otherwise. ``new`` mode skips consecutive repeats of the + same tool, so the counter does too.""" + if not function_name: + return False + normalized = normalize_tool_progress_mode(mode) + return normalized in TOOL_PROGRESS_VISIBLE_MODES and not (normalized == "new" and function_name == last_tool_name) + + +def format_hidden_line(count: int) -> Optional[str]: + """Dim post-turn recovery line, or ``None`` when nothing was hidden.""" + try: + n = int(count) + except (TypeError, ValueError): + return None + return f"⋯ {n} {'tool line' if n == 1 else 'tool lines'} hidden · /focus off to show" if n > 0 else None + + +def focus_statusbar_segment(enabled: bool) -> str: + """Status-bar segment text for focus view (empty when off).""" + return FOCUS_STATUSBAR_LABEL if enabled else "" + + +def format_focus_status(enabled: bool, configured_mode: object) -> str: + """Human-readable ``/focus status`` body (no ANSI — callers colour it).""" + mode = normalize_tool_progress_mode(configured_mode).upper() + if enabled: + return f"Focus view: ON — only your prompt and the final response.\n /focus off restores tool progress: {mode}" + return f"Focus view: OFF — tool progress: {mode}" + + +def format_focus_toggle_message(enabled: bool, configured_mode: object) -> str: + """Confirmation line printed when focus view is switched (no ANSI).""" + if enabled: + return "Focus view enabled — just your prompt and the final response" + return f"Focus view disabled — tool progress: {normalize_tool_progress_mode(configured_mode).upper()}" + + +# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- +# Names external plugins imported from this module before the Sep 2026 decomposition. +# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does). +# The whole block is removed by reverting the commit that added it. + +FOCUS_USAGE = "Usage: /focus [on|off|status]" + +def effective_tool_progress_mode(focus_enabled: bool, configured_mode: object) -> str: + """Return the tool-progress mode that should actually be in force. + + Focus view wins while it is on (it *is* "tool progress off" plus reporting). + When focus is off the user's configured mode is returned untouched — this is + what makes ``/focus off`` restore ``/verbose verbose`` rather than clobbering + it to ``all``. + """ + normalized = normalize_tool_progress_mode(configured_mode) + if focus_enabled: + return FOCUS_TOOL_PROGRESS_MODE + return normalized +# ---- END PLUGIN-COMPAT ---- diff --git a/hermes_cli/goal_command.py b/hermes_cli/goal_command.py new file mode 100644 index 0000000000000000000000000000000000000000..e334192f810f850297a33587127555f4bf433237 --- /dev/null +++ b/hermes_cli/goal_command.py @@ -0,0 +1,194 @@ +"""The CLI's /goal semantics, shared by every goal command surface. + +Adapters supply authorization and schedule the returned prompt; this module alone +parses subcommands and mutates goal state. It never changes conversation history. +""" +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Callable + +from hermes_cli import goals + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class GoalCommandResult: + output: str + prompt: str | None = None + kickoff: bool = False + clear_pending: str | None = None + error: bool = False + + +def _english(key, default, **values): + return default.format(**values) + + +def _status(mgr, arg, render): + return GoalCommandResult(mgr.status_line()) + + +def _show(mgr, arg, render): + return GoalCommandResult(f"{mgr.status_line()}\n{mgr.render_contract()}") + + +def _pause(mgr, arg, render): + state = mgr.pause(reason="user-paused") + return GoalCommandResult( + render("gateway.goal.paused", "⏸ Goal paused: {goal}", goal=state.goal) if state + else render("gateway.goal.no_goal_set", "No goal set."), + clear_pending="pause" if state else None, + ) + + +def _resume(mgr, arg, render): + state = mgr.resume() + if state is None: + return GoalCommandResult(render("gateway.goal.no_resume", "No goal to resume.")) + return GoalCommandResult(render("gateway.goal.resumed", "▶ Goal resumed: {goal}", goal=state.goal), + prompt=mgr.next_continuation_prompt()) + + +def _clear(mgr, arg, render): + had = mgr.has_goal() + mgr.clear() + return GoalCommandResult(render("gateway.goal_cleared", "✓ Goal cleared.") if had else + render("gateway.no_active_goal", "No active goal."), clear_pending="clear") + + +def _unwait(mgr, arg, render): + return GoalCommandResult("▶ Wait barrier cleared — goal loop resumes." if mgr.stop_waiting() + else "No wait barrier set.") + + +def _wait(mgr, arg): + if not arg: + return GoalCommandResult("Usage: /goal wait [reason]", error=True) + tokens = arg.split(None, 1) + try: + pid = int(tokens[0]) + except ValueError: + return GoalCommandResult("/goal wait: must be an integer process id.", error=True) + reason = tokens[1].strip() if len(tokens) > 1 else "" + mgr.wait_on(pid, reason=reason) + suffix = f" ({reason})" if reason else "" + return GoalCommandResult(f"⏳ Goal parked on pid {pid}{suffix}. Loop pauses until it exits.") + + +def _gate_add(mgr, arg): + gate = mgr.add_gate(arg) + return GoalCommandResult(f"⚿ Gate added: $ {gate.command} " + f"({gate.max_retries} retries, {gate.timeout_seconds}s timeout). " + "It must pass before the goal can complete.") + + +def _gate_remove(mgr, arg): + return GoalCommandResult(f"✓ Gate removed: $ {mgr.remove_gate(int(arg))}") + + +def _gate_clear(mgr, arg): + count = mgr.clear_gates() + return GoalCommandResult(f"✓ Cleared {count} gate{'s' if count != 1 else ''}.") + + +_GATE_HANDLERS = {"add": _gate_add, "remove": _gate_remove, "rm": _gate_remove, "clear": _gate_clear} +_EXACT_HANDLERS = { + "": _status, "status": _status, "show": _show, "pause": _pause, + "resume": _resume, "clear": _clear, "stop": _clear, "done": _clear, "unwait": _unwait, +} + + +def _gate(mgr, arg, authorize_gate): + if not arg or arg.lower() == "list": + return GoalCommandResult(mgr.render_gates()) + tokens = arg.split(None, 1) + verb, rest = tokens[0].lower(), tokens[1].strip() if len(tokens) > 1 else "" + handler = _GATE_HANDLERS.get(verb) + if handler is None or (verb == "clear" and rest) or (verb != "clear" and not rest): + return GoalCommandResult("Usage: /goal gate [list | add | remove | clear]", error=True) + # Gates run shell commands without a later approval. The adapter must explicitly + # authorize creation; recovery commands remain available to non-admin senders. + if verb == "add" and (denial := authorize_gate()): + return GoalCommandResult(denial, error=True) + try: + return handler(mgr, rest) + except (RuntimeError, ValueError, IndexError) as exc: + operation = "remove" if verb == "rm" else verb + return GoalCommandResult(f"/goal gate {operation}: {exc}", error=True) + + +def _set(mgr, arg, *, drafting, last_user_message, render, progress): + if drafting: + if not arg: + return GoalCommandResult("Usage: /goal draft ", error=True) + if progress is not None: + progress("Drafting completion contract…") + try: + contract = goals.draft_contract(arg) + except Exception as exc: + logger.debug("goal draft failed: %s", exc) + contract = None + headline = arg + else: + headline, contract = goals.parse_contract(arg) + contract = contract if not contract.is_empty() else None + state = mgr.set(headline or arg, contract=contract) + output = render("gateway.goal.set", "⊙ Goal set ({budget}-turn budget): {goal}", + budget=state.max_turns, goal=state.goal) + if state.has_contract(): + label = "Drafted completion contract:" if drafting else "Completion contract:" + output += f"\n{label}\n{state.contract.render_block()}" + if drafting: + output += ("\nTighten any field by re-setting the goal with inline lines " + "(e.g. verify: ), then /goal resume. Use /goal show to review." + if state.has_contract() else + "\nCouldn't draft a contract (aux model unavailable) — running as a " + "free-form goal. The per-turn judge still applies.") + else: + against = " against the contract above" if state.has_contract() else "" + output += (f"\nAfter each turn, a judge model checks if the goal is done{against}. " + "Hermes keeps working until it is, you pause/clear it, or the budget is " + "exhausted. Use /goal status, /goal show, /goal pause, /goal resume, /goal clear.") + return GoalCommandResult(output, goals.goal_kick_prompt(state.goal, last_user_message), kickoff=True) + + +def is_goal_control(arg: str) -> bool: + """Whether this command controls an existing goal rather than replacing it.""" + normalized = arg.strip().lower() + return normalized in _EXACT_HANDLERS or normalized.split(None, 1)[0] in {"wait", "gate"} + + +def dispatch_goal_command( + mgr: goals.GoalManager, arg: str, *, authorize_gate: Callable[[], str | None], + last_user_message=None, render: Callable = _english, + progress: Callable[[str], None] | None = None, +) -> GoalCommandResult: + """Apply one command. ``authorize_gate`` returns a denial or None (explicit approval). + + Synchronous like GoalManager: async adapters run this off-loop with copied + ContextVars so draft credentials and persisted I/O stay in the caller's profile. + """ + arg = arg.strip() + tokens = arg.split(None, 1) + verb = tokens[0].lower() if tokens else "" + rest = tokens[1].strip() if len(tokens) > 1 else "" + prefix = "Invalid goal" + try: + if handler := _EXACT_HANDLERS.get(arg.lower()): + return handler(mgr, "", render) + if verb == "wait": + prefix = "/goal wait" + return _wait(mgr, rest) + if verb == "gate": + prefix = "/goal gate" + return _gate(mgr, rest, authorize_gate) + return _set(mgr, rest if verb == "draft" else arg, + drafting=verb == "draft", last_user_message=last_user_message, + render=render, progress=progress) + except (RuntimeError, ValueError, IndexError) as exc: + output = (render("gateway.goal.invalid", "Invalid goal: {error}", error=str(exc)) + if prefix == "Invalid goal" else f"{prefix}: {exc}") + return GoalCommandResult(output, error=True) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py new file mode 100644 index 0000000000000000000000000000000000000000..2d4f80047ff87ec73b28d02eabff83ae09543ff3 --- /dev/null +++ b/hermes_cli/inventory.py @@ -0,0 +1,749 @@ +"""Provider/model inventory — shared substrate for the dashboard ``/api/model/options``, the TUI +``model.options``/``model.save_key`` RPC handlers, and the interactive picker.""" + +from __future__ import annotations + +from contextvars import copy_context +from dataclasses import dataclass, replace +from threading import Lock, Thread, current_thread +from typing import Any, Optional + +_pricing_prewarm_lock = Lock() +_pricing_prewarm_threads: dict[tuple[str, tuple[tuple[str, str], ...]], Thread] = {} + + +@dataclass(frozen=True) +class ConfigContext: + """Disk-config snapshot (``load_picker_context()``); the TUI overlays live agent state via + ``with_overrides()``.""" + + current_provider: str + current_model: str + current_base_url: str + user_providers: dict + custom_providers: list + excluded_providers: list = None + + def with_overrides( + self, *, current_provider: Optional[str] = None, current_model: Optional[str] = None, + current_base_url: Optional[str] = None, + ) -> "ConfigContext": + """Copy with TRUTHY overrides applied: the TUI reads agent attributes that may be empty strings + before an agent is spawned — empties must not clobber the disk-config values.""" + overrides = (("current_provider", current_provider), ("current_model", current_model), + ("current_base_url", current_base_url)) + kw = {k: v for k, v in overrides if v} + return replace(self, **kw) if kw else self + + +def load_picker_context() -> ConfigContext: + """Load the disk-config snapshot every consumer needs.""" + from hermes_cli.config import ( + coerce_provider_id, get_compatible_custom_providers, load_config, stringify_provider_map, + ) + cfg = load_config() + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + # PyYAML parses unquoted scalars as int (`provider: 2070`); keep strings so picker/options + # paths never call `.strip()` on an int. + current_model = str(model_cfg.get("default", model_cfg.get("name", "")) or "") + current_provider = coerce_provider_id(model_cfg.get("provider", "")) + current_base_url = str(model_cfg.get("base_url", "") or "") + else: # config.model can be a bare string in older configs + current_model, current_provider, current_base_url = (str(model_cfg) if model_cfg else ""), "", "" + excluded = cfg.get("model_catalog", {}).get("excluded_providers") or [] + return ConfigContext( + current_provider=current_provider, current_model=current_model, current_base_url=current_base_url, + user_providers=stringify_provider_map(cfg.get("providers")), + custom_providers=get_compatible_custom_providers(cfg), + excluded_providers=excluded if isinstance(excluded, list) else [], + ) + + +def _slug(row: dict) -> str: + return str(row.get("slug") or "").strip().lower() + + +def _without_slug(rows: list[dict], slug: str) -> list[dict]: + return [r for r in rows if _slug(r) != slug] + + +# ─── Public: payload builder ──────────────────────────────────────────── + + +def build_models_payload( + ctx: ConfigContext, *, explicit_only: bool = False, include_unconfigured: bool = False, + picker_hints: bool = False, canonical_order: bool = False, pricing: bool = False, + pricing_cache_only: bool = False, + capabilities: bool = False, featured: bool = False, force_fresh_nous_tier: bool = False, + refresh: bool = False, probe_custom_providers: bool = True, probe_current_custom_provider: bool = False, + for_picker: bool = False, max_models: int | None = None, +) -> dict: + """Build the ``{providers, model, provider}`` shape every consumer needs. ``explicit_only`` keeps + only providers the user explicitly configured — hides ambient/auto-seeded credentials from + desktop chat pickers. ``pricing_cache_only``: with ``pricing``, use only values already resident + in process caches (normal picker opens, while a background worker warms cold endpoints).""" + from hermes_cli.model_switch import list_authenticated_providers + + rows = list_authenticated_providers( + current_provider=ctx.current_provider, current_base_url=ctx.current_base_url, + current_model=ctx.current_model, user_providers=ctx.user_providers, + custom_providers=ctx.custom_providers, force_fresh_nous_tier=force_fresh_nous_tier, + max_models=max_models, refresh=refresh, probe_custom_providers=probe_custom_providers, + probe_current_custom_provider=probe_current_custom_provider, for_picker=for_picker, + excluded_providers=ctx.excluded_providers or [], + ) + + # Managed local runtime: staged GGUFs are selectable like any provider's models, but + # list_authenticated_providers can't know about them (no credential — reachability is the + # credential), so inject the row here where every picker surface inherits it. + local_row = _local_runtime_row(ctx) + if local_row is not None: + rows = _without_slug(rows, "llamacpp") + [local_row] + # A live session on the managed server reports provider "custom" (raw base_url label), which + # would materialize a duplicate "Custom endpoint" row with the same staged models stealing the + # checkmark. The Local row owns the managed server's identity — drop such custom rows. + if local_row.get("is_current"): + staged = set(local_row["models"]) + + def _is_managed_custom(row: dict) -> bool: + models = {str(m) for m in (row.get("models") or [])} + return _slug(row) == "custom" and bool(models) and models <= staged + + rows = [r for r in rows if not _is_managed_custom(r)] + + moa_row = _moa_provider_row(ctx.current_provider) + if moa_row is not None: + rows = [moa_row] + _without_slug(rows, "moa") + + if explicit_only: + rows = _filter_explicit_provider_rows(rows, ctx) + # If the current provider lost its credential, list_authenticated_providers() omits it; keep + # that one row so the UI shows the saved selection + a re-auth affordance instead of appearing + # to jump providers. Exception: a "custom" current on the managed local server is already + # represented by the Local row — the skeleton would resurrect the duplicate removed above. + _local_owns_current = bool(local_row and local_row.get("is_current") + and (ctx.current_provider or "").lower() == "custom") + if not _local_owns_current: + rows = list(rows) + _append_unconfigured_rows(rows, ctx, current_only=True) + + # A local proxy serving a model also in an aggregator's catalog would show under both, and picking + # the aggregator row silently breaks the call — aggregators only list models no specific provider has. + _strip_aggregator_overlaps(rows) + + if include_unconfigured: + rows = list(rows) + _without_slug(_append_unconfigured_rows(rows, ctx), "moa") + if picker_hints: + _apply_picker_hints(rows) + if canonical_order: + rows = _reorder_canonical(rows) + if pricing: + _apply_pricing(rows, force_fresh_nous_tier=force_fresh_nous_tier, cached_only=pricing_cache_only) + if capabilities: + _apply_capabilities(rows) + if featured: + _apply_featured(rows) + _apply_custom_aliases(rows) + + return {"providers": rows, "model": ctx.current_model, "provider": ctx.current_provider} + + +def _strip_aggregator_overlaps(rows: list[dict]) -> None: + """Drop models from TRUE routing aggregators (OpenRouter, custom:* proxies) that a user-defined + provider also serves. The is_user_defined guard matters: is_routing_aggregator() is True for every + custom:* slug, so without it the dedup would empty a user's own custom row. Flat-namespace + resellers (opencode-go/zen) serve every model first-party and keep shared names.""" + try: + from hermes_cli.providers import is_routing_aggregator + except Exception: + return + + user_models: set[str] = set() + for row in rows: + if row.get("is_user_defined"): + user_models.update(m.lower() for m in (row.get("models") or [])) + if not user_models: + return + for row in rows: + if row.get("is_user_defined") or not is_routing_aggregator(row.get("slug", "")): + continue + # Only strip overlaps from TRUE routing aggregators (OpenRouter, custom:* proxies). Flat-namespace + # resellers (opencode-go / opencode-zen) serve every listed model as a first-party model, so their + # rows must keep models that a user's proxy happens to share a name with — otherwise a subscription + # provider's own catalog (minimax-m3, glm-5, deepseek-v4-flash, ...) is silently gutted in the + # picker. (#47077) + original = row.get("models") or [] + filtered = [m for m in original if m.lower() not in user_models] + if len(filtered) < len(original): + row["models"] = filtered + row["total_models"] = len(filtered) + + +def build_model_options_payload( + ctx: ConfigContext, *, explicit_only: bool = False, include_unconfigured: bool = False, + refresh: bool = False, +) -> dict: + """Shared API-server/dashboard/TUI payload. Normal open probes only the current custom provider so + offline saved endpoints don't block the picker; explicit refresh probes all and busts the cache.""" + refresh = bool(refresh) + payload = build_models_payload( + ctx, explicit_only=bool(explicit_only), include_unconfigured=bool(include_unconfigured), + picker_hints=True, canonical_order=True, pricing=True, pricing_cache_only=not refresh, + capabilities=True, featured=True, + refresh=refresh, probe_custom_providers=refresh, probe_current_custom_provider=not refresh, + ) + if not refresh: + _prewarm_pricing_async(payload["providers"], current_provider=ctx.current_provider, + current_base_url=ctx.current_base_url) + return payload + + +# ─── Public: auxiliary-task pickers ───────────────────────────────────── + + +def build_aux_picker_rows( + *, current_provider: str = "", current_model: str = "", current_base_url: str = "", + max_models: int | None = None, +) -> list[dict]: + """Provider rows for any auxiliary-task picker (vision, compression, …). Honours + ``excluded_providers``; exhausted-pool providers stay visible (``for_picker``); only the active + custom endpoint is probed. ``moa`` is excluded: auxiliary_client unwraps it to its aggregator + anyway, so offering it would be a choice silently rewritten. + + Aux pickers kept re-deriving their own kwargs and each one silently dropped a different slice of the + user's configuration. Two independent contributor PRs landed against the same two call sites for exactly + this: 52642 (user ``providers:`` / ``custom_providers:`` entries never appeared) and #66624 (providers + with an exhausted credential pool were hidden). Both were per-site kwarg patches, so the next aux picker + would have reintroduced the same gap. Routing through one function makes the correct behaviour the + default that a new caller cannot forget: + """ + ctx = load_picker_context().with_overrides( + current_provider=current_provider, current_model=current_model, current_base_url=current_base_url, + ) + rows = build_models_payload( + ctx, for_picker=True, probe_custom_providers=False, probe_current_custom_provider=True, + max_models=max_models, + )["providers"] + return _without_slug(rows, "moa") + + +def format_aux_picker_entries( + rows: list[dict], *, current_provider: str = "", current_base_url: str = "", +) -> list[tuple[str, str, list[str]]]: + """Render aux-picker rows as ``(slug, label, models)``. A raw ``base_url`` custom endpoint is + "current" only through that URL, never a slug — so with ``current_base_url`` set no row is marked.""" + entries: list[tuple[str, str, list[str]]] = [] + current_slug = str(current_provider or "").strip().lower() + has_base_url = bool(str(current_base_url or "").strip()) + for row in rows: + slug = str(row.get("slug") or "") + name = row.get("name") or slug + total = row.get("total_models") or len(row.get("models") or []) + model_hint = f" — {total} models" if total else "" + marker = " ← current" if slug.lower() == current_slug and current_slug and not has_base_url else "" + entries.append((slug, f"{name}{model_hint}{marker}", list(row.get("models") or []))) + return entries + + +def _reasoning_catalog_reader(slug: str): + """Per-model reasoning-capability reader for aggregators that publish one. Cache-only — the picker + must never block on HTTP; a cold cache warms in the background and reports no restriction until then.""" + try: + from hermes_cli.models_reasoning_caps import ( + nous_model_reasoning_capabilities, + openrouter_model_reasoning_capabilities, + warm_nous_reasoning_caps_async, + warm_openrouter_reasoning_caps_async, + ) + except Exception: + return None + + readers = { + "nous": (warm_nous_reasoning_caps_async, nous_model_reasoning_capabilities), + "openrouter": (warm_openrouter_reasoning_caps_async, openrouter_model_reasoning_capabilities), + } + if slug not in readers: + return None + warm, read = readers[slug] + warm() + return read + + +def _apply_capabilities(rows: list[dict]) -> None: + """Attach ``{model: {fast, reasoning, ...}}`` per row. ``reasoning`` defaults True when the catalog is + silent (the dial is a no-op on models that ignore it; hiding it from a capable model is worse). A + serving aggregator's detail overrides models.dev (adds ``can_disable_reasoning``). ``supported_efforts`` + is deliberately NOT forwarded — it under-reports levels that work.""" + from hermes_cli.models import model_supports_fast_mode + + try: + from agent.models_dev import get_model_capabilities + except Exception: + get_model_capabilities = None # type: ignore[assignment] + + for row in rows: + slug = row.get("slug") or "" + caps: dict[str, dict[str, Any]] = {} + read_reasoning_catalog = _reasoning_catalog_reader(slug.lower()) + + for model in row.get("models") or []: + reasoning = True + if get_model_capabilities is not None and slug: + try: + meta = get_model_capabilities(slug, model) + if meta is not None: + reasoning = bool(meta.supports_reasoning) + except Exception: + reasoning = True + + entry: dict[str, Any] = {"fast": bool(model_supports_fast_mode(model)), "reasoning": reasoning} + + if reasoning and read_reasoning_catalog is not None: + try: + detail = read_reasoning_catalog(model) + except Exception: + detail = None + if detail and not detail.get("supports_reasoning"): + # Aggregator catalog beats models.dev for a route it serves: no reasoning param + # means no reasoning controls, so no disable to describe either. + entry["reasoning"] = False + elif detail: + entry["can_disable_reasoning"] = not detail.get("mandatory") + + caps[model] = entry + + row["capabilities"] = caps + + +# Newest N models per lab an aggregator row features by default (older tail behind search/show-all); +# 5 keeps a lab's headliners without letting a prolific vendor flood the view. +_FEATURED_PER_LAB = 5 + + +def _apply_featured(rows: list[dict]) -> None: + """Attach a ``featured_models`` shortlist to each aggregator row: newest ``_FEATURED_PER_LAB`` per + vendor by models.dev ``release_date`` (ranked within the row, never vs. today, so it is stable); + ties keep curated order. Non-aggregators get an empty list and keep top-N behaviour.""" + try: + from agent.models_dev import get_model_info + except Exception: + get_model_info = None # type: ignore[assignment] + + for row in rows: + slug = str(row.get("slug") or "").strip().lower() + models = row.get("models") or [] + + by_lab: dict[str, list[tuple[int, str, str]]] = {} # only multi-lab aggregators get a shortlist + for pos, model in enumerate(models): + lab = model.split("/", 1)[0] if "/" in model else "" + if not lab: # no vendor prefix → single-namespace provider, not an aggregator + by_lab = {} + break + date = "" + if get_model_info is not None: + info = get_model_info(slug, model) or get_model_info("openrouter", model) + date = getattr(info, "release_date", "") if info else "" + by_lab.setdefault(lab, []).append((pos, date, model)) + + if len(by_lab) < 2: + row["featured_models"] = [] + continue + + featured: list[str] = [] + for entries in by_lab.values(): + # Newest release_date first; earlier list position breaks ties (sole key when undated). + ranked = sorted(entries, key=lambda e: (e[1], -e[0]), reverse=True) + featured.extend(model for _pos, _date, model in ranked[:_FEATURED_PER_LAB]) + order = {m: i for i, m in enumerate(models)} # keep the row's model order for stable rendering + row["featured_models"] = sorted(featured, key=lambda m: order[m]) + + +def _apply_custom_aliases(rows: list[dict]) -> None: + """Attach the accepted identity set to each user-defined row: ``model.options`` reports the canonical + ``custom:`` while rows carry the bare key as ``slug``, so GUI exact-match never finds the row. + + GUI pickers compare the two to decide which row is active; exact equality never matches for custom + providers (#87035). Exposing ``aliases`` — every current and legacy spelling from + :func:`hermes_cli.providers.custom_provider_aliases` — lets the frontend do a membership check instead. + """ + from hermes_cli.providers import custom_provider_aliases + + for row in rows: + if not row.get("is_user_defined"): + continue + try: + row["aliases"] = sorted( + custom_provider_aliases(str(row.get("name", "")), str(row.get("slug", "")))) + except Exception: + continue + + +# ─── Internal: row post-processing ────────────────────────────────────── + + +def _provider_auth_hint(slug: str) -> tuple[str, str]: + """``(auth_type, key_env)`` for a canonical provider (``("api_key", "")`` when unregistered).""" + from hermes_cli.auth import PROVIDER_REGISTRY + + cfg = PROVIDER_REGISTRY.get(slug) + auth_type = cfg.auth_type if cfg else "api_key" + key_env = cfg.api_key_env_vars[0] if (cfg and cfg.api_key_env_vars) else "" + return auth_type, key_env + + +def _row(slug: str, name: str, is_current: bool, **extra: Any) -> dict: + return {"slug": slug, "name": name, "is_current": is_current, "is_user_defined": False, **extra} + + +def _canonical_row(entry, cur: str, **extra: Any) -> dict: + from hermes_cli.models import _PROVIDER_LABELS + + return _row(entry.slug, _PROVIDER_LABELS.get(entry.slug, entry.label), entry.slug.lower() == cur, **extra) + + +def _append_unconfigured_rows( + rows: list[dict], ctx: ConfigContext, *, current_only: bool = False, +) -> list[dict]: + """Empty setup skeletons for canonical providers missing from ``rows`` — except the *current* one: + if config.yaml still points at it but credentials are gone, keep a row carrying the saved model so + GUI pickers don't silently snap to another provider.""" + from hermes_cli.models import CANONICAL_PROVIDERS, _model_requires_account_discovery + + seen = {r["slug"].lower() for r in rows} + cur = (ctx.current_provider or "").lower() + cur_model = str(ctx.current_model or "").strip() + extras: list[dict] = [] + for entry in CANONICAL_PROVIDERS: + if entry.slug.lower() in seen: + continue + if current_only and entry.slug.lower() != cur: + continue + if entry.slug.lower() == cur: + saved_model = "" if _model_requires_account_discovery(entry.slug, cur_model) else cur_model + auth_type, key_env = _provider_auth_hint(entry.slug) + tail = ( + "Astra requires successful account-scoped model discovery." + if cur_model and not saved_model else "Showing the saved model only." + ) + warning = ( + f"Configured provider missing usable credentials; paste {key_env} to reactivate. {tail}" + if auth_type == "api_key" and key_env + else f"Configured provider is not authenticated; run `hermes model` to reactivate. {tail}" + ) + extras.append(_canonical_row( + entry, cur, models=[saved_model] if saved_model else [], total_models=1 if saved_model else 0, + source="configured-current", authenticated=False, auth_type=auth_type, key_env=key_env, + warning=warning, + )) + continue + extras.append(_canonical_row(entry, cur, models=[], total_models=0, source="canonical")) + return extras + + +def _anthropic_oauth_credentials_present() -> bool: + """True when the user explicitly authenticated Anthropic via OAuth (Hermes device flow or Claude Code + login) — those leave no trace in active_provider / model.provider / API-key env vars.""" + try: + from agent.anthropic_credentials import read_claude_code_credentials, read_hermes_oauth_credentials + + readers = (read_hermes_oauth_credentials, read_claude_code_credentials) + if any((read() or {}).get("accessToken") for read in readers): + return True + except Exception: + return False + # Pool-only OAuth entries (auth.json credential_pool.anthropic) are equally deliberate — discovery + # accepts them via pool.has_credentials(), so the filter must too or those rows are built then + # silently dropped. Read-only (no load_pool) so a picker open never mutates auth.json. + try: + from agent.credential_pool import AUTH_TYPE_OAUTH + from hermes_cli.auth import read_credential_pool + + for entry in read_credential_pool("anthropic"): + if (isinstance(entry, dict) and entry.get("auth_type") == AUTH_TYPE_OAUTH + and str(entry.get("access_token") or "").strip()): + return True + except Exception: + pass + return False + + +def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]: + """Keep only rows backed by explicit user configuration — ``list_authenticated_providers`` also + discovers ambient credentials (e.g. GitHub CLI -> Copilot) Desktop chat pickers must not show.""" + from hermes_cli.auth import is_provider_explicitly_configured + + current_slug = str(ctx.current_provider or "").strip().lower() + + def _is_explicit(row: dict, slug: str) -> bool: + # Managed local models are explicit configuration by existence (gigabytes downloaded into the + # machine-scoped dir); there is deliberately no config credential, so without the source clause + # the row would only survive on the profile where Use was last clicked. + if (row.get("is_user_defined") or (current_slug and slug == current_slug) + or row.get("source") == "local-runtime"): + return True + if slug == "moa": + # Virtual routing mode, not a configured provider: hide unless current (above) or the user + # wrote an enabled preset into RAW config (the DEFAULT_CONFIG preset must not show MoA). + return _raw_config_has_enabled_moa_preset() + return ( + _provider_is_keyless(slug) # zero-setup providers need no configuration at all + # Anthropic OAuth (device flow / Claude Code) and external-process CLIs (copilot-acp) are + # deliberate sign-ins that leave no trace in config/env; keep the rows discovery accepted. + or (slug == "anthropic" and _anthropic_oauth_credentials_present()) + or _external_process_signed_in(slug) + or is_provider_explicitly_configured(slug) + ) + + return [row for row in rows + if (slug := str(row.get("slug", "")).strip().lower()) and _is_explicit(row, slug)] + + +def _external_process_signed_in(slug: str) -> bool: + """True when an external-process provider has verified CLI credentials.""" + try: + from hermes_cli.auth import PROVIDER_REGISTRY, get_external_process_provider_status + pconfig = PROVIDER_REGISTRY.get(slug) + return bool(pconfig and pconfig.auth_type == "external_process" + and get_external_process_provider_status(slug).get("auth_verified")) + except Exception: + return False + + +def _provider_is_keyless(slug: str) -> bool: + """True when the provider's Hermes overlay declares it keyless.""" + try: + from hermes_cli.providers import HERMES_OVERLAYS + overlay = HERMES_OVERLAYS.get(slug) + return bool(overlay is not None and getattr(overlay, "keyless", False)) + except Exception: + return False + + +def _raw_config_has_enabled_moa_preset() -> bool: + """True when the user's RAW config enables MoA: ``load_config()`` merges the DEFAULT_CONFIG preset for + everyone, which is not a user choice; visible once one enabled preset (or legacy flat config) is saved.""" + try: + from hermes_cli.config import read_raw_config + + raw = read_raw_config() + except Exception: + return False + + moa = raw.get("moa") if isinstance(raw, dict) else None + if not isinstance(moa, dict): + return False + + presets = moa.get("presets") + if isinstance(presets, dict): + return any( + not isinstance(preset, dict) or preset.get("enabled", True) + for name, preset in presets.items() if str(name or "").strip() + ) + + legacy_keys = {"reference_models", "aggregator", "reference_temperature", "aggregator_temperature", + "max_tokens", "reference_max_tokens", "fanout"} + return any(key in moa for key in legacy_keys) and bool(moa.get("enabled", True)) + + +def _apply_picker_hints(rows: list[dict]) -> None: + """Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row.""" + for row in rows: + if "authenticated" in row: + continue + # Skeleton rows (_append_unconfigured_rows) have empty `models` AND source="canonical". + is_skeleton = row.get("source") == "canonical" and not row.get("models") + row["authenticated"] = not is_skeleton + if not is_skeleton or row.get("is_user_defined"): + continue + auth_type, key_env = _provider_auth_hint(row["slug"]) + row["auth_type"] = auth_type + row["key_env"] = key_env + row["warning"] = (f"paste {key_env} to activate" if auth_type == "api_key" and key_env + else f"run `hermes model` to configure ({auth_type})") + + +def _reorder_canonical(rows: list[dict]) -> list[dict]: + """Canonical slugs in ``CANONICAL_PROVIDERS`` order, truly-custom rows last. Keys on slug membership, + NOT ``is_user_defined`` — ``providers:`` config rows carry that flag even for canonical slugs.""" + from hermes_cli.models import CANONICAL_PROVIDERS + + order = {e.slug: i for i, e in enumerate(CANONICAL_PROVIDERS)} + canon = sorted((r for r in rows if r["slug"] in order), key=lambda r: order[r["slug"]]) + extras = [r for r in rows if r["slug"] not in order] + return canon + extras + + +def _apply_pricing(rows: list[dict], *, force_fresh_nous_tier: bool = False, cached_only: bool = False) -> None: + """Set ``row["pricing"] = {model_id: {input, output, cache | None, free}}``; for Nous also + ``free_tier`` (account is free-tier) and ``unavailable_models`` (paid models a free user can't pick). + ``cached_only`` never hits the network: unknown Nous entitlement fails closed (``free_tier_pending``, + all models locked) and missing pricing is marked ``pricing_pending``.""" + from hermes_cli.models_pricing import ( + _format_price_per_mtok, + compute_sale_discount, + get_pricing_for_provider, + ) + from hermes_cli.models import ( + check_nous_free_tier, + get_cached_nous_free_tier, + partition_nous_models_by_tier, + ) + + nous_free_tier: Optional[bool] = None # resolved once (cached in models.py for the TTL window) + + for row in rows: + slug = str(row.get("slug", "")).lower() + models = row.get("models") or [] + if not models: + continue + if row.get("free_tier_row"): + # The free tier's one model has no Portal pricing and no entitlement to read: pricing + # it would lock the only row a free-tier install can select. + continue + try: + pricing_kwargs = {"cached_only": True} if cached_only else {} + raw_pricing = get_pricing_for_provider(slug, **pricing_kwargs) or {} + except Exception: + raw_pricing = {} + cached_nous_tier: Optional[bool] = None + if slug == "nous" and cached_only: + cached_nous_tier = get_cached_nous_free_tier() + if cached_nous_tier is None: + # Entitlement unknown: stay nonblocking but fail closed until the prewarm has populated + # both caches, else a free account could briefly select paid models on first open. + row["free_tier_pending"] = True + row["unavailable_models"] = list(models) + if not row.get("warning"): # say why every model renders locked + row["warning"] = ("Checking Nous plan entitlement… models unlock on the " + "next picker open or refresh.") + continue + if not raw_pricing: + if slug == "nous": + row["free_tier"] = bool(cached_nous_tier) + row["pricing_pending"] = True + row["unavailable_models"] = list(models) if cached_nous_tier else [] + continue + + formatted: dict[str, dict] = {} + for mid in models: + p = raw_pricing.get(mid) + if not p: + continue + inp_raw, out_raw = p.get("prompt", ""), p.get("completion", "") + cache_raw = p.get("input_cache_read", "") + inp = _format_price_per_mtok(inp_raw) if inp_raw != "" else "" + out = _format_price_per_mtok(out_raw) if out_raw != "" else "" + entry: dict = { + "input": inp, "output": out, + "cache": _format_price_per_mtok(cache_raw) if cache_raw else None, + "free": inp == "free" and out in ("free", ""), # both input and output cost nothing + } + # Sale chrome is Nous Portal-only (other catalogs' nested pricing.original is ignored); free + # models get flat -100% chrome, was_* only when the gateway served an original. + if slug == "nous": + sale = compute_sale_discount(inp_raw, out_raw, p.get("original")) + if sale is not None: + discount_percent, was_prompt_raw, was_out_raw = sale + entry["discount_percent"] = discount_percent + for key, was_raw in (("was_input", was_prompt_raw), ("was_output", was_out_raw)): + if was_raw != "": + entry[key] = _format_price_per_mtok(was_raw) + formatted[mid] = entry + + if formatted: + row["pricing"] = formatted + + if slug == "nous": + try: + if nous_free_tier is None: + nous_free_tier = (cached_nous_tier if cached_only + else check_nous_free_tier(force_fresh=force_fresh_nous_tier)) + row["free_tier"] = bool(nous_free_tier) + row["unavailable_models"] = ( + partition_nous_models_by_tier(list(models), raw_pricing, free_tier=True)[1] + if nous_free_tier else []) + except Exception: # tier detection failed — fail open (no gating) + row["free_tier"] = False + row["unavailable_models"] = [] + + +def _local_runtime_row(ctx: "ConfigContext") -> dict | None: + """The ``llamacpp`` row from staged GGUFs (``None`` when none) — downloaded models must be selectable + before the server runs (selection starts it via the runtime_provider seam).""" + try: + from hermes_cli.local_runtime.bootstrap import staged_model_ids + + staged = staged_model_ids() + if not staged: + return None + current = (ctx.current_provider or "").strip().lower() in ("llamacpp", "llama.cpp", "llama-cpp") + if not current: + # A LIVE session on the managed server reports provider "custom" with the managed base_url; + # match on the endpoint so the session being chatted in still shows a selection. + try: + from hermes_cli.local_runtime.endpoint import _state_endpoint + + managed = _state_endpoint() + current = bool(managed and (ctx.current_base_url or "").strip().rstrip("/") + == managed["base_url"].rstrip("/")) + except Exception: + current = False + # Bare "Local" user-facing (engine name is an implementation detail); authenticated = reachability. + return _row("llamacpp", "Local", current, models=staged, total_models=len(staged), + source="local-runtime", authenticated=True, auth_type="local", warning=None) + except Exception: + return None + + +def _prewarm_pricing_async( + rows: list[dict], *, current_provider: str = "", current_base_url: str = "", +) -> Optional[Thread]: + """Warm picker pricing caches without delaying the current payload (one worker per + profile + endpoint scope; a live worker is reused).""" + from hermes_constants import hermes_home_key + from hermes_cli.models_pricing import pricing_cache_scope + + slugs = {str(row.get("slug") or "").lower() for row in rows if row.get("slug")} + endpoint_scope = tuple(sorted( + (slug, pricing_cache_scope(slug, current_provider=current_provider, current_base_url=current_base_url)) + for slug in slugs)) + prewarm_key = (hermes_home_key(), endpoint_scope) + + with _pricing_prewarm_lock: + current = _pricing_prewarm_threads.get(prewarm_key) + if current is not None and current.is_alive(): + return current + # The worker mutates only private copies; the pricing helpers populate shared process caches. + worker_rows = [{**row, "models": list(row.get("models") or [])} for row in rows] + + def _worker() -> None: + try: + _apply_pricing(worker_rows) + finally: + with _pricing_prewarm_lock: + if _pricing_prewarm_threads.get(prewarm_key) is current_thread(): + _pricing_prewarm_threads.pop(prewarm_key, None) + + thread = Thread(target=copy_context().run, args=(_worker,), + name="hermes-picker-pricing-prewarm", daemon=True) + _pricing_prewarm_threads[prewarm_key] = thread + thread.start() + return thread + + +def _moa_provider_row(current_provider: str = "") -> dict | None: + """The virtual ``moa`` row shared by the CLI inventory and gateway picker; ``None`` without presets.""" + try: + from hermes_cli.config import load_config + from hermes_cli.moa_config import normalize_moa_config + + cfg = normalize_moa_config(load_config().get("moa") or {}) + models = list(cfg.get("presets", {}).keys()) + if not models: + return None + return _row( + "moa", "Mixture of Agents", (current_provider or "").lower() == "moa", models=models, + total_models=len(models), source="virtual", authenticated=True, auth_type="virtual", + warning="Aggregator acts as the selected model; references provide analysis before each call.") + except Exception: + return None diff --git a/hermes_cli/kanban_db_dispatch.py b/hermes_cli/kanban_db_dispatch.py new file mode 100644 index 0000000000000000000000000000000000000000..7a8f7a08a77857fe88dc690396506477daefc7c5 --- /dev/null +++ b/hermes_cli/kanban_db_dispatch.py @@ -0,0 +1,2386 @@ +"""Dispatcher: crash/stale/orphan detection, failure accounting and the respawn circuit breaker, memory-aware concurrency caps, the one-shot ``dispatch_once`` pass, worker spawning (``_default_spawn``), worker-log rotation and the long-lived ``run_daemon`` loop. + +Split out of ``hermes_cli.kanban_db``; origin-resident helpers are reached +late-bound via ``_kb`` (import-cycle breaking) so monkeypatching +``kanban_db.`` keeps working. +""" + +from __future__ import annotations + +import contextlib +import os +import re +import signal +import sqlite3 +import subprocess +import sys +import time +from dataclasses import dataclass +from dataclasses import field +from pathlib import Path +from typing import Any +from typing import Callable +from typing import Mapping +from typing import Optional +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from hermes_cli.kanban_db import Task + + +# After this many consecutive non-success attempts on a task/profile the +# dispatcher parks the task in ``blocked`` with a reason — prevents retry storms. +DEFAULT_FAILURE_LIMIT = 2 + +# Worker log files larger than this at spawn time are rotated. +DEFAULT_LOG_ROTATE_BYTES = 2 * 1024 * 1024 # 2 MiB +DEFAULT_LOG_BACKUP_COUNT = 1 + +# Keep a little wall-clock budget for the worker to observe a terminal timeout +# and call kanban_block/kanban_complete before max_runtime_seconds kills it. +KANBAN_TERMINAL_TIMEOUT_GRACE_SECONDS = 30 + +# --------------------------------------------------------------------------- +# Respawn guard constants +# --------------------------------------------------------------------------- + +# Patterns in last_failure_error that indicate a quota / auth blocker. +# These errors won't resolve by retrying immediately — auto-block instead. +_RESPAWN_BLOCKER_RE = re.compile( + r"\b(quota|rate[\s_\-]?limit|429|403|auth\w*|" + r"unauthorized|forbidden|billing|subscription|" + r"access[\s_]denied|permission[\s_]denied|" + r"invalid[\s_]api[\s_]key)\b", + re.IGNORECASE, +) + +# Within this window a completed run counts as "recent proof"; don't re-spawn. +_RESPAWN_GUARD_SUCCESS_WINDOW = 3600 # 1 hour + +# Cooldown after a rate-limited (quota-wall) requeue before re-spawning. Without +# it the task would re-spawn on the very next tick and bounce off the same quota +# wall, burning a worker slot every tick for hours. Overridable via +# ``HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS``. +DEFAULT_RATE_LIMIT_COOLDOWN_SECONDS = 300 # 5 minutes + +# Within this window a GitHub PR URL in a comment blocks re-spawn. +_RESPAWN_GUARD_PR_WINDOW = 86400 # 24 hours + +_RESPAWN_GUARD_PR_URL_RE = re.compile( + r"https?://github\.com/[^/\s]+/[^/\s]+/pull/\d+", + re.IGNORECASE, +) + + +@dataclass +class DispatchResult: + """Outcome of a single ``dispatch`` pass. + + ``kanban.default_assignee`` applied this tick before spawning (#27145). Surfaces the auto-assignment to + telemetry / CLI / dashboard so the operator can see when the dispatcher is acting on the fallback rule + ``kanban.max_in_progress_per_profile`` (#21582). Each entry is ``(task_id, assignee, + current_running_count)``. NOT an operator-actionable failure — the task will be picked up on a + subsequent tick when the assignee has capacity. Separate bucket so telemetry / dashboards can show "this + profile is busy" vs + the board's dispatch lock (issue #35240). A losing dispatcher does no DB writes this tick — the lock + holder is making progress on the same board. This is the steady-state signal that a single-writer guard + is + """ + + reclaimed: int = 0 + promoted: int = 0 + reconciled_orphans: list[str] = field(default_factory=list) + """``running`` cards requeued by :func:`reconcile_orphaned_running` (broken + claim bookkeeping, dead/gone worker).""" + spawned: list[tuple[str, str, str]] = field(default_factory=list) + """``(task_id, assignee, workspace_path)`` triples.""" + skipped_unassigned: list[str] = field(default_factory=list) + """Ready task ids with no assignee at all — operator-actionable (usually a + misfiled task waiting for routing).""" + auto_assigned_default: list[str] = field(default_factory=list) + """Unassigned task ids that had ``kanban.default_assignee`` applied this + tick before spawning, so telemetry/CLI/dashboard can show the dispatcher + acting on the fallback rule rather than explicit assignments.""" + skipped_nonspawnable: list[str] = field(default_factory=list) + """Ready task ids whose assignee names a control-plane lane (e.g. a Claude + Code terminal like ``orion-cc``), not a Hermes profile. Expected steady-state + on multi-lane setups, NOT operator-actionable; tracked apart so health + telemetry can tell "stuck" from "correctly idle".""" + skipped_per_profile_capped: list[tuple[str, str, int]] = field(default_factory=list) + """``(task_id, assignee, current_running_count)`` deferred because the + assignee is at ``kanban.max_in_progress_per_profile``. Picked up on a later + tick; separate bucket so dashboards show "profile busy" vs "stuck".""" + crashed: list[str] = field(default_factory=list) + """Task ids reclaimed because their worker PID disappeared.""" + auto_blocked: list[str] = field(default_factory=list) + """Task ids auto-blocked by the spawn-failure circuit breaker.""" + timed_out: list[str] = field(default_factory=list) + """Task ids whose workers exceeded ``max_runtime_seconds``.""" + stale: list[str] = field(default_factory=list) + """Task ids reclaimed for no heartbeat within ``dispatch_stale_timeout_seconds``.""" + respawn_guarded: list[tuple[str, str]] = field(default_factory=list) + """``(task_id, reason)`` skipped by the respawn guard: ``"blocker_auth"`` + (quota/auth error — also auto-blocked), ``"recent_success"`` (completed run + within guard window), ``"active_pr"`` (GitHub PR URL in a recent comment).""" + rate_limited: list[str] = field(default_factory=list) + """Task ids whose workers bailed on a provider rate-limit / quota wall + (EX_TEMPFAIL sentinel exit) and were released to ``ready`` WITHOUT counting + a failure — a long quota window must never trip the circuit breaker.""" + skipped_locked: bool = False + """True when another process held the board's dispatch lock: this tick did + no DB writes; the lock holder is making progress on the same board.""" + memory_pressure: Optional[str] = None + """Memory pressure that restricted this tick: ``"critical"`` (no new + workers), ``"elevated"`` (at most one), ``None`` (no restriction). + Reclaim/promotion bookkeeping still ran; deferred tasks stay queued.""" + + +# Bounded registry of recently-reaped worker exits, filled by the reap loop in +# ``dispatch_once`` and read by ``detect_crashed_workers`` to classify a dead-pid +# task. Entry: ``pid -> (raw_wait_status, reaped_at_epoch)``; raw status kept so +# both WIFEXITED/WEXITSTATUS and WIFSIGNALED can be consulted. Trimmed by age +# plus a total size cap. +_RECENT_WORKER_EXIT_TTL_SECONDS = 600 +_RECENT_WORKER_EXITS_MAX = 4096 +_recent_worker_exits: "dict[int, tuple[int, float]]" = {} + + +def _record_worker_exit(pid: int, raw_status: int) -> None: + """Record a reaped child's exit status; duplicate pids overwrite (latest wins).""" + if not pid or pid <= 0: + return + now = time.time() + _recent_worker_exits[int(pid)] = (int(raw_status), now) + if len(_recent_worker_exits) > _RECENT_WORKER_EXITS_MAX // 2: + cutoff = now - _RECENT_WORKER_EXIT_TTL_SECONDS + for _pid in [p for p, (_s, t) in _recent_worker_exits.items() if t < cutoff]: + _recent_worker_exits.pop(_pid, None) + if len(_recent_worker_exits) > _RECENT_WORKER_EXITS_MAX: + # Drop oldest half. + ordered = sorted(_recent_worker_exits.items(), key=lambda kv: kv[1][1]) + for _pid, _ in ordered[: len(ordered) // 2]: + _recent_worker_exits.pop(_pid, None) + + +def _classify_worker_exit(pid: int) -> "tuple[str, Optional[int]]": + """``(kind, code)`` for a reaped worker PID: ``clean_exit`` (rc 0 while + still ``running`` = protocol violation), ``rate_limited`` + (``KANBAN_RATE_LIMIT_EXIT_CODE``, never counts as a failure), + ``nonzero_exit``, ``signaled`` (``code`` is the signal), ``unknown`` (pid + not in the reap registry; ``code`` None).""" + entry = _recent_worker_exits.get(int(pid)) + if entry is None: + return ("unknown", None) + raw, _ = entry + try: + if os.WIFEXITED(raw): + code = os.WEXITSTATUS(raw) + if code == 0: + return ("clean_exit", 0) + if code == _kb.KANBAN_RATE_LIMIT_EXIT_CODE: + return ("rate_limited", code) + return ("nonzero_exit", code) + if os.WIFSIGNALED(raw): + return ("signaled", os.WTERMSIG(raw)) + except Exception: + pass + return ("unknown", None) + + +def reap_worker_zombies() -> "list[int]": + """Reap all zombie children without blocking; returns reaped PIDs. No-op on Windows.""" + reaped: "list[int]" = [] + if os.name != "nt": + try: + while True: + try: + pid, status = os.waitpid(-1, os.WNOHANG) + except ChildProcessError: + break + if pid == 0: + break + _record_worker_exit(pid, status) + reaped.append(pid) + except Exception: + pass + return reaped + + +def _pid_alive(pid: Optional[int]) -> bool: + """Return True if ``pid`` is still running on this host. + + Uses ``gateway.status._pid_exists`` (OpenProcess on Windows, ``os.kill(pid, 0)`` + on POSIX). **DO NOT** call ``os.kill(pid, 0)`` directly on Windows — there + ``sig=0`` is ``CTRL_C_EVENT`` broadcast to the console group, potentially + killing unrelated processes. + + Zombies (exited, not yet reaped) still pass the existence check, so a + worker would look "alive" forever between exit and reap. Linux: peek at + ``/proc//status`` and treat ``State: Z`` as dead; macOS: ask ``ps`` + for the BSD ``stat`` field and treat ``Z`` as dead. + """ + if not pid or pid <= 0: + return False + from gateway.status import _pid_exists + if not _pid_exists(int(pid)): + return False + if sys.platform == "linux": + try: + with open(f"/proc/{int(pid)}/status", "r", encoding="utf-8") as f: + for line in f: + if line.startswith("State:"): + # "State:\tZ (zombie)" → dead + if "Z" in line.split(":", 1)[1]: + return False + break + except (FileNotFoundError, PermissionError, OSError): + # proc entry gone → already reaped; treat as dead. + pass + elif sys.platform == "darwin": + try: + proc = subprocess.run( + ["ps", "-o", "stat=", "-p", str(int(pid))], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, encoding='utf-8', errors='replace', + timeout=1, + check=False, + ) + if proc.returncode != 0: + return False + if "Z" in (proc.stdout or "").strip(): + return False + except (OSError, subprocess.SubprocessError, TimeoutError): + # If the secondary probe fails, keep the kill(0) answer. + pass + return True + + +def _kill_fn(signal_fn) -> Optional[Callable[[int, int], None]]: + """``signal_fn`` test hook, else ``os.kill`` when the platform has one.""" + if signal_fn is not None: + return signal_fn + return os.kill if hasattr(os, "kill") else None + + +def _poll_worker_exit(pid: int) -> bool: + """Poll ~5 s (10 x 0.5 s) for ``pid`` to die; True once it is gone.""" + for _ in range(10): + if not _kb._pid_alive(pid): + return True + time.sleep(0.5) + return False + + +def _sigkill(kill, pid: int) -> bool: + """Best-effort SIGKILL; True when the signal was delivered.""" + try: + # signal.SIGKILL doesn't exist on Windows; SIGTERM maps to TerminateProcess. + kill(int(pid), getattr(signal, "SIGKILL", signal.SIGTERM)) + return True + except (ProcessLookupError, OSError): + return False + + +def _terminate_reclaimed_worker( + pid: Optional[int], + claim_lock: Optional[str], + *, + signal_fn=None, +) -> dict[str, Any]: + """Best-effort host-local worker termination for reclaim paths.""" + info: dict[str, Any] = { + "prev_pid": int(pid) if pid else None, + "host_local": False, + "termination_attempted": False, + "terminated": False, + "sigkill": False, + } + if not pid or pid <= 0 or not claim_lock: + return info + if not str(claim_lock).startswith(_kb._host_prefix()): + return info + info["host_local"] = True + + kill = _kill_fn(signal_fn) + if kill is None: + return info + + info["termination_attempted"] = True + try: + kill(int(pid), signal.SIGTERM) + except ProcessLookupError: + # Already gone = successful termination. Leaving terminated=False would + # make the reclaim guard misread a dead worker as alive and defer forever. + info["terminated"] = True + return info + except OSError: + return info + + if _poll_worker_exit(pid): + info["terminated"] = True + return info + if _kb._pid_alive(pid): + if not _sigkill(kill, pid): + return info + info["sigkill"] = True + info["terminated"] = not _kb._pid_alive(pid) + return info + + +def _worker_survived_termination(termination: dict) -> bool: + """True when we tried to kill our own host-local worker and it is still alive. + + Reclaiming then would release the claim and spawn a second worker while the + first still runs — the duplication loop. Only host-local workers we actually + signalled count; a non-local lock or no-op attempt (no ``os.kill``) must fall + through to the normal release path since we cannot manage that worker anyway. + """ + return bool( + termination.get("termination_attempted") + and termination.get("host_local") + and not termination.get("terminated") + ) + + +def _defer_reclaim_for_live_worker( + conn: sqlite3.Connection, + task_id: str, + claim_lock: Optional[str], + now: int, + termination: dict, + *, + reason: str, +) -> None: + """Hold a claim whose worker survived termination instead of releasing it. + + Extends ``claim_expires`` by ``RECLAIM_DEFER_GRACE_SECONDS`` so the task + stays ``running`` (no duplicate spawn) and records ``reclaim_deferred``. + The next tick retries the kill; not spawning a duplicate is what lets the + throttled worker finally die. + """ + grace = now + _kb.RECLAIM_DEFER_GRACE_SECONDS + with _kb.write_txn(conn): + cur = conn.execute( + "UPDATE tasks SET claim_expires = ? " + "WHERE id = ? AND status = 'running' AND claim_lock IS ?", + (grace, task_id, claim_lock), + ) + if cur.rowcount != 1: + return + run_id = _kb._current_run_id(conn, task_id) + if run_id is not None: + conn.execute("UPDATE task_runs SET claim_expires = ? WHERE id = ?", (grace, run_id)) + payload = {"reason": reason, "claim_lock": claim_lock, "claim_expires_now": grace} + payload.update(termination) + _kb._append_event(conn, task_id, "reclaim_deferred", payload, run_id=run_id) + + +def heartbeat_worker( + conn: sqlite3.Connection, + task_id: str, + *, + note: Optional[str] = None, + expected_run_id: Optional[int] = None, +) -> bool: + """Record a ``heartbeat`` event + touch ``last_heartbeat_at``. + + Liveness signal orthogonal to the PID check: a worker whose forked child + (train loop, crawl) is stuck can still have a live Python process. + Returns False if the task is not running or its claim expired. + """ + now = int(time.time()) + with _kb.write_txn(conn): + sql = "UPDATE tasks SET last_heartbeat_at = ? WHERE id = ? AND status = 'running'" + params: tuple = (now, task_id) + if expected_run_id is not None: + sql += " AND current_run_id = ?" + params += (int(expected_run_id),) + cur = conn.execute(sql, params) + if cur.rowcount != 1: + return False + run_id = ( + int(expected_run_id) + if expected_run_id is not None + else _kb._current_run_id(conn, task_id) + ) + if run_id is not None: + conn.execute("UPDATE task_runs SET last_heartbeat_at = ? WHERE id = ?", (now, run_id)) + _kb._append_event( + conn, task_id, "heartbeat", + {"note": note} if note else None, + run_id=run_id, + ) + return True + + +def enforce_max_runtime(conn: sqlite3.Connection, *, signal_fn=None) -> list[str]: + """Terminate workers whose per-task ``max_runtime_seconds`` has elapsed. + + SIGTERM, short grace, then SIGKILL. Emits ``timed_out`` and restores the + task's source phase so the next tick re-spawns the same kind of worker — + unless the circuit breaker already gave up, leaving it blocked. Host-local + only (same reasoning as ``detect_crashed_workers``). ``signal_fn`` is a test hook. + """ + timed_out: list[str] = [] + now = int(time.time()) + host_prefix = _kb._host_prefix() + + rows = conn.execute( + "SELECT t.id, t.worker_pid, " + " COALESCE(r.started_at, t.started_at) AS active_started_at, " + " t.max_runtime_seconds, t.claim_lock " + "FROM tasks t " + "LEFT JOIN task_runs r ON r.id = t.current_run_id " + "WHERE t.status = 'running' AND t.max_runtime_seconds IS NOT NULL " + " AND COALESCE(r.started_at, t.started_at) IS NOT NULL " + " AND t.worker_pid IS NOT NULL" + ).fetchall() + for row in rows: + lock = row["claim_lock"] or "" + if not lock.startswith(host_prefix): + continue + # Runtime is per attempt: ``tasks.started_at`` records the FIRST start, + # so retries must be measured from the active task_runs row. + elapsed = now - int(row["active_started_at"]) + limit = int(row["max_runtime_seconds"]) + if elapsed < limit: + continue + + pid = int(row["worker_pid"]) + tid = row["id"] + # SIGTERM then SIGKILL after 5 s grace; workers wanting a cleaner + # shutdown install their own SIGTERM handler. + killed = False + kill = _kill_fn(signal_fn) + if kill is not None: + with contextlib.suppress(ProcessLookupError, OSError): + kill(pid, signal.SIGTERM) + # Short polling wait — no time.sleep on the write txn. + _poll_worker_exit(pid) + if _kb._pid_alive(pid): + killed = _sigkill(kill, pid) + + error = f"elapsed {int(elapsed)}s > limit {limit}s" + with _kb.write_txn(conn): + retry_status = _kb._retry_status_for_run(conn, tid) + cur = conn.execute( + "UPDATE tasks SET status = ?, claim_lock = NULL, " + "claim_expires = NULL, worker_pid = NULL, " + "last_heartbeat_at = NULL " + "WHERE id = ? AND status = 'running' " + " AND worker_pid = ? AND claim_lock IS ?", + (retry_status, tid, pid, row["claim_lock"]), + ) + if cur.rowcount == 1: + payload = { + "pid": pid, + "elapsed_seconds": int(elapsed), + "limit_seconds": limit, + "sigkill": killed, + "retry_status": retry_status, + } + run_id = _kb._end_run( + conn, tid, outcome="timed_out", status="timed_out", + error=error, metadata=payload, + ) + _kb._append_event(conn, tid, "timed_out", payload, run_id=run_id) + timed_out.append(tid) + # Outside the write_txn above because ``_record_task_failure`` opens its + # own. If the breaker trips this flips the task to ``blocked`` and emits + # ``gave_up`` on top of the ``timed_out`` already emitted. + if cur.rowcount == 1: + _record_task_failure( + conn, tid, + error=error, + outcome="timed_out", + release_claim=False, + end_run=False, + event_payload_extra={"pid": pid, "sigkill": killed, "retry_status": retry_status}, + ) + return timed_out + + +# A running task with no heartbeat for this long is inactive regardless of +# ``dispatch_stale_timeout_seconds`` (spec: ">4h started + no commits in 1h"). +_STALE_HEARTBEAT_GAP_SECONDS = 3600 + + +def detect_stale_running( + conn: sqlite3.Connection, + *, + stale_timeout_seconds: int = 0, + signal_fn=None, +) -> list[str]: + """Reclaim ``running`` tasks with no heartbeat progress; returns their ids. + + Stale = running longer than ``stale_timeout_seconds`` (active run's + ``started_at``, else ``tasks.started_at``) AND ``last_heartbeat_at`` NULL or + older than ``_STALE_HEARTBEAT_GAP_SECONDS``. Task returns to its source + phase, run closes ``outcome='stale'``, a live host-local worker is killed. + ``0`` disables the check; ``signal_fn`` is a test hook. Deliberately NOT + counted via ``_record_task_failure``: an absent heartbeat is not a worker + failure, and counting it would let long-running tasks trip the breaker. + """ + if stale_timeout_seconds <= 0: + return [] + + now = int(time.time()) + reclaimed: list[str] = [] + + rows = conn.execute( + "SELECT t.id, t.worker_pid, t.last_heartbeat_at, t.claim_lock, " + " COALESCE(r.started_at, t.started_at) AS active_started_at " + "FROM tasks t " + "LEFT JOIN task_runs r ON r.id = t.current_run_id " + "WHERE t.status = 'running'" + ).fetchall() + + for row in rows: + if row["active_started_at"] is None: + continue + elapsed = now - int(row["active_started_at"]) + if elapsed < stale_timeout_seconds: + continue + + last_hb = row["last_heartbeat_at"] + hb_age = (now - int(last_hb)) if last_hb is not None else None + if hb_age is not None and hb_age < _STALE_HEARTBEAT_GAP_SECONDS: + continue + + pid = row["worker_pid"] + tid = row["id"] + lock = row["claim_lock"] or "" + + termination = _kb._terminate_reclaimed_worker(pid, lock, signal_fn=signal_fn) + + # Never release a claim while our own worker is still alive: that would + # spawn a duplicate beside it. Hold the claim and retry next tick. + if _worker_survived_termination(termination): + _defer_reclaim_for_live_worker( + conn, tid, lock, now, termination, + reason="heartbeat_stale_worker_alive", + ) + continue + + with _kb.write_txn(conn): + retry_status = _kb._retry_status_for_run(conn, tid) + cur = conn.execute( + "UPDATE tasks SET status = ?, claim_lock = NULL, " + "claim_expires = NULL, worker_pid = NULL, " + "last_heartbeat_at = NULL " + "WHERE id = ? AND status = 'running' " + " AND claim_lock IS ?", + (retry_status, tid, row["claim_lock"]), + ) + if cur.rowcount != 1: + continue + + payload = { + "elapsed_seconds": int(elapsed), + "last_heartbeat_at": _kb._opt_int(last_hb), + "heartbeat_age_seconds": _kb._opt_int(hb_age), + "timeout_seconds": stale_timeout_seconds, + "pid": int(pid) if pid else None, + "retry_status": retry_status, + } + payload.update(termination) + + run_id = _kb._end_run( + conn, tid, + outcome="stale", status="stale", + error=( + f"no heartbeat for {int(hb_age)}s " + if hb_age is not None + else "no heartbeat ever" + ) + f" after {int(elapsed)}s running", + metadata=payload, + ) + _kb._append_event(conn, tid, "stale", payload, run_id=run_id) + reclaimed.append(tid) + + return reclaimed + + +def reconcile_orphaned_running(conn: sqlite3.Connection) -> list[str]: + """Requeue ``running`` cards with broken claim bookkeeping; returns their ids. + + A task ``running`` with NULL ``claim_lock``/``claim_expires`` (crash + mid-claim, manual SQL, DB restore) is a zombie forever: ``release_stale_claims`` + needs ``claim_expires``, ``detect_crashed_workers`` needs a host-local lock + + pid, ``detect_stale_running`` is off by default. Orphans go back to ``ready`` + with a comment, leaked run closed, ``reconciled`` event; a row with a live + host-local PID is deferred so no duplicate spawns beside it. + """ + now = int(time.time()) + reconciled: list[str] = [] + rows = conn.execute( + "SELECT id, claim_lock, claim_expires, worker_pid FROM tasks " + "WHERE status = 'running' " + " AND (claim_lock IS NULL OR claim_expires IS NULL)" + ).fetchall() + for row in rows: + tid = row["id"] + pid = row["worker_pid"] + if pid and _kb._pid_alive(pid): + # Never requeue beside a live process. Retry next tick. + _kb._log.debug( + "kanban reconcile: task %s has broken claim bookkeeping but " + "pid %s is alive on this host — deferring", tid, pid, + ) + continue + with _kb.write_txn(conn): + cur = conn.execute( + "UPDATE tasks SET status = 'ready', claim_lock = NULL, " + "claim_expires = NULL, worker_pid = NULL, " + "last_heartbeat_at = NULL " + "WHERE id = ? AND status = 'running' " + " AND claim_lock IS ? AND claim_expires IS ?", + (tid, row["claim_lock"], row["claim_expires"]), + ) + if cur.rowcount != 1: + continue + payload = { + "reason": "orphaned_running", + "claim_lock": row["claim_lock"], + "claim_expires": _kb._opt_int(row["claim_expires"]), + "worker_pid": int(pid) if pid else None, + "now": now, + } + run_id = _kb._end_run( + conn, tid, + outcome="reclaimed", status="reclaimed", + error="orphaned running card (broken claim bookkeeping)", + metadata=payload, + ) + _kb._insert_comment( + conn, tid, "dispatcher", + "reconciliation: card was 'running' with no valid claim " + "(dead/gone worker) — requeued to ready", + now, + ) + _kb._append_event(conn, tid, "reconciled", payload, run_id=run_id) + reconciled.append(tid) + _kb._log.info( + "kanban reconcile: requeued orphaned running task %s " + "(claim_lock=%r, worker_pid=%r)", tid, row["claim_lock"], pid, + ) + return reconciled + + +def _error_fingerprint(error_text: str) -> str: + """Normalize an error message (strip PIDs, timestamps) so same-root-cause errors group.""" + fp = re.sub(r'\bpid \d+\b', 'pid N', error_text[:80]) + fp = re.sub(r'\b\d{10,}\b', '', fp) + return fp.lower().strip() + + +# ~96% of "clean exit without a terminal tool call" tasks complete on a later +# run, so a protocol violation gets a bounded retry before the breaker trips. +# The budget is a violation-only STREAK (``_protocol_violation_streak``), +# independent of ``consecutive_failures``: other failure kinds neither consume +# nor extend it. Per-task ``max_retries`` overrides it. +_PROTOCOL_VIOLATION_FAILURE_LIMIT = 3 + +# Closed runs to walk when counting the streak; it trips at a handful anyway. +_PROTOCOL_VIOLATION_SCAN_LIMIT = 50 + + +def _protocol_violation_streak(conn: sqlite3.Connection, task_id: str) -> int: + """Count the task's trailing run of clean-exit protocol violations. + + Walks closed runs newest-first (including the one ``detect_crashed_workers`` + just closed). ``rate_limited`` runs are neutral and skipped (a quota wall + says nothing about the task); any other closed run breaks the streak, so + the budget counts ONLY protocol violations. Violations are recognized by the + ``protocol_violation`` run-metadata marker, with the error text as fallback + for runs recorded before the marker existed. + """ + streak = 0 + rows = conn.execute( + "SELECT outcome, error, metadata FROM task_runs " + "WHERE task_id = ? AND ended_at IS NOT NULL " + "ORDER BY id DESC LIMIT ?", + (task_id, _PROTOCOL_VIOLATION_SCAN_LIMIT), + ).fetchall() + for row in rows: + outcome = row["outcome"] or "" + if outcome == "rate_limited": + continue + if outcome == "crashed" and ( + _kb._json_dict(row["metadata"]).get("protocol_violation") + or "protocol violation" in (row["error"] or "") + ): + streak += 1 + continue + break + return streak + + +_PROTOCOL_VIOLATION_ERROR = ( + # Worker subprocess returned 0 but its task is still ``running`` in the DB — it exited without calling + # ``kanban_complete`` / ``kanban_block``. Overwhelmingly the work itself succeeded and only the + # paperwork was skipped, so a retry usually completes; the corrective sentence below is surfaced to the + # retry worker via the prior-attempt error in ``build_worker_context`` (guidance approach from #61817). + "worker exited cleanly (rc=0) without calling " + "kanban_complete or kanban_block — protocol violation. " + "If the prior run already did the work, verify it and " + "report the result via kanban_complete; a run that ends " + "without a terminal kanban call counts as failed no " + "matter what it did." +) + + +@dataclass +class _DeadWorker: + """How ``detect_crashed_workers`` should book one dead worker.""" + + kind: str + code: Optional[int] + error_text: str + event_kind: str + event_payload: dict + protocol_violation: bool = False + rate_limited: bool = False + + @property + def run_outcome(self) -> str: + # A rate-limited requeue is recorded as ``rate_limited`` so board history + # doesn't show a phantom crash for a quota wall. + return "rate_limited" if self.rate_limited else "crashed" + + +def _classify_dead_worker(pid: int, claimer: Optional[str]) -> _DeadWorker: + """Map a dead worker's reaped exit status to its reclaim bookkeeping.""" + kind, code = _classify_worker_exit(pid) + if kind == "clean_exit": + # rc=0 while still ``running``: usually the work succeeded and only the + # paperwork was skipped; the corrective sentence reaches the retry + # worker via ``build_worker_context``. + return _DeadWorker( + kind, code, _PROTOCOL_VIOLATION_ERROR, "protocol_violation", + # ``protocol_violation`` is the durable marker for + # _protocol_violation_streak: _end_run copies this payload into the + # run metadata. + {"pid": pid, "claimer": claimer, "exit_code": code, "protocol_violation": True}, + protocol_violation=True, + ) + if kind == "rate_limited": + # Quota wall — NOT a task failure. Release to the source phase and do + # NOT count a failure so a long quota window can't trip the breaker. + return _DeadWorker( + kind, code, + f"pid {pid} exited rate-limited (quota wall) — requeued without counting a failure", + "rate_limited", + {"pid": pid, "claimer": claimer, "exit_code": code}, + rate_limited=True, + ) + if kind == "nonzero_exit": + error_text = f"pid {pid} exited with code {code}" + elif kind == "signaled": + error_text = f"pid {pid} killed by signal {code}" + else: + error_text = f"pid {pid} not alive" + event_payload = {"pid": pid, "claimer": claimer} + if code is not None and kind != "unknown": + event_payload["exit_kind"] = kind + event_payload["exit_code"] = code + return _DeadWorker(kind, code, error_text, "crashed", event_payload) + + +@dataclass +class _CrashSweep: + """Everything ``detect_crashed_workers`` collects inside its reclaim txn.""" + + crashed: list[str] = field(default_factory=list) + rate_limited: list[str] = field(default_factory=list) + # ``(task_id, pid, claimer, protocol_violation, error_text)``: accounted + # after the txn via ``_record_task_failure`` (needs its own write_txn). + crash_details: list[tuple[str, int, str, bool, str]] = field(default_factory=list) + # Worker-exit observer payloads, fired only after every reclaim/accounting + # txn has committed. + exited_hook_payloads: list[dict] = field(default_factory=list) + + +def _reclaim_dead_workers(conn: sqlite3.Connection) -> _CrashSweep: + """Release every host-local ``running`` task whose worker PID is dead.""" + sweep = _CrashSweep() + with _kb.write_txn(conn): + rows = conn.execute( + "SELECT id, worker_pid, claim_lock, started_at, assignee " + "FROM tasks " + "WHERE status = 'running' AND worker_pid IS NOT NULL" + ).fetchall() + host_prefix = _kb._host_prefix() + for row in rows: + lock = row["claim_lock"] or "" + if not lock.startswith(host_prefix): + continue + # Launch-window grace so a freshly-spawned worker isn't reclaimed + # before its PID is visible on /proc. + started_at = _kb._row_get(row, "started_at") + if started_at is not None and time.time() - started_at < _kb._resolve_crash_grace_seconds(): + continue + if _kb._pid_alive(row["worker_pid"]): + continue + + pid = int(row["worker_pid"]) + dead = _classify_dead_worker(pid, row["claim_lock"]) + retry_status = _kb._retry_status_for_run(conn, row["id"]) + dead.event_payload["retry_status"] = retry_status + cur = conn.execute( + "UPDATE tasks SET status = ?, claim_lock = NULL, " + "claim_expires = NULL, worker_pid = NULL " + "WHERE id = ? AND status = 'running' " + " AND worker_pid = ? AND claim_lock IS ?", + (retry_status, row["id"], pid, row["claim_lock"]), + ) + if cur.rowcount != 1: + continue + run_id = _kb._end_run( + conn, row["id"], + outcome=dead.run_outcome, status=dead.run_outcome, + error=dead.error_text, + metadata=dict(dead.event_payload), + ) + _kb._append_event(conn, row["id"], dead.event_kind, dead.event_payload, run_id=run_id) + sweep.exited_hook_payloads.append({ + "task_id": row["id"], + "assignee": row["assignee"], + "run_id": run_id, + "worker_pid": pid, + "exit_kind": dead.kind, + "exit_code": dead.code, + "outcome": dead.run_outcome, + "retry_status": retry_status, + }) + if dead.rate_limited or dead.protocol_violation: + # Stamp last_failure_error WITHOUT touching ``consecutive_failures``: + # a rate-limited requeue must show ``check_respawn_guard`` a quota + # blocker; a below-budget protocol violation never reaches + # ``_record_task_failure`` (which stamps this column), yet the + # board UI and retry worker need the corrective message. + conn.execute( + "UPDATE tasks SET last_failure_error = ? WHERE id = ?", + (dead.error_text[:500], row["id"]), + ) + if dead.rate_limited: + sweep.rate_limited.append(row["id"]) + else: + sweep.crashed.append(row["id"]) + sweep.crash_details.append( + (row["id"], pid, row["claim_lock"], dead.protocol_violation, dead.error_text) + ) + return sweep + + +def _account_crashes(conn: sqlite3.Connection, crash_details: list) -> list[str]: + """Count each crash against the breaker; returns the task ids it tripped. + + Protocol violations get a BOUNDED violation-only budget independent of + ``consecutive_failures`` (per-task ``max_retries`` takes precedence); + systemic same-error crashes (>= 3 identical fingerprints this tick) trip + immediately. + """ + auto_blocked: list[str] = [] + fp_counts: dict[str, int] = {} + for _, _, _, _, err_text in crash_details: + fp = _error_fingerprint(err_text) + fp_counts[fp] = fp_counts.get(fp, 0) + 1 + for tid, pid, claimer, protocol_violation, error_text in crash_details: + if protocol_violation: + streak = _protocol_violation_streak(conn, tid) + trow = conn.execute("SELECT max_retries FROM tasks WHERE id = ?", (tid,)).fetchone() + if trow is None: + continue # task deleted mid-loop + task_override = _kb._row_get(trow, "max_retries") + violation_limit = ( + int(task_override) if task_override is not None else _PROTOCOL_VIOLATION_FAILURE_LIMIT + ) + if streak < violation_limit: + # Below budget: already back at ``ready`` with the error stamped. + # No ``_record_task_failure`` — must not consume the unified budget. + continue + # ``force_trip``: the decision (incl. per-task ``max_retries``) was + # already made against the violation streak above. + tripped = _record_task_failure( + conn, tid, + error=error_text, + outcome="crashed", + failure_limit=violation_limit, + force_trip=True, + release_claim=False, + end_run=False, + event_payload_extra={ + "pid": pid, + "claimer": claimer, + "protocol_violations": streak, + "protocol_violation_limit": violation_limit, + }, + ) + else: + is_systemic = fp_counts.get(_error_fingerprint(error_text), 0) >= 3 + tripped = _record_task_failure( + conn, tid, + error=error_text, + outcome="crashed", + failure_limit=1 if is_systemic else None, + release_claim=False, + end_run=False, + event_payload_extra={"pid": pid, "claimer": claimer}, + ) + if tripped: + auto_blocked.append(tid) + return auto_blocked + + +def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]: + """Reclaim ``running`` tasks whose worker PID is no longer alive. + + Restores the source phase immediately (no waiting for the claim TTL), for + tasks claimed by *this host* only — other hosts' PIDs are meaningless. + Clean exit while ``running`` is a protocol violation with a bounded + violation-only retry budget; ``KANBAN_RATE_LIMIT_EXIT_CODE`` is a quota + wall, released WITHOUT counting a failure and surfaced via the + ``_last_rate_limited`` attribute (the return stays crashed-only). + """ + sweep = _reclaim_dead_workers(conn) + # Outside the main txn: account each crash and maybe trip the breaker. + auto_blocked = _account_crashes(conn, sweep.crash_details) if sweep.crash_details else [] + # Side-channel attributes keep the public ``list[str]`` return stable; + # ``dispatch_once`` reads them to populate ``DispatchResult``. Rate-limited + # requeues did NOT count a failure and are NOT crashes. + detect_crashed_workers._last_auto_blocked = auto_blocked # type: ignore[attr-defined] + detect_crashed_workers._last_rate_limited = sweep.rate_limited # type: ignore[attr-defined] + # Fired only now, after the reclaim txn AND breaker accounting have + # committed, so subscribers always observe fully durable board state. + if sweep.exited_hook_payloads and _kb._kanban_observer_consumed("on_kanban_worker_exited"): + _board = _kb.get_current_board() + for hook_fields in sweep.exited_hook_payloads: + hook_fields = dict(hook_fields) + _kb._fire_kanban_lifecycle_hook( + # Kanban worker-lifecycle, task-mutation, and dispatcher-tick observers (RFC #58548, + # accepted as the design basis in the #64231 batch disposition; on_kanban_dispatch_tick is + # the re-port of PR #56066). All five are observers only: return values are ignored, and + # every fire site is fully best-effort, so a broken callback can never break dispatch or a + # task mutation. Cost rule: every call site short-circuits on has_hook(), so when nothing + # subscribes no payload is built and the hot paths (each dispatcher tick, each task write) + # pay one dict probe. WHICH PROCESS: worker spawn/exit/stale-claim and the dispatch tick + # fire in the DISPATCHER process (gateway-embedded dispatcher or ``hermes kanban + # dispatch``); on_kanban_task_updated fires in whichever process committed the mutation + # (CLI, worker, or the gateway-embedded dashboard API). Common kwargs (task-scoped hooks): + # task_id: str, profile_name: str, board: str | None, assignee: str | None, run_id: int | + # None. on_kanban_worker_spawned fires after ``spawn_fn`` returns AND the worker PID (when + # one was reported) is durably persisted, per the RFC timing contract; like + # kanban_task_claimed it runs inside the board's dispatch lock, so callbacks must stay fast. + # Adds: worker_pid: int | None, workspace_path: str. Privacy: workspace_path is a filesystem + # path and may reveal project layout or usernames. + "on_kanban_worker_exited", + hook_fields.pop("task_id"), + board=_board, + **hook_fields, + ) + return sweep.crashed + + +def _record_task_failure( + conn: sqlite3.Connection, + task_id: str, + error: str, + *, + outcome: str, + failure_limit: int = None, + force_trip: bool = False, + release_claim: bool = False, + end_run: bool = False, + event_payload_extra: Optional[dict] = None, +) -> bool: + """Record a non-success outcome and maybe trip the circuit breaker; every + non-success path funnels through here so ``consecutive_failures`` stays + consistent. Returns True when the task was auto-blocked. + + ``release_claim=True, end_run=True``: spawn-failure path (task still + running with an open run — restore source phase or ``blocked``, release + claim, close run). Both False: timeout/crash path (caller already restored + the phase and closed the run; only the counter moves, a trip flips to + ``blocked`` + ``gave_up``). Threshold: per-task ``max_retries`` > + ``failure_limit`` > ``DEFAULT_FAILURE_LIMIT``. ``force_trip`` trips + unconditionally (caller applied its own bounded-retry policy). + """ + if failure_limit is None: + failure_limit = DEFAULT_FAILURE_LIMIT + error = error[:500] + with _kb.write_txn(conn): + row = conn.execute( + "SELECT consecutive_failures, status, max_retries, current_run_id " + "FROM tasks WHERE id = ?", (task_id,), + ).fetchone() + if row is None: + return False + retry_status = ( + _kb._retry_status_for_run(conn, task_id, row["current_run_id"]) + if release_claim + else ("review" if row["status"] == "review" else "ready") + ) + failures = int(row["consecutive_failures"]) + 1 + + # Per-task override wins over caller-supplied and default thresholds. + task_override = _kb._row_get(row, "max_retries") + if task_override is not None: + effective_limit, limit_source = int(task_override), "task" + else: + effective_limit, limit_source = int(failure_limit), "dispatcher" + + if not (force_trip or failures >= effective_limit): + if release_claim: + # Spawn path: restore the claimed source phase + clear claim. + conn.execute( + "UPDATE tasks SET status = ?, claim_lock = NULL, " + "claim_expires = NULL, worker_pid = NULL, " + "consecutive_failures = ?, last_failure_error = ? " + "WHERE id = ? AND status = 'running'", + (retry_status, failures, error, task_id), + ) + else: + conn.execute( + "UPDATE tasks SET consecutive_failures = ?, " + "last_failure_error = ? WHERE id = ?", + (failures, error, task_id), + ) + # Timeout/crash path's caller already emitted its own event. + if end_run: + run_id = _kb._end_run( + conn, task_id, outcome=outcome, status=outcome, error=error, + metadata={"failures": failures, "retry_status": retry_status}, + ) + _kb._append_event( + conn, task_id, outcome, + {"error": error, "failures": failures, "retry_status": retry_status}, + run_id=run_id, + ) + return False + + # Spawn path (release_claim) is still running and also clears claim + # state; the timeout/crash path already did. + conn.execute( + "UPDATE tasks SET status = 'blocked', " + + ("claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, " + if release_claim else "") + + "consecutive_failures = ?, last_failure_error = ? " + "WHERE id = ? AND status IN ('running', 'ready', 'review')", + (failures, error, task_id), + ) + payload = { + "failures": failures, + "effective_limit": effective_limit, + "limit_source": limit_source, + "error": error, + "trigger_outcome": outcome, + "retry_status": retry_status, + } + run_id = None + if end_run: + # Only the spawn path has an open run to close. + run_id = _kb._end_run( + conn, task_id, outcome="gave_up", status="gave_up", error=error, + metadata={ + "failures": failures, + "trigger_outcome": outcome, + "effective_limit": effective_limit, + "limit_source": limit_source, + "retry_status": retry_status, + }, + ) + if event_payload_extra: + payload.update(event_payload_extra) + _kb._append_event(conn, task_id, "gave_up", payload, run_id=run_id) + return True + + +def _set_worker_pid(conn: sqlite3.Connection, task_id: str, pid: int) -> None: + """Record the spawned child's pid + emit a ``spawned`` event carrying it.""" + with _kb.write_txn(conn): + conn.execute("UPDATE tasks SET worker_pid = ? WHERE id = ?", (int(pid), task_id)) + run_id = _kb._current_run_id(conn, task_id) + if run_id is not None: + conn.execute("UPDATE task_runs SET worker_pid = ? WHERE id = ?", (int(pid), run_id)) + _kb._append_event(conn, task_id, "spawned", {"pid": int(pid)}, run_id=run_id) + + +def _clear_failure_counter(conn: sqlite3.Connection, task_id: str) -> None: + """Reset the unified consecutive-failures counter. + + Called from ``complete_task`` on success. NOT called on spawn success: a + spawn proves the worker could start, not that the run will succeed, so + timeouts and crashes must accumulate across spawn boundaries. + """ + with _kb.write_txn(conn): + conn.execute( + "UPDATE tasks SET consecutive_failures = 0, " + "last_failure_error = NULL WHERE id = ?", + (task_id,), + ) + + +def check_respawn_guard( + conn: sqlite3.Connection, task_id: str, *, lane: str = "ready", +) -> Optional[str]: + """Return a guard reason if ``task_id`` should NOT be re-spawned, else None. + + Called per ready/review row before any claim attempt. Priority order: + ``"rate_limit_cooldown"`` (latest run ``rate_limited`` within the cooldown; + checked BEFORE ``blocker_auth`` because the requeue stamps a quota-flavored + ``last_failure_error`` that would otherwise park the task forever — that + path never increments ``consecutive_failures``), ``"blocker_auth"`` + (quota/auth pattern; the breaker still trips eventually), then for the + ready lane only ``"recent_success"`` (completed run within the window, unless + a re-queue event arrived after it — a deliberate re-run) and ``"active_pr"`` + (PR URL in a recent comment; re-spawning risks a duplicate PR). The review + lane skips the last two: they are the *inputs* to a review handoff. Stale / + dead claim locks are NOT a guard reason — the reclaim passes own those. + """ + row = conn.execute( + "SELECT last_failure_error FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if row is None: + return None + + now = int(time.time()) + + # 1. Rate-limit cooldown — see docstring for why this precedes blocker_auth. + # LATEST run only: a newer crash/completion supersedes the rate-limit run. + rl_cooldown = _kb._resolve_rate_limit_cooldown_seconds() + latest_run = conn.execute( + "SELECT outcome, ended_at FROM task_runs " + "WHERE task_id = ? AND ended_at IS NOT NULL " + "ORDER BY ended_at DESC LIMIT 1", + (task_id,), + ).fetchone() + if latest_run is not None and latest_run["outcome"] == "rate_limited": + if rl_cooldown <= 0: + # Cooldown disabled — respawn immediately, skipping blocker_auth so + # the stamped rate-limit text doesn't re-trap the task. + return None + ended_at = latest_run["ended_at"] + if ended_at is not None and (now - int(ended_at)) < rl_cooldown: + return "rate_limit_cooldown" + # Cooldown elapsed — return early so blocker_auth doesn't catch the + # stamped rate-limit text; this path intentionally retries forever + # (spaced by the cooldown) until quota returns or a real run supersedes it. + return None + + # 2. Quota / auth blocker: retrying immediately will not help. + err = row["last_failure_error"] + if err and _RESPAWN_BLOCKER_RE.search(err): + return "blocker_auth" + + # Review-lane spawns stop here: a recent completed run and a fresh PR URL + # are the canonical *inputs* to a review handoff, not duplicate-work signals. + if lane == "review": + return None + + # 3. Completed run within guard window. Exception: an explicit re-queue + # AFTER that success (done→ready drag, re-promotion, unblock, reclaim) is + # a deliberate "run it again" — otherwise a manual done→ready would sit + # silently held until the window elapses. + cutoff = now - _RESPAWN_GUARD_SUCCESS_WINDOW + recent_completed = conn.execute( + "SELECT ended_at FROM task_runs " + "WHERE task_id = ? AND outcome = 'completed' AND ended_at >= ? " + "ORDER BY ended_at DESC LIMIT 1", + (task_id, cutoff), + ).fetchone() + if recent_completed: + completed_at = int(recent_completed["ended_at"] or 0) + requeued_after = conn.execute( + "SELECT 1 FROM task_events " + "WHERE task_id = ? AND created_at >= ? " + "AND kind IN ('status', 'promoted', 'unblocked', 'reclaimed') " + "LIMIT 1", + (task_id, completed_at), + ).fetchone() + if not requeued_after: + return "recent_success" + + # 4. GitHub PR URL in a recent comment — prior worker already opened a PR. + pr_cutoff = now - _RESPAWN_GUARD_PR_WINDOW + for c in conn.execute( + "SELECT body FROM task_comments WHERE task_id = ? AND created_at >= ?", + (task_id, pr_cutoff), + ).fetchall(): + if c["body"] and _RESPAWN_GUARD_PR_URL_RE.search(c["body"]): + return "active_pr" + + return None + + +def _profile_exists_fn() -> Optional[Callable[[str], bool]]: + """``hermes_cli.profiles.profile_exists``, or ``None`` when it cannot be + imported (local import avoids a cycle; callers fall back to trusting the + assignee).""" + try: + from hermes_cli.profiles import profile_exists + except Exception: + return None + return profile_exists + + +def _has_spawnable(conn: sqlite3.Connection, status: str) -> bool: + rows = conn.execute( + "SELECT DISTINCT assignee FROM tasks " + "WHERE status = ? AND assignee IS NOT NULL AND claim_lock IS NULL", + (status,), + ).fetchall() + if not rows: + return False + profile_exists = _profile_exists_fn() + if profile_exists is None: + # Can't introspect — assume spawnable, preserve legacy behavior. + return True + return any(profile_exists(row["assignee"]) for row in rows) + + +def has_spawnable_ready(conn: sqlite3.Connection) -> bool: + """True iff a ready+assigned+unclaimed task maps to a real Hermes profile. + + Lets health telemetry tell "stuck" (``0 spawned`` with spawnable work) from + "correctly idle" (only control-plane lanes waiting on ``claim_task``). Falls + back to "any assigned" when ``profile_exists`` is unimportable. + """ + return _has_spawnable(conn, "ready") + + +def has_spawnable_review(conn: sqlite3.Connection) -> bool: + """:func:`has_spawnable_ready` for the review column.""" + return _has_spawnable(conn, "review") + + +def review_dispatch_enabled() -> bool: + """Whether review tasks dispatch automatically. Default true (Hermes ships + ``sdlc-review``); operators disable it for human-only review boards. + """ + try: + from hermes_cli.config import load_config + return bool((load_config() or {}).get("kanban", {}).get("review_dispatch", True)) + except Exception: + return True + + +# Memory-aware dispatch guard: an uncapped board once OOM'd a 1 GiB host. Two +# safeguards — a memory-DERIVED default cap when none is configured +# (``resolve_max_in_progress``) and a live memory-PRESSURE guard inside the +# tick (``_memory_pressure_level``) because a static cap can't see other +# tenants. Both fail open: non-Linux / read error → no cap / "unknown". + +# Assumed per-worker footprint for the derived cap; deliberately conservative +# so the cap errs toward fewer workers on small VMs. +MEMORY_GUARD_MB_PER_WORKER = 512 + +# Derived default bounds: never below 2 (smallest VM must still progress), +# never above 8 (more fan-out must be explicit in config). +DERIVED_MAX_IN_PROGRESS_FLOOR = 2 +DERIVED_MAX_IN_PROGRESS_CEILING = 8 + + +def _system_memory_sample() -> dict: + """Best-effort system memory snapshot (KiB values), ``{}`` when unknown. + + Local import keeps ``kanban_db`` importable without the gateway package. + Module-level indirection is also the test seam — conftest patches this to + ``{}`` so results don't depend on the CI runner's live memory. + """ + try: + from gateway.lifecycle_ledger import sample_memory + return sample_memory() or {} + except Exception: + return {} + + +def derive_default_max_in_progress(sample: Optional[Mapping[str, Any]] = None) -> Optional[int]: + """Memory-derived default for ``kanban.max_in_progress`` when unset: + ``clamp(MemTotal / MEMORY_GUARD_MB_PER_WORKER, FLOOR, CEILING)``. Returns + ``None`` (no cap) when total memory is unknown, so macOS/Windows dev + machines are unaffected. + """ + if sample is None: + sample = _system_memory_sample() + total_kib = sample.get("mem_total_kib") + if isinstance(total_kib, bool) or not isinstance(total_kib, int) or total_kib <= 0: + return None + workers = (total_kib // 1024) // MEMORY_GUARD_MB_PER_WORKER + return max(DERIVED_MAX_IN_PROGRESS_FLOOR, min(workers, DERIVED_MAX_IN_PROGRESS_CEILING)) + + +def resolve_max_in_progress(configured: Optional[int]) -> Optional[int]: + """Effective global concurrency cap: explicit config wins, else the + memory-derived default. All config-parsing callers route through this so + both paths agree. + """ + if configured is not None: + return configured + return derive_default_max_in_progress() + + +def configured_max_in_progress() -> Optional[int]: + """Read ``kanban.max_in_progress`` from config, or None when unset/invalid. + + Shared so every dispatch entry point agrees on "explicitly configured": a + positive integer wins, anything else falls through to the derived default. + """ + try: + from hermes_cli.config import load_config_readonly + raw = (load_config_readonly() or {}).get("kanban", {}).get("max_in_progress") + except Exception: + return None + if raw is None: + return None + try: + ival = int(raw) + except (TypeError, ValueError): + return None + return ival if ival >= 1 else None + + +def count_running_tasks(conn: sqlite3.Connection) -> int: + """Number of tasks in ``status='running'``. + + Used by the multi-board sweep to count OTHER boards' workers against the + host-level budget — the memory-derived cap bounds the machine, not the + board. Fails open to 0 so a broken board doesn't brick dispatch on healthy ones. + """ + try: + return int( + conn.execute( + "SELECT COUNT(*) FROM tasks WHERE status = 'running'" + ).fetchone()[0] + ) + except Exception: + return 0 + + +def count_running_tasks_other_boards(board: Optional[str] = None) -> int: + """Total ``running`` tasks across every board EXCEPT ``board``. + + Caps bound the HOST, but each board's tick only sees its own DB; without + this a derived cap of N gets multiplied by the number of active boards. + Boards are matched by resolved DB path, so ``HERMES_KANBAN_DB`` (pins every + board to one file) yields 0. Fails open per board. + """ + try: + current_path = str(_kb.kanban_db_path(board=board).expanduser().resolve()) + except Exception: + current_path = None + try: + boards = _kb.list_boards(include_archived=False) + except Exception: + return 0 + total = 0 + for meta in boards: + slug = meta.get("slug") or _kb.DEFAULT_BOARD + try: + path = _kb.kanban_db_path(board=slug).expanduser() + resolved = str(path.resolve()) + if current_path is not None and resolved == current_path: + continue + if not path.exists(): + continue + other = _kbc.connect(board=slug) + try: + total += count_running_tasks(other) + finally: + with contextlib.suppress(Exception): + other.close() + except Exception: + continue + return total + + +def _memory_pressure_level(sample: Optional[Mapping[str, Any]] = None) -> str: + """Classify system memory pressure: ok/elevated/critical/unknown. + + Reuses :func:`gateway.memory_status.classify_pressure` so "critical" matches + the dashboard banner and lifecycle-ledger OOM heuristics. ``unknown`` + (non-Linux, read failure) imposes no restriction — never brick dispatch + where /proc is unavailable. + """ + if sample is None: + sample = _system_memory_sample() + if not sample: + return "unknown" + try: + from gateway.memory_status import classify_pressure + return classify_pressure(sample.get("mem_available_kib"), sample.get("mem_total_kib")) + except Exception: + return "unknown" + + +def dispatch_once( + conn: sqlite3.Connection, + *, + spawn_fn=None, + ttl_seconds: Optional[int] = None, + dry_run: bool = False, + max_spawn: Optional[int] = None, + max_in_progress: Optional[int] = None, + failure_limit: int = DEFAULT_FAILURE_LIMIT, + stale_timeout_seconds: int = 0, + board: Optional[str] = None, + default_assignee: Optional[str] = None, + max_in_progress_per_profile: Optional[int] = None, + reconcile_orphans: bool = True, +) -> DispatchResult: + """Run one dispatcher tick under the board's single-writer lock. + + Wraps :func:`_dispatch_once_locked` in the non-blocking :func:`_dispatch_tick_lock` + so two dispatchers on one ``kanban.db`` never race a write tick on WAL + frames. The loser returns an empty ``DispatchResult`` with + ``skipped_locked=True`` and writes nothing; the lock is keyed on the + resolved DB path so unrelated boards tick in parallel. + """ + def _locked_tick() -> DispatchResult: + return _dispatch_once_locked( + conn, + spawn_fn=spawn_fn, + ttl_seconds=ttl_seconds, + dry_run=dry_run, + max_spawn=max_spawn, + max_in_progress=max_in_progress, + failure_limit=failure_limit, + stale_timeout_seconds=stale_timeout_seconds, + board=board, + default_assignee=default_assignee, + max_in_progress_per_profile=max_in_progress_per_profile, + reconcile_orphans=reconcile_orphans, + ) + + try: + db_path = _kb.kanban_db_path(board=board) + except Exception: + # Must not lose the tick — fall through to an unguarded dispatch. + result = _locked_tick() + _kb._fire_dispatch_tick_hook(result, board=board, dry_run=dry_run) + return result + with _kbc._dispatch_tick_lock(db_path) as held: + if not held: + result = DispatchResult(skipped_locked=True) + else: + result = _locked_tick() + # Still under the dispatch lock: periodic PASSIVE WAL checkpoint. + _kbc._maybe_checkpoint_wal(conn, db_path) + # Lock released. Fire the tick observer strictly OUTSIDE the critical + # section: a slow subscriber must never stall a sibling dispatcher's tick. + _kb._fire_dispatch_tick_hook(result, board=board, dry_run=dry_run) + return result + + +def _call_spawn_fn(spawn_fn, task: Task, workspace: str, board: Optional[str]) -> Optional[int]: + """Back-compat: older spawn_fn signatures (and test stubs) accept only + ``(task, workspace)``; pass ``board`` only when the callable supports it.""" + import inspect + try: + sig = inspect.signature(spawn_fn) + if "board" in sig.parameters: + return spawn_fn(task, workspace, board=board) + return spawn_fn(task, workspace) + except (TypeError, ValueError): + return spawn_fn(task, workspace) + + +def _dispatch_lane_task( + conn: sqlite3.Connection, + row: sqlite3.Row, + assignee: str, + result: "DispatchResult", + *, + lane: str, + dry_run: bool, + ttl_seconds: Optional[int], + board: Optional[str], + failure_limit: int, + spawn_fn, + per_profile_cap: Optional[int], + per_profile_running: dict[str, int], +) -> bool: + """Guard, claim, resolve the workspace and spawn one ready/review row. + Returns True when a spawn slot was consumed (real or ``dry_run``); every + skip is recorded on ``result``. + """ + task_id = row["id"] + # Non-profile assignees (control-plane lanes that pull via ``claim_task``) + # would fail ``hermes -p `` at startup and loop ready→crash→ready + # forever. Bucketed apart from skipped_unassigned: the operator cannot fix + # it by assigning a profile, and health telemetry suppresses "stuck" for it. + profile_exists = _profile_exists_fn() + if profile_exists is not None and not profile_exists(assignee): + result.skipped_nonspawnable.append(task_id) + return False + # Per-profile cap: one profile's local model / API quota / browser pool + # must not be overwhelmed by a fan-out even with global headroom. + if per_profile_cap is not None: + current = per_profile_running.get(assignee, 0) + if current >= per_profile_cap: + result.skipped_per_profile_capped.append((task_id, assignee, current)) + return False + guard_reason = check_respawn_guard(conn, task_id, lane=lane) + if guard_reason is not None: + result.respawn_guarded.append((task_id, guard_reason)) + # Event so ``hermes kanban tail`` shows why the task looks stuck. + # Honour kanban.default_assignee: when the dispatcher hits an unassigned ready task and an + # operator-configured fallback exists, persist the assignment and proceed. This removes the + # dashboard footgun where a task created without an assignee parks in 'ready' forever even though + # the operator's intent ("default") was perfectly clear (#27145). Mutating the row (not just the + # in-memory view) keeps diagnostics and the board state consistent: the task is now legitimately + # owned by ``kanban.default_assignee``, not "unassigned but secretly routed". + if not dry_run: + with _kb.write_txn(conn): + _kb._append_event(conn, task_id, "respawn_guarded", {"reason": guard_reason}) + return False + + def _count_spawn(name: str) -> None: + # Later rows in this tick respect the per-profile cap; subsequent + # ticks re-query from the DB. + if per_profile_cap is not None and name: + per_profile_running[name] = per_profile_running.get(name, 0) + 1 + + if dry_run: + result.spawned.append((task_id, assignee, "")) + _count_spawn(assignee) + return True + claim = _kb.claim_review_task if lane == "review" else _kb.claim_task + claimed = claim(conn, task_id, ttl_seconds=ttl_seconds) + if claimed is None: + return False + try: + resolved_branch_name = None + if claimed.workspace_kind == "worktree": + workspace, resolved_branch_name = _kbw._resolve_worktree_workspace(claimed, board=board) + else: + workspace = _kbw.resolve_workspace(claimed, board=board) + except Exception as exc: + if _record_task_failure( + conn, claimed.id, f"workspace: {exc}", + outcome="spawn_failed", failure_limit=failure_limit, release_claim=True, end_run=True, + ): + result.auto_blocked.append(claimed.id) + return False + _kbw.set_workspace_path(conn, claimed.id, str(workspace)) + if claimed.workspace_kind == "worktree": + _kbw.set_branch_name(conn, claimed.id, resolved_branch_name or (claimed.branch_name or "").strip() or f"wt/{claimed.id}") + _kbw._maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind) + if lane == "review": + # Force-load sdlc-review; the kanban lifecycle is already in every + # worker's system prompt via KANBAN_GUIDANCE. + claimed.skills = list(dict.fromkeys([*(claimed.skills or []), "sdlc-review"])) + try: + pid = _call_spawn_fn(spawn_fn if spawn_fn is not None else _default_spawn, claimed, str(workspace), board) + if pid: + _set_worker_pid(conn, claimed.id, int(pid)) + # Fires AFTER the PID (when reported) is durably persisted. Best-effort. + _kb._fire_worker_spawned_hook(conn, claimed, str(workspace), pid, board=board) + # consecutive_failures is deliberately NOT reset here: resetting on + # spawn would let a task that keeps timing out loop forever. Cleared + # only on successful completion (complete_task). + result.spawned.append((claimed.id, claimed.assignee or "", str(workspace))) + _count_spawn(claimed.assignee) + return True + except Exception as exc: + if _record_task_failure( + conn, claimed.id, str(exc), + outcome="spawn_failed", failure_limit=failure_limit, release_claim=True, end_run=True, + ): + result.auto_blocked.append(claimed.id) + return False + + +def _apply_default_assignee( + conn: sqlite3.Connection, task_id: str, assignee: str, *, dry_run: bool, +) -> bool: + """Persist ``kanban.default_assignee`` on an unassigned ready row. + + Mutating the row keeps board state honest: the task is legitimately owned + by the default, not "unassigned but secretly routed". ``dry_run`` reports + without writing. Returns False when the write failed. + """ + if dry_run: + return True + try: + with _kb.write_txn(conn): + conn.execute( + "UPDATE tasks SET assignee = ? WHERE id = ? " + "AND (assignee IS NULL OR assignee = '')", + (assignee, task_id), + ) + _kb._append_event( + conn, task_id, "assigned", + {"assignee": assignee, "source": "kanban.default_assignee"}, + ) + except Exception: + _kb._log.debug( + "kanban dispatch: failed to apply default_assignee=%r to task %s", + assignee, task_id, exc_info=True, + ) + return False + return True + + +def _run_reclaim_phase( + conn: sqlite3.Connection, + result: DispatchResult, + *, + stale_timeout_seconds: int, + failure_limit: int, + reconcile_orphans: bool, +) -> None: + """Reclaim stale/orphaned/crashed/timed-out running tasks, then promote.""" + reap_worker_zombies() + result.reclaimed = _kb.release_stale_claims(conn) + if reconcile_orphans: + result.reconciled_orphans = reconcile_orphaned_running(conn) + result.stale = detect_stale_running(conn, stale_timeout_seconds=stale_timeout_seconds) + result.crashed = detect_crashed_workers(conn) + # Side-channel attributes (see detect_crashed_workers); rate-limited tasks + # went back to ``ready`` and the respawn guard defers them until quota clears. + result.auto_blocked.extend(getattr(detect_crashed_workers, "_last_auto_blocked", [])) + result.rate_limited.extend(getattr(detect_crashed_workers, "_last_rate_limited", [])) + result.timed_out = enforce_max_runtime(conn) + result.promoted = _kb.recompute_ready(conn, failure_limit=failure_limit) + + +def _tick_spawn_budget( + conn: sqlite3.Connection, + result: DispatchResult, + *, + max_spawn: Optional[int], + max_in_progress: Optional[int], + board: Optional[str], +) -> tuple[bool, Optional[int]]: + """``(may_spawn, spawn_budget)`` for this tick; ``budget None`` = uncapped. + + ``max_spawn`` is a live per-board concurrency cap (running + this tick's + spawns), not a per-tick budget — a per-tick reading would grow concurrency + by N every tick. ``max_in_progress`` is a HOST-level cap: running workers on + every other board count against the same budget, else N boards multiply the + cap by N — exactly the fan-out the memory-derived default exists to prevent. + """ + # Count already-running tasks so max_spawn enforces concurrency, not a + # per-tick budget: "running" tasks stay running until the worker calls + # kanban_complete/kanban_block or the TTL reclaims them. + running_count = 0 + spawn_budget: Optional[int] = None + if max_spawn is not None or max_in_progress is not None: + running_count = count_running_tasks(conn) + + # Both ready and review loops consume from the same budget. + if max_spawn is not None: + if running_count >= max_spawn: + return False, None + spawn_budget = max_spawn - running_count + + if max_in_progress is not None: + total_running = running_count + count_running_tasks_other_boards(board) + if total_running >= max_in_progress: + return False, None + remaining = max_in_progress - total_running + if spawn_budget is None or spawn_budget > remaining: + spawn_budget = remaining + + # Memory-pressure guard: a static cap can't see the host's actual state. + # critical -> spawn nothing this tick; elevated -> at most one new worker. + # Reclaim/promotion already ran, so bookkeeping stays live; deferred tasks + # wait for a later tick. "unknown" imposes no restriction. + pressure = _memory_pressure_level() + if pressure == "critical": + result.memory_pressure = pressure + _kb._log.warning( + "kanban dispatch: system memory pressure is critical; " + "spawning no new workers this tick (deferred, not dropped)" + ) + return False, None + if pressure == "elevated": + result.memory_pressure = pressure + if spawn_budget is None or spawn_budget > 1: + _kb._log.warning( + "kanban dispatch: system memory pressure is elevated; " + "limiting to at most 1 new worker this tick" + ) + spawn_budget = 1 + return True, spawn_budget + + +def _lane_rows(conn: sqlite3.Connection, status: str) -> list[sqlite3.Row]: + """Unclaimed rows of one lane in dispatch order.""" + return conn.execute( + "SELECT id, assignee FROM tasks " + f"WHERE status = '{status}' AND claim_lock IS NULL " + "ORDER BY priority DESC, created_at ASC" + ).fetchall() + + +def _any_spawnable_review(review_rows: list[sqlite3.Row]) -> bool: + """Mirrors the review loop's own gate so human-pulled control-plane lanes + don't tax ready throughput; assumes spawnable when profiles are unimportable.""" + if not review_rows: + return False + profile_exists = _profile_exists_fn() + if profile_exists is None: + return any(row["assignee"] for row in review_rows) + return any(row["assignee"] and profile_exists(row["assignee"]) for row in review_rows) + + +def _resolve_default_assignee(default_assignee: Optional[str]) -> Optional[str]: + """``kanban.default_assignee`` when it names a real profile. When the + profiles module isn't importable trust the operator's config: the + downstream profile_exists check still buckets a missing profile as + nonspawnable.""" + name = (default_assignee or "").strip() or None + if name: + try: + from hermes_cli.profiles import profile_exists + if not profile_exists(name): + return None + except Exception: + pass + return name + + +# The dispatch lock has been released here. Fire the tick observer strictly OUTSIDE the single-writer +# critical section (#56066 sweeper finding / #64231 disposition): a slow subscriber must never extend the +# lock hold and stall a sibling dispatcher's tick. +def _dispatch_once_locked( + conn: sqlite3.Connection, + *, + spawn_fn=None, + ttl_seconds: Optional[int] = None, + dry_run: bool = False, + max_spawn: Optional[int] = None, + max_in_progress: Optional[int] = None, + failure_limit: int = DEFAULT_FAILURE_LIMIT, + stale_timeout_seconds: int = 0, + board: Optional[str] = None, + default_assignee: Optional[str] = None, + max_in_progress_per_profile: Optional[int] = None, + reconcile_orphans: bool = True, +) -> DispatchResult: + """One dispatcher tick: reclaim stale/crashed running tasks, promote + todo -> ready, then atomically claim each spawnable ready/review row and + call ``spawn_fn(task, workspace_path, board) -> Optional[int]``, recording + the PID so later ticks catch crashes before the TTL. Cap semantics: + :func:`_tick_spawn_budget`.""" + result = DispatchResult() + _run_reclaim_phase( + conn, result, stale_timeout_seconds=stale_timeout_seconds, + failure_limit=failure_limit, reconcile_orphans=reconcile_orphans, + ) + may_spawn, spawn_budget = _tick_spawn_budget( + conn, result, max_spawn=max_spawn, max_in_progress=max_in_progress, board=board, + ) + if not may_spawn: + return result + + ready_rows = _lane_rows(conn, "ready") + # Review rows are enumerated up front so the budget split can see whether + # review work exists at all. + review_rows = _lane_rows(conn, "review") if review_dispatch_enabled() else [] + # Review-lane reservation: the ready loop runs first and would otherwise + # consume the ENTIRE shared budget, starving reviews under a sustained ready + # backlog. When spawnable review work exists and there is any budget, hold + # one slot back. + ready_budget = spawn_budget + if spawn_budget is not None and spawn_budget > 0 and _any_spawnable_review(review_rows): + ready_budget = max(spawn_budget - 1, 0) + # Per-profile cap. Deferred tasks go to skipped_per_profile_capped, not + # skipped_unassigned — "busy, retry later" differs from "needs routing". + per_profile_cap = max_in_progress_per_profile if ( + # Per-profile concurrency cap (#21582): when set, track how many workers each assignee already has + # in flight, and refuse to spawn when this would push that assignee past the cap. Prevents fan-out + # workloads from melting a single profile's local model / API quota / browser pool while leaving + # other profiles idle. + isinstance(max_in_progress_per_profile, int) + and max_in_progress_per_profile > 0 + ) else None + per_profile_running: dict[str, int] = {} + if per_profile_cap is not None: + for prow in conn.execute( + "SELECT assignee, COUNT(*) AS n FROM tasks " + "WHERE status = 'running' AND assignee IS NOT NULL " + "GROUP BY assignee" + ): + per_profile_running[prow["assignee"]] = int(prow["n"]) + lane_kwargs: dict[str, Any] = dict( + dry_run=dry_run, ttl_seconds=ttl_seconds, board=board, + failure_limit=failure_limit, spawn_fn=spawn_fn, + per_profile_cap=per_profile_cap, per_profile_running=per_profile_running, + ) + default_assignee = _resolve_default_assignee(default_assignee) + spawned = 0 + for row in ready_rows: + if ready_budget is not None and spawned >= ready_budget: + break + row_assignee = row["assignee"] + if not row_assignee: + # Honour kanban.default_assignee so an unassigned task doesn't + # park in 'ready' forever. + if not default_assignee or not _apply_default_assignee( + conn, row["id"], default_assignee, dry_run=dry_run, + ): + result.skipped_unassigned.append(row["id"]) + continue + row_assignee = default_assignee + result.auto_assigned_default.append(row["id"]) + if _dispatch_lane_task(conn, row, row_assignee, result, lane="ready", **lane_kwargs): + spawned += 1 + + # A review agent (sdlc-review) approves (→ done) or requests changes + # (→ ready/todo). Review spawns share max_spawn with ready tasks. The loop + # checks the FULL shared ``spawn_budget`` — the reservation above caps the + # ready lane, it grants no extra capacity here. + for row in review_rows: + if spawn_budget is not None and spawned >= spawn_budget: + break + if not row["assignee"]: + result.skipped_unassigned.append(row["id"]) + continue + if _dispatch_lane_task(conn, row, row["assignee"], result, lane="review", **lane_kwargs): + spawned += 1 + return result + + +def _positive_int(value: Any, default: int, *, minimum: int = 1) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= minimum else default + + +def worker_log_rotation_config(kanban_cfg: Optional[dict] = None) -> tuple[int, int]: + """Return ``(rotate_bytes, backup_count)`` for worker log rotation. + Defaults: rotate at 2 MiB, keep one backup (``.log.1``); both overridable + from ``config.yaml``. + """ + if kanban_cfg is None: + try: + from hermes_cli.config import load_config + + kanban_cfg = (load_config().get("kanban") or {}) + except Exception: + kanban_cfg = {} + kanban_cfg = kanban_cfg or {} + max_bytes = _positive_int(kanban_cfg.get("worker_log_rotate_bytes"), DEFAULT_LOG_ROTATE_BYTES, minimum=1) + backup_count = _positive_int(kanban_cfg.get("worker_log_backup_count"), DEFAULT_LOG_BACKUP_COUNT, minimum=0) + return max_bytes, backup_count + + +def _rotated_log_path(log_path: Path, generation: int) -> Path: + return log_path.with_suffix(log_path.suffix + f".{generation}") + + +def _rotate_worker_log( + log_path: Path, + max_bytes: int, + backup_count: int = DEFAULT_LOG_BACKUP_COUNT, +) -> None: + """Rotate ```` when it exceeds ``max_bytes``: ```` → ``.1``, + older generations shift up to ``backup_count``. + """ + try: + if not log_path.exists() or log_path.stat().st_size <= max_bytes: + return + backup_count = _positive_int(backup_count, DEFAULT_LOG_BACKUP_COUNT, minimum=0) + if backup_count == 0: + log_path.unlink() + return + oldest = _rotated_log_path(log_path, backup_count) + with contextlib.suppress(OSError): + if oldest.exists(): + oldest.unlink() + for generation in range(backup_count - 1, 0, -1): + src = _rotated_log_path(log_path, generation) + if not src.exists(): + continue + with contextlib.suppress(OSError): + src.rename(_rotated_log_path(log_path, generation + 1)) + log_path.rename(_rotated_log_path(log_path, 1)) + except OSError: + pass + + +def _module_hermes_argv() -> list[str]: + """Interpreter-bound Hermes CLI invocation (``hermes_cli.main`` is the + console-script target — there is no top-level ``hermes`` package).""" + return [sys.executable, "-m", "hermes_cli.main"] + + +def _absolute_hermes_path(path: str) -> str: + """Return an absolute filesystem path for a resolved Hermes shim.""" + expanded = os.path.expanduser(path) + return expanded if os.path.isabs(expanded) else os.path.abspath(expanded) + + +def _looks_like_path(value: str) -> bool: + """Return true when a command override is an explicit path, not a name.""" + expanded = os.path.expanduser(value) + return ( + expanded.startswith("~") + or os.path.isabs(expanded) + or bool(os.path.dirname(expanded)) + or "\\" in expanded + or bool(re.match(r"^[A-Za-z]:", expanded)) + ) + + +def _is_windows_batch_shim(path: str) -> bool: + """Return true for Windows shell/batch shims that should not be argv[0].""" + return path.lower().endswith((".cmd", ".bat")) + + +def _path_search_names(command: str) -> list[str]: + """Return executable names to try for an unqualified command.""" + if not _kb._IS_WINDOWS or os.path.splitext(command)[1]: + return [command] + raw = os.environ.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD" + return [command + ext for ext in raw.split(";") if ext] + + +def _safe_which_no_cwd(command: str) -> Optional[str]: + """Resolve a bare command from PATH without implicit current-dir search. + + On Windows ``shutil.which`` may search the current directory before PATH + for bare names — unsafe for a dispatcher. Only explicit PATH entries are + considered; empty / ``.`` entries are skipped. + """ + for raw_dir in os.environ.get("PATH", "").split(os.pathsep): + if not raw_dir or raw_dir == ".": + continue + directory = os.path.expanduser(raw_dir) + for name in _path_search_names(command): + candidate = os.path.join(directory, name) + if os.path.isfile(candidate) and (_kb._IS_WINDOWS or os.access(candidate, os.X_OK)): + return candidate + return None + + +def _hermes_path_argv(path: str) -> list[str]: + """argv for a resolved Hermes executable path. Windows batch shims + (``.cmd``/``.bat``) are unsafe as argv[0] because the argument vector + includes task-derived values; prefer the module form.""" + if _kb._IS_WINDOWS and _is_windows_batch_shim(path): + return _module_hermes_argv() + return [_absolute_hermes_path(path)] + + +def _resolve_hermes_argv() -> list[str]: + """Resolve the ``hermes`` invocation as argv for ``Popen``: ``$HERMES_BIN`` + (path-like -> absolute; bare names keep PATH semantics, never a + same-directory file), then ``which("hermes")`` (Windows: safe PATH search, + batch shims fall back to the module form), then ``sys.executable -m + hermes_cli.main`` for shim-less environments (cron, systemd ``User=``, + launchd). Mirrors ``gateway.run._resolve_hermes_bin``; local because + ``hermes_cli`` sits below ``gateway`` in the dependency order. + """ + import shutil + + env_bin = os.environ.get("HERMES_BIN", "").strip() + if env_bin: + if _looks_like_path(env_bin): + return _hermes_path_argv(env_bin) + resolved_env_bin = _safe_which_no_cwd(env_bin) + if resolved_env_bin: + return _hermes_path_argv(resolved_env_bin) + return _module_hermes_argv() + + hermes_bin = _safe_which_no_cwd("hermes") if _kb._IS_WINDOWS else shutil.which("hermes") + if hermes_bin: + return _hermes_path_argv(hermes_bin) + return _module_hermes_argv() + + +def _worker_terminal_timeout_env( + max_runtime_seconds: Optional[int], + current_timeout: Optional[str], +) -> Optional[str]: + """Return a worker-scoped TERMINAL_TIMEOUT override, if needed. + + When ``max_runtime_seconds`` exceeds the terminal tool's default timeout, + raise only the child's default so a long command isn't killed by the + generic terminal default first. + """ + if max_runtime_seconds is None: + return None + try: + runtime = int(max_runtime_seconds) + except (TypeError, ValueError): + return None + if runtime <= 0: + return None + + desired = max(1, runtime - KANBAN_TERMINAL_TIMEOUT_GRACE_SECONDS) + try: + existing = int(str(current_timeout).strip()) if current_timeout else 0 + except (TypeError, ValueError): + existing = 0 + if existing >= desired: + return None + return str(desired) + + +def _resolve_worker_cli_toolsets(hermes_home: Optional[str]) -> Optional[list[str]]: + """Return the assigned profile's effective CLI toolsets for a worker. + + Resolved at dispatch time and passed as an explicit ``--toolsets`` pin so + worker startup cannot fall back to a stale root/active-profile config or a + profile whose top-level ``toolsets`` is only the kanban orchestrator + surface. ``model_tools`` still appends the task-scoped kanban lifecycle + tools when ``HERMES_KANBAN_TASK`` is set. + """ + if not hermes_home: + return None + try: + from agent.secret_scope import ( + build_profile_secret_scope, is_multiplex_active, reset_secret_scope, set_secret_scope) + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + from hermes_cli.config import load_config + from hermes_cli.tools_config import _get_platform_tools + + token = set_hermes_home_override(hermes_home) + # Toolset availability probes read credentials (``get_secret``); under multiplex an + # unscoped read raises and the pin was silently dropped for every worker. + secret_token = ( + set_secret_scope(build_profile_secret_scope(Path(hermes_home))) + if is_multiplex_active() else None) + try: + cfg = load_config() + toolsets = sorted(_get_platform_tools(cfg, "cli")) + finally: + if secret_token is not None: + reset_secret_scope(secret_token) + reset_hermes_home_override(token) + return toolsets or None + except Exception as exc: + _kb._log.debug( + "kanban worker: could not resolve CLI toolsets for HERMES_HOME=%r (%s)", + hermes_home, + exc, + ) + return None + + +_retagged_workspace_roots: set[str] = set() + + +def _retag_legacy_worker_sessions(workspaces_root_path: str) -> None: + """Reclaim pre-tag worker rows in state.db so they leave the session lists. + + Best-effort: the durable gate is ``state_meta`` in + ``retag_kanban_worker_sessions``; the in-process set avoids reopening + state.db on every spawn. A tick must never fail because a session DB was + busy or missing. + """ + if workspaces_root_path in _retagged_workspace_roots: + return + try: + from hermes_state_registry import acquire, release_or_close + + # Inside the gateway the dispatcher shares the process's registry handle; a bare + # SessionDB() here was one more writer connection on the same state.db (#100896). + db = acquire() + try: + db.retag_kanban_worker_sessions(workspaces_root_path) + finally: + release_or_close(db) + _retagged_workspace_roots.add(workspaces_root_path) + except Exception as exc: + _kb._log.debug("kanban worker: legacy session retag skipped (%s)", exc) + + +def _worker_argv(task: Task, profile_arg: str, hermes_home: Optional[str]) -> list[str]: + """Build the ``hermes -p --cli ... chat -q ...`` worker command.""" + cmd = [ + *_resolve_hermes_argv(), + "-p", profile_arg, + # A worker must NEVER boot the interactive TUI: its no-TTY bail-out + # exits 0 without doing the task → "protocol violation" every attempt. + "--cli", + # Workers run under a profile-scoped HERMES_HOME and so see that + # profile's shell-hook allowlist; pass --accept-hooks explicitly so + # configured hooks still register. + "--accept-hooks", + ] + # One `--skills X` pair per name: easier to read in `ps` and avoids quoting + # ambiguity if a skill name contains unusual chars. + for sk in task.skills or (): + if sk: + cmd.extend(["--skills", sk]) + if task.model_override: + cmd.extend(["-m", task.model_override]) + # Pin the provider too so the worker resolves the model against the + # intended backend (model X with provider Y is the classic board-stall). + if task.provider_override: + cmd.extend(["--provider", task.provider_override]) + # Independent of the model override — a task can run the profile's own + # model at a different depth. + if task.reasoning_effort: + cmd.extend(["--reasoning", task.reasoning_effort]) + worker_toolsets = _resolve_worker_cli_toolsets(hermes_home) + if worker_toolsets: + cmd.extend(["--toolsets", ",".join(worker_toolsets)]) + cmd.extend(["chat", "-q", f"work kanban task {task.id}"]) + if task.goal_mode: + # The kanban goal-loop hook only runs in cli.py's fully-quiet branch. + # Without -Q the worker gets one turn, prints text, exits rc=0, and the + # dispatcher records a protocol violation. + cmd.append("-Q") + return cmd + + +def _open_worker_log(task: Task, board: Optional[str]): + """Append-mode per-task log (a re-run on unblock appends, never overwrites), + rotated first. Anchored at the board root (not the shared kanban root) so + `hermes kanban log` reads its own file and boards sharing task ids don't + collide.""" + log_dir = _kb.worker_logs_dir(board=board) + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / f"{task.id}.log" + rotate_bytes, backup_count = worker_log_rotation_config() + _rotate_worker_log(log_path, rotate_bytes, backup_count) + return open(log_path, "ab") + + +def _restart_safe_worker_argv(task: Task, command: list[str]) -> list[str]: + """Wrap a managed-gateway worker in the shared restart-safe scope. + + Kanban workers are long-lived agentic runs, so they never take cron's + degraded mode: ``require_restart_safe_scope=True`` makes the helper raise. + """ + from tools.process_registry import restart_safe_gateway_child_argv + + if task.current_run_id is None: + # Outside managed systemd this is harmless, but a managed dispatch must + # never mint an untraceable worker. Check topology through the shared + # helper first, using a placeholder suffix that cannot be launched. + dispatch = restart_safe_gateway_child_argv( + command, + unit_suffix=f"kanban-{task.id}-run-missing", + require_restart_safe_scope=True, + ) + if dispatch.mode != "in_process": + raise RuntimeError( + "cannot create restart-safe systemd scope for Kanban worker: " + "the claimed task has no current run id" + ) + return command + + return restart_safe_gateway_child_argv( + command, + unit_suffix=f"kanban-{task.id}-run-{task.current_run_id}", + require_restart_safe_scope=True, + ).argv + + +def _default_spawn(task: Task, workspace: str, *, board: Optional[str] = None) -> Optional[int]: + """Fire-and-forget ``hermes -p chat -q ...`` subprocess. + + Returns the child's PID so the dispatcher can detect crashes before the + claim TTL expires; completion is still observed via the worker's own + ``complete`` / ``block`` transitions. ``board`` pins the child's + ``HERMES_KANBAN_DB`` / ``HERMES_KANBAN_BOARD`` / workspaces_root to the + board the task was claimed from, so workers cannot see other boards. + """ + if not task.assignee: + raise ValueError(f"task {task.id} has no assignee") + + from hermes_cli.profiles import normalize_profile_name, resolve_profile_env + + profile_arg = normalize_profile_name(task.assignee) + + from agent.secret_scope import ( + build_profile_secret_scope, is_multiplex_active, reset_secret_scope, set_secret_scope) + from tools.environments.local import build_subprocess_env, strip_launch_profile_env + + try: + profile_home = resolve_profile_env(profile_arg) + except FileNotFoundError: + # No profile dir (isolated test fixtures) — the CLI resolves it from + # HERMES_PROFILE (set below) instead. + profile_home = None + + multiplex_active = is_multiplex_active() + # build_subprocess_env's secret scrub resolves terminal.env_passthrough vars + # through get_secret(), which raises UnscopedSecretError with no profile scope + # installed while multiplexing is on — mirrors _resolve_worker_cli_toolsets's + # own scope-then-read ordering a few functions up in this module. + secret_token = ( + set_secret_scope(build_profile_secret_scope(Path(profile_home))) + if multiplex_active and profile_home else None) + try: + env = build_subprocess_env( + scrub_secrets=multiplex_active, + inherit_profile_home=True, + ) + finally: + if secret_token is not None: + reset_secret_scope(secret_token) + # The dispatcher is detached from every conversation; its worker must never + # inherit routing mirrored by a previous gateway turn. + from gateway.session_context import _VAR_MAP + for key in _VAR_MAP: + env.pop(key, None) + + # Inject HERMES_HOME so the worker reads the profile-scoped config.yaml: + # without it the child's get_hermes_home() falls back to the DEFAULT + # profile root because `hermes -p` applies its override before + # hermes_constants is imported. + if profile_home: + env["HERMES_HOME"] = profile_home + # A multiplexer dispatching for another profile must not hand it the launch + # profile's .env settings / TERMINAL_* policy — a standalone dispatcher never would. + strip_launch_profile_env(env, profile_home) + if task.tenant: + env["HERMES_TENANT"] = task.tenant + env["HERMES_KANBAN_TASK"] = task.id + env["HERMES_KANBAN_WORKSPACE"] = workspace + # Tag the session `kanban` so session-browsing surfaces filter it out by + # source instead of rendering one sidebar row per attempt. + env["HERMES_SESSION_SOURCE"] = "kanban" + # TERMINAL_CWD takes precedence over process cwd in file_tools and + # build_context_files_prompt; without it relative writes land in the gateway + # user's home and workers load the gateway's AGENTS.md. file_tools rejects + # relative / sentinel values, so only set a real absolute directory. + # Pin TERMINAL_CWD to the task's workspace so the worker's file tools and context-file loader anchor on + # the workspace, not whatever cwd the dispatching gateway happened to export. The worker subprocess is + # already launched with cwd=workspace, but TERMINAL_CWD takes precedence over the process cwd in both + # file_tools._resolve_base_dir (#41312 — relative write_file paths were landing in the gateway user's + # home) and build_context_files_prompt (#34619 — workers loaded the dispatching gateway's AGENTS.md + # instead of the task's). Setting it to the workspace fixes both: the workspace is where the task's work + # actually happens. + if workspace and os.path.isabs(workspace) and os.path.isdir(workspace): + env["TERMINAL_CWD"] = workspace + if task.branch_name: + env["HERMES_KANBAN_BRANCH"] = task.branch_name + if task.current_run_id is not None: + env["HERMES_KANBAN_RUN_ID"] = str(task.current_run_id) + if task.claim_lock: + env["HERMES_KANBAN_CLAIM_LOCK"] = task.claim_lock + # Goal-loop mode (Ralph-style /goal judge loop in cli.py quiet-mode path). + # Only set when enabled so non-goal tasks keep a clean env. + if task.goal_mode: + env["HERMES_KANBAN_GOAL_MODE"] = "1" + if task.goal_max_turns is not None: + env["HERMES_KANBAN_GOAL_MAX_TURNS"] = str(int(task.goal_max_turns)) + for var in ("TERMINAL_TIMEOUT", "TERMINAL_MAX_FOREGROUND_TIMEOUT"): + override = _worker_terminal_timeout_env(task.max_runtime_seconds, env.get(var)) + if override is not None: + env[var] = override + # Pin the board DB + workspaces root so the worker's kanban paths still + # match after `hermes -p` rewrites HERMES_HOME (symlink / Docker layouts). + env["HERMES_KANBAN_DB"] = str(_kb.kanban_db_path(board=board)) + env["HERMES_KANBAN_WORKSPACES_ROOT"] = str(_kb.workspaces_root(board=board)) + _retag_legacy_worker_sessions(env["HERMES_KANBAN_WORKSPACES_ROOT"]) + # Board slug — defense-in-depth pin if a path is resolved without the + # DB / workspaces env vars. + env["HERMES_KANBAN_BOARD"] = _kb._normalize_board_slug(board) or _kb.get_current_board() + # kanban_comment reads HERMES_PROFILE for its default author; `-p` alone + # doesn't set the env var. + env["HERMES_PROFILE"] = profile_arg + # This is the grant boundary: the dispatcher assigned this new worker's task. + from agent.delegation_context import DELEGATED_CHILD_ENV_MARKER + env.pop(DELEGATED_CHILD_ENV_MARKER, None) + # `--cli` is the highest-precedence TUI override; dropping HERMES_TUI covers + # older hermes builds on PATH that predate the flag's precedence. + env.pop("HERMES_TUI", None) + + cmd = _worker_argv(task, profile_arg, env.get("HERMES_HOME")) + # A worker spawned by a managed systemd gateway must leave the gateway's + # cgroup before startup; otherwise restarting the service kills the worker + # that is performing the handoff. + cmd = _restart_safe_worker_argv(task, cmd) + from tools.process_registry import systemd_user_bus_env + env = systemd_user_bus_env(env) + log_f = _open_worker_log(task, board) + try: + proc = subprocess.Popen( # noqa: S603 -- argv is a fixed list built above + cmd, + cwd=workspace if os.path.isdir(workspace) else None, + stdin=subprocess.DEVNULL, + stdout=log_f, + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + creationflags=subprocess.CREATE_NO_WINDOW if _kb._IS_WINDOWS else 0, + ) + except FileNotFoundError: + log_f.close() + raise RuntimeError( + "`hermes` executable not found on PATH. " + "Install Hermes Agent or activate its venv before running the kanban dispatcher." + ) + # Intentionally NOT closing log_f: the child keeps writing after return; + # the OS-level FD stays open in the child until it exits. + return proc.pid + + +# --------------------------------------------------------------------------- +# Long-lived dispatcher daemon +# --------------------------------------------------------------------------- + +def run_daemon( + *, + interval: float = 60.0, + max_spawn: Optional[int] = None, + failure_limit: int = DEFAULT_FAILURE_LIMIT, + stop_event=None, + on_tick=None, +) -> None: + """Run the dispatcher in a loop until interrupted. + + Calls :func:`dispatch_once` every ``interval`` seconds; exits cleanly on + SIGINT / SIGTERM so it is systemd-friendly. ``stop_event`` and ``on_tick`` + are test hooks. Each tick resolves ``kanban.max_in_progress`` exactly like + the gateway dispatcher and ``hermes kanban dispatch`` — the standalone + daemon must not be the one uncapped entry point. + """ + import threading + + if stop_event is None: + stop_event = threading.Event() + + def _handle(_signum, _frame): + stop_event.set() + + # Install handlers only on the main thread — tests call this inline from + # worker threads and signal() would raise there. + if threading.current_thread() is threading.main_thread(): + for sig_name in ("SIGINT", "SIGTERM"): + sig = getattr(signal, sig_name, None) + if sig is not None: + with contextlib.suppress(ValueError, OSError): + signal.signal(sig, _handle) + + while not stop_event.is_set(): + try: + # Re-resolved every tick (config load is mtime-cached) so operator + # edits apply without a restart. + max_in_progress = resolve_max_in_progress(configured_max_in_progress()) + with contextlib.closing(_kbc.connect()) as conn: + res = dispatch_once( + conn, + max_spawn=max_spawn, + max_in_progress=max_in_progress, + failure_limit=failure_limit, + ) + if on_tick is not None: + with contextlib.suppress(Exception): + on_tick(res) + except Exception: + # Don't let any single tick kill the daemon. + import traceback + traceback.print_exc() + stop_event.wait(timeout=interval) + + +# Late-bound origin namespace (see module docstring); imported LAST so this +# module is fully populated before ``kanban_db`` imports from it. +from hermes_cli import kanban_db as _kb # noqa: E402 +from hermes_cli import kanban_db_connect as _kbc # noqa: E402 +from hermes_cli import kanban_db_workspace as _kbw # noqa: E402 diff --git a/hermes_cli/kanban_pr_acceptance_store.py b/hermes_cli/kanban_pr_acceptance_store.py new file mode 100644 index 0000000000000000000000000000000000000000..cf6a8327f3d14c4449d3812406895b9c5e71de2c --- /dev/null +++ b/hermes_cli/kanban_pr_acceptance_store.py @@ -0,0 +1,45 @@ +"""Persist acceptance with the same ownership snapshot as the terminal write.""" +from __future__ import annotations + +from hermes_cli.kanban_db_connect import write_txn +from hermes_cli.kanban_pr_acceptance import _PR, collect_acceptance + + +def _snapshot(conn, task_id): + row = conn.execute("SELECT current_run_id, status, completion_contract FROM tasks WHERE id=?", (task_id,)).fetchone() + return tuple(row) if row else None + + +def prepare_acceptance(conn, task_id, expected_run_id, metadata): + snapshot = _snapshot(conn, task_id) + if snapshot is None: + return False + run_id, status, contract = snapshot + if not contract or contract == "local-only": + return None + if status not in {"running", "ready", "blocked", "review"} or (expected_run_id is not None and run_id != expected_run_id): + return False + published_pr = metadata.get("published_pr") if isinstance(metadata, dict) else None + match = _PR.fullmatch(published_pr) if isinstance(published_pr, str) else None + # Publication binds once. Retrying cannot replace the task's PR with a green sibling. + if match and contract == match[1]: + with write_txn(conn): + if _snapshot(conn, task_id) != snapshot: + return False + conn.execute("UPDATE tasks SET completion_contract=? WHERE id=?", (published_pr, task_id)) + snapshot = (run_id, status, published_pr) + contract = published_pr + return snapshot, collect_acceptance(contract, published_pr) + + +def record_acceptance(conn, task_id, acceptance): + """Called under complete_task's write_txn, before its terminal UPDATE.""" + from hermes_cli.kanban_db import _append_event + snapshot, receipt = acceptance + if _snapshot(conn, task_id) != snapshot: + return False + _append_event(conn, task_id, "pr_acceptance", receipt, run_id=snapshot[0]) + if not receipt["ok"]: + detail = f"PR acceptance {receipt['classification']}: {receipt.get('detail', '')} {receipt['recovery']}" + conn.execute("UPDATE tasks SET last_failure_error=? WHERE id=?", (detail, task_id)) + return receipt["ok"] diff --git a/hermes_cli/main_install_repair.py b/hermes_cli/main_install_repair.py new file mode 100644 index 0000000000000000000000000000000000000000..303e447ddc6cd849e30d81aa3999d6f70669adcb --- /dev/null +++ b/hermes_cli/main_install_repair.py @@ -0,0 +1,1245 @@ +"""Install/update recovery: interrupted-install markers, lazy-refresh repair, Windows shim quarantine, dependency verification. + +Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...) +are imported lazily inside the functions that use them (avoids an import cycle). +""" + +import contextlib +import logging +import os +import shlex +import shutil +import subprocess +import sys +import threading +import time as _time + +from pathlib import Path +from hermes_cli import _early_recovery as _early_recovery_mod + +# Log-record parity with the origin module. +logger = logging.getLogger("hermes_cli.main") + + +def _pyproject_project(debug_fmt: str | None = None) -> dict | None: + """``[project]`` table of pyproject.toml, or ``None`` when absent/unreadable.""" + from hermes_cli.main import PROJECT_ROOT + pyproject = PROJECT_ROOT / "pyproject.toml" + if not pyproject.is_file(): + return None + try: + import tomllib + with pyproject.open("rb") as handle: + project = tomllib.load(handle).get("project", {}) + except Exception as exc: + if debug_fmt: + logger.debug(debug_fmt, exc) + return None + return project if isinstance(project, dict) else None + + +def _naive_requirement(spec: str) -> tuple[str, str]: + """``(name, head)`` of a ``name OP version ; marker`` spec without ``packaging``.""" + head = spec.split(";", 1)[0].strip() + bare = head + for op in ("==", ">=", "<=", "~=", ">", "<", "!="): + if op in bare: + bare = bare.split(op, 1)[0] + break + return bare.strip().split("[", 1)[0].strip(), head + + +def _parse_requirements(raw_deps: list[str]) -> list[tuple[str, "object | None", str]]: + """``(name, marker, head)`` per dep spec — ``packaging`` when importable, else a naive split.""" + parsed: list[tuple[str, "object | None", str]] = [] + try: + from packaging.requirements import Requirement # type: ignore + for spec in raw_deps: + try: + req = Requirement(spec) + except Exception: + continue + parsed.append((req.name, req.marker, spec.split(";", 1)[0].strip())) + except Exception: + for spec in raw_deps: + name, head = _naive_requirement(spec) + if name: + parsed.append((name, None, head)) + return parsed + + +def _load_installable_optional_extras(group: str = "all") -> list[str]: + """Return optional extras referenced by a dependency group (``all`` or ``termux-all``).""" + optional_deps = (_pyproject_project() or {}).get("optional-dependencies", {}) + if not isinstance(optional_deps, dict): + return [] + referenced: list[str] = [] + for ref in optional_deps.get(group, []): + if "[" in ref and "]" in ref: + name = ref.split("[", 1)[1].split("]", 1)[0] + if name in optional_deps: + referenced.append(name) + return referenced + + +# Install-scoped breadcrumbs live next to the venv (not under $HERMES_HOME) +# because the venv is shared across profiles. +# ``.update-incomplete`` — generic core ``.[all]`` install was interrupted; +# cleared only after a confirmed full dependency reinstall/recovery. +# ``.lazy-refresh-incomplete`` — lazy-backend refresh may have corrupted packages; +# cleared only after import-probe repair confirms healthy (never on indeterminate). +# Narrow lazy probes must NEVER clear the generic core marker. +# See #58004. +def _update_marker_path() -> Path: + from hermes_cli.main import PROJECT_ROOT + return PROJECT_ROOT / ".update-incomplete" + + +def _lazy_refresh_marker_path() -> Path: + from hermes_cli.main import PROJECT_ROOT + return PROJECT_ROOT / ".lazy-refresh-incomplete" + + +def _pytest_owns_live_checkout(root: Path) -> bool: + """True under pytest when ``root`` is this checkout: unsandboxed update/recovery tests must + neither litter the live repo root with breadcrumbs (false-arming the developer's next launch) + nor run a real reinstall against the executing venv (cf. ``managed_scope._under_pytest``).""" + return "PYTEST_CURRENT_TEST" in os.environ and root == Path(__file__).resolve().parent.parent + + +def _clear_marker_file(path: Path, *, label: str) -> None: + """Remove an update-recovery breadcrumb. Never raises.""" + try: + path.unlink() + except FileNotFoundError: + pass + except OSError as exc: + logger.debug("Could not clear %s marker: %s", label, exc) + + +def _clear_update_incomplete_marker() -> None: + """Remove the interrupted core-install breadcrumb. Never raises.""" + _clear_marker_file(_update_marker_path(), label="update-incomplete") + + +def _clear_lazy_refresh_incomplete_marker() -> None: + """Remove the interrupted lazy-refresh breadcrumb. Never raises.""" + _clear_marker_file(_lazy_refresh_marker_path(), label="lazy-refresh-incomplete") + + +def _claim_recovery_lock(lock_path: Path) -> bool: + """Atomically claim the single-flight recovery lock; False when another process holds it. + + A crashed holder's stale lock is broken after an hour (well past any realistic install). + Failing to CREATE the lock (read-only fs, perms) proceeds unlocked — the install itself + will surface the real problem.""" + 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 False + except OSError as exc: + logger.debug("Could not create install-recovery lock: %s", exc) + return True + + +@contextlib.contextmanager +def _stdout_to_stderr(): + """Route Python prints AND the fd 1 that pip/uv inherit to stderr: launches whose stdout is + a protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get install noise.""" + saved_stdout_fd = None + saved_sys_stdout = sys.stdout + try: + saved_stdout_fd = os.dup(1) + os.dup2(2, 1) + except OSError: + saved_stdout_fd = None + sys.stdout = sys.stderr + try: + yield + 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 + + +def _recover_from_interrupted_install() -> None: + """Finish update work left half-done by a prior ``hermes update``. + + ``.update-incomplete`` recovers via full quarantined reinstall; ``.lazy-refresh-incomplete`` + via package-only import probes (cleared only when probes confirm healthy/repaired). Never + raises: on failure it prints the manual command and leaves the marker for the next launch. + Concurrent launches race on the shared venv, so an ``O_EXCL`` lockfile lets one process + recover while the others skip. + """ + from hermes_cli.main import PROJECT_ROOT + if _pytest_owns_live_checkout(PROJECT_ROOT): + return + lazy_marker = _lazy_refresh_marker_path().exists() + if not lazy_marker and not _update_marker_path().exists(): + return + # Managed/Docker installs and git-less PyPI installs never 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() + _clear_lazy_refresh_incomplete_marker() + return + lock_path = PROJECT_ROOT / ".update-incomplete.lock" + if not _claim_recovery_lock(lock_path): + return + try: + with _stdout_to_stderr(): + if lazy_marker: + _recover_lazy_refresh_marker_locked() + if _update_marker_path().exists(): + _recover_core_update_marker_locked() + finally: + try: + lock_path.unlink() + except OSError: + pass + + +def _recover_lazy_refresh_marker_locked() -> None: + """Heal ``.lazy-refresh-incomplete`` via confirmed import-probe repair.""" + print( + "⚠ A previous lazy-backend refresh may have left the venv unhealthy — " + "running import-based package repair...") + install_prefix, install_env = _default_venv_install_target() + status = _repair_venv_via_import_probes(install_prefix, env=install_env) + if status in ("healthy", "repaired"): + _clear_lazy_refresh_incomplete_marker() + print("✓ Lazy-refresh venv recovery confirmed — install is healthy again.") + return + indeterminate = status == "indeterminate" + problem = ( + "Import probes unavailable — cannot confirm venv health." if indeterminate + else "Lazy-refresh package repair incomplete.") + print(f" ⚠ {problem} Leaving `.lazy-refresh-incomplete` for the next launch.") + if indeterminate: + return + print(" Recover manually with:") + all_specs = _lazy_refresh_repair_specs(sorted(set(_LAZY_REFRESH_REPAIR_PACKAGES.values()))) + print( + f" {' '.join(install_prefix)} install --force-reinstall " + + " ".join(shlex.quote(s) for s in all_specs)) + + +def _recover_core_update_marker_locked() -> None: + """Heal ``.update-incomplete`` via full ``.[all]`` reinstall only. + + Narrow lazy-refresh import probes are not proof that a generic interrupted core + install finished — a missing dep outside that probe set would look healthy and + clear the breadcrumb too early. + """ + from hermes_cli.main import PROJECT_ROOT + print( + "⚠ A previous `hermes update` was interrupted mid-install — " + "finishing dependency installation now...") + + # Windows: a ``hermes.exe`` launch has the launcher as an ancestor; the quarantined full + # reinstall can still replace it. Package-only repair is first aid and NEVER clears the marker. + # Full editable reinstall uses quarantine so the live shim can still be replaced. Package-only import + # repair may help as first aid but must NEVER clear this core marker on its own (#58004 review). + self_locked = _windows_running_hermes_launcher_locked() + if self_locked: + install_prefix, install_env = _default_venv_install_target() + print( + " → Running from hermes.exe; applying package-only first aid, " + "then quarantined full reinstall (core marker stays until that " + "succeeds)...") + _repair_venv_via_import_probes(install_prefix, env=install_env) + try: + from hermes_cli import _install_repair as _ir + + # ensure_uv bootstraps uv itself when missing (the early pass's stdlib-only lookup + # cannot), so a venv whose uv vanished mid-update still heals. + from hermes_cli.managed_uv import ensure_uv + ensure_uv() + # Shared stdlib executor: late path and pre-import early pass run exactly the same + # reinstall. Its own stdout→stderr redirect nests harmlessly inside ours. + _ir.run_core_install(PROJECT_ROOT) + _clear_update_incomplete_marker() + print("✓ Dependency installation recovered — your install is healthy again.") + except Exception as exc: + # Leave the marker so the next launch retries; give the exact manual command. + logger.debug("Interrupted-install recovery failed: %s", exc) + print("✗ Could not auto-recover the interrupted install.") + manual = ( + " Hermes is still running from the launcher that needs " + "replacing. Close other Hermes windows, restart from a " + "different terminal, then run:", + f' cd /d "{PROJECT_ROOT}"', + f' "{sys.executable}" -m pip install -e ".[all]"', + ) if self_locked else ( + " Recover manually with:", + f" cd {PROJECT_ROOT}", + f" {sys.executable} -m ensurepip --upgrade", + f" {sys.executable} -m pip install -e '.[all]'", + ) + for line in manual: + print(line) + + +def _norm_exe_path(path) -> str: + """Case-folded resolved path, for comparing executables on Windows.""" + try: + return str(Path(path).resolve()).lower() + except OSError: + return str(path).lower() + + +def _windows_shim_in_process_chain() -> Path | None: + """The venv console shim this process runs from or under, if any. + + ``venv\\Scripts\\hermes.exe`` holds itself open (no ``FILE_SHARE_DELETE``) for the whole + process lifetime, so an editable install run from one can never rewrite it. Two probes, since + either can come up empty: own launch paths (argv[0], ``__main__`` file/spec origin — runpy/ + zipapp puts ``\\__main__.py`` there) and psutil ancestry. Candidates are intersected + with the project venv's own shims so a foreign ``hermes.exe`` never matches. + + See #88838, #89599. + """ + if not _is_windows(): + return None + scripts_dir = _venv_scripts_dir() + if scripts_dir is None: + return None + shims = {_norm_exe_path(shim): shim for shim in _hermes_exe_shims(scripts_dir)} + if not shims: + return None + + def _match(candidate) -> Path | None: + path = Path(candidate) + if path.name.lower() == "__main__.py": + path = path.parent + return shims.get(_norm_exe_path(path)) + + main_mod = sys.modules.get("__main__") + candidates = [*sys.argv[:1], *filter(None, ( + getattr(main_mod, "__file__", None), + getattr(getattr(main_mod, "__spec__", None), "origin", None)))] + for candidate in candidates: + matched = _match(candidate) + if matched is not None: + return matched + + with contextlib.suppress(Exception): + import psutil + me = psutil.Process() + for proc in [me] + list(me.parents()): + try: + matched = _match(proc.exe()) + except Exception: + continue + if matched is not None: + return matched + return None + + +def _windows_running_hermes_launcher_locked() -> bool: + """True when a venv ``hermes*.exe`` shim is this process or an ancestor (best-effort).""" + return _windows_shim_in_process_chain() is not None + + +# Set on the re-exec'd child so it can never spawn another one. +_UPDATE_REEXEC_ENV = "HERMES_UPDATE_REEXEC" + + +def _reexec_dependency_sync_off_windows_shim() -> bool: + """Hand the dependency sync to the venv interpreter, off the console shim. + + Returns True when a child was spawned and the caller must exit at once (releasing the + shim before the child reaches ``pip install -e .``); False to continue in-process. + + Called at the dependency-sync boundary, NOT at the top of the command: by then the code swap + is done and every interactive question has been answered; only the venv rewrite — the one + step that cannot run inside the shim — remains. Earlier would detach every run (even the + ``Already up to date!`` no-op) and take the prompts along. Waiting on the child deadlocks + (we hold the handle it needs) and Windows has no exec, so the shell returns; the child keeps + the console, prints its own result, and ``--gateway`` writes the true exit code to + ``.update_exit_code``. The child re-runs ``hermes update`` so the sync and its tail happen + exactly once; ``_UPDATE_REEXEC_ENV`` stops it spawning again and stops the "already up to + date" early return from swallowing the sync. ``.update-incomplete`` is already written, so + a child that dies mid-install is finished by the next launch's recovery. + + Called at the dependency-sync boundary, NOT at the top of the command — the same placement rule as the + native-module deferral beside it, and for the same reason (#86735): a hand-off that fires before the + fetch detaches every run, including the ``Already up to date!`` no-op that never touches the venv at + all, and it takes the interactive prompts with it. By the time we reach here the code swap is done and + every question — stash, branch switch, config migration — has already been asked and answered in the + user's own console. + ``venv\\Scripts\\hermes.exe`` is a launcher that runs the interpreter with the shim as its script and + holds it open without ``FILE_SHARE_DELETE`` for the whole command, so the quarantine rename is refused + and uv fails to replace it with os error 32 (#88838, #89599). + """ + if os.environ.get(_UPDATE_REEXEC_ENV) == "1": + return False + shim = _windows_shim_in_process_chain() + if shim is None: + return False + from hermes_constants import venv_python_path + python_exe = venv_python_path(shim.parent.parent, windows=True) + cmd = [str(python_exe), "-m", "hermes_cli.main", *sys.argv[1:]] + if python_exe.is_file(): + try: + subprocess.Popen( + cmd, env={**os.environ, _UPDATE_REEXEC_ENV: "1"}, stdin=subprocess.DEVNULL) + print( + f"→ Windows: {shim.name} cannot replace itself while it runs; " + "finishing the dependency install under the venv Python.") + print( + " The code update is already applied. The install continues " + "below and this shell returns right away.") + return True + except OSError as exc: + logger.debug("Dependency-sync hand-off via %s failed: %s", python_exe, exc) + print(f" ⚠ Could not hand the dependency install off {shim.name}.") + print(" Continuing in-process; if it cannot replace the shim, run:") + print(f" {subprocess.list2cmdline(cmd)}") + return False + + +def _default_venv_install_target() -> tuple[list[str], dict[str, str] | None]: + """Return ``(install_cmd_prefix, env)`` for the project venv when possible.""" + from hermes_cli.main import PROJECT_ROOT + try: + from hermes_cli.managed_uv import ensure_uv + uv_bin = ensure_uv() + except Exception: + uv_bin = None + if uv_bin: + from hermes_constants import project_venv_dir + venv_dir = project_venv_dir(PROJECT_ROOT) or PROJECT_ROOT / "venv" + env = {**os.environ, "VIRTUAL_ENV": str(venv_dir)} + if _is_termux_env(env): + env.pop("PYTHONPATH", None) + env.pop("PYTHONHOME", None) + return [uv_bin, "pip"], env + return [sys.executable, "-m", "pip"], None + + +def _run_install_with_heartbeat( + cmd: list[str], *, env: dict[str, str] | None = None, heartbeat_interval_seconds: int = 30 +) -> None: + """Run a dependency install, printing an elapsed-time heartbeat while pip/uv is silent. + + Resolvers/build backends compiling Rust/C extensions can stay quiet for minutes. + """ + from hermes_cli.main import PROJECT_ROOT + done = threading.Event() + start = _time.time() + + def _heartbeat() -> None: + # Wait first, then print, so short installs don't emit noise. + while not done.wait(heartbeat_interval_seconds): + elapsed = int(_time.time() - start) + print( + f" … still installing dependencies ({elapsed}s elapsed)" + " — compiling Rust/C extensions can take several minutes", + flush=True) + + t = threading.Thread(target=_heartbeat, daemon=True) + t.start() + try: + # stderr=STDOUT: uv/pip write progress to stderr. Legacy desktop + # hand-offs (pre scripts/desktop-update/windows.ps1, which drains + # both pipes) only drain the child's stdout, so a full stderr + # pipe (~64KB) blocks the installer forever. Merged into stdout, + # the output rides the pipe old hand-offs DO drain. This module + # is imported when `hermes update` starts, so an update running + # from an old base executes the old copy regardless of the git + # reset — this protects updates initiated from bases that ship + # it. (managed_uv.py gets the same fix AND is imported lazily + # after the reset, so its sync is protected even on old bases.) + subprocess.run(cmd, cwd=PROJECT_ROOT, check=True, env=env, stderr=subprocess.STDOUT) + finally: + done.set() + t.join(timeout=0.2) + + +def _run_repair_step(run, cmd: list[str], *, log_msg: str, fail_msg: str | None, **kwargs) -> bool: + """``run(cmd, **kwargs)``; on ``CalledProcessError`` log + print the failure and return False.""" + try: + run(cmd, **kwargs) + except subprocess.CalledProcessError as e: + logger.warning(log_msg, e) + if fail_msg is not None: + print(fail_msg) + return False + return True + + +def _report_still_missing(missing: list[str], hint: str, *, ok: str) -> None: + if missing: + print(f" ⚠ Still missing after repair: {', '.join(missing)}. {hint}") + else: + print(ok) + + +def _is_windows() -> bool: + return sys.platform == "win32" + + +def _venv_scripts_dir() -> Path | None: + """Return the venv Scripts directory if we're running inside the project venv.""" + from hermes_cli.main import PROJECT_ROOT + from hermes_constants import project_venv_dir, venv_bin_dir + venv_dir = project_venv_dir(PROJECT_ROOT) + if venv_dir is None: + return None + scripts = venv_bin_dir(venv_dir, windows=_is_windows()) + return scripts if scripts.is_dir() else None + + +def _hermes_exe_shims(scripts_dir: Path) -> list[Path]: + """Entry-point shims uv may rewrite during ``pip install -e .`` — Windows .exe launchers + only; POSIX shims are plain scripts replaced atomically.""" + if not _is_windows(): + return [] + names = set(_load_console_script_names()) or {"hermes", "hermes-agent", "hermes-acp"} + # Not a [project.scripts] entry point, but older update/install paths still + # rewrite and quarantine it. + names.add("hermes-gateway") + return [scripts_dir / f"{name}.exe" for name in sorted(names)] + + +_QUARANTINE_BACKOFF_MS = (0, 100, 250, 500, 1000) + + +def _rename_with_backoff(source: Path, target: Path, attempts: int) -> OSError | None: + """Rename with the quarantine backoff ladder; returns the last ``OSError`` or ``None``.""" + for delay_ms in _QUARANTINE_BACKOFF_MS[:attempts]: + if delay_ms: + _time.sleep(delay_ms / 1000.0) + try: + source.rename(target) + return None + except OSError as e: + last_exc = e + return last_exc + + +def _quarantine_running_hermes_exe( + scripts_dir: Path, *, max_attempts: int = 4, failed_out: list[str] | None = None +) -> list[tuple[Path, Path]]: + """Pre-empt the Windows file lock on the running ``hermes.exe``. + + Windows allows RENAMING a running executable but blocks DELETE/REPLACE (uv fails with + ``Access is denied. (os error 5)``), so live shims are renamed to ``.old.`` + first; ``_cleanup_quarantined_exes`` sweeps the ``.old`` files next invocation. Rename can + still fail when another process holds the .exe without ``FILE_SHARE_DELETE`` (AV scanner: + transient; Hermes Desktop backend child: until closed) — retry with backoff, then warn + naming the likely culprit. Returns ``(original, quarantined)`` pairs for rollback; + ``failed_out`` collects shims whose rename failed every attempt so the update dependency + sync can refuse instead of stranding a half-broken venv. + + See #87331. + """ + moved: list[tuple[Path, Path]] = [] + if not _is_windows(): + return moved + stamp = int(_time.time() * 1000) + # First attempt immediate; 100/250/500ms covers the typical AV re-scan window. + attempts = max(1, min(max_attempts, len(_QUARANTINE_BACKOFF_MS))) + for shim in _hermes_exe_shims(scripts_dir): + if not shim.exists(): + continue + target = shim.with_suffix(shim.suffix + f".old.{stamp}") + last_exc = _rename_with_backoff(shim, target, attempts) + if last_exc is None: + moved.append((shim, target)) + continue + + # Every rename failed. MOVEFILE_DELAY_UNTIL_REBOOT is no fallback (needs elevation, frees + # nothing now, moves a later repaired shim aside at boot). Report; let uv try its luck. + print( + f" ⚠ Could not quarantine {shim.name} ({last_exc.__class__.__name__}: " + f"another process is holding it open).") + print( + " Close Hermes Desktop, exit other `hermes` REPLs, stop the " + "gateway, or pause AV scanning, then re-run `hermes update`.") + if failed_out is not None: + failed_out.append(shim.name) + + return moved + + +_PENDING_RENAME_KEY = r"SYSTEM\CurrentControlSet\Control\Session Manager" +_PENDING_RENAME_VALUE = "PendingFileRenameOperations" + + +def _filter_pending_shim_renames(entries: list[str], shims: list[Path]) -> tuple[list[str], int]: + """Drop our ```` -> ``.old.`` pairs from a PendingFileRenameOperations + value (a flat REG_MULTI_SZ of (source, target) pairs shared with other installers). + Returns the entries to keep and how many pairs were dropped.""" + import ntpath + + def _norm(value: str) -> str: + path = str(value).lstrip("!") + if path.startswith("\\??\\"): + path = path[4:] + return ntpath.normcase(ntpath.normpath(path)) + + shim_paths = {_norm(str(shim)) for shim in shims} + kept: list[str] = [] + removed = 0 + for index in range(0, len(entries) - 1, 2): + source, target = entries[index], entries[index + 1] + source_norm = _norm(source) + if source_norm in shim_paths and _norm(target).startswith(f"{source_norm}.old."): + removed += 1 + else: + kept.extend((source, target)) + if len(entries) % 2: + kept.append(entries[-1]) + return kept, removed + + +def _cleanup_pending_shim_renames(scripts_dir: Path) -> int: + """Drop reboot renames older Hermes versions queued for our shims: ``MOVEFILE_DELAY_UNTIL_REBOOT`` + fallbacks outlive the update that queued them and move away whatever sits at the shim path + at next boot — even a shim a later repair just wrote. Needs elevation; a no-op otherwise.""" + if not _is_windows(): + return 0 + try: + import winreg + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, _PENDING_RENAME_KEY, 0, + winreg.KEY_QUERY_VALUE | winreg.KEY_SET_VALUE, + ) as key: + entries, value_type = winreg.QueryValueEx(key, _PENDING_RENAME_VALUE) + if value_type != winreg.REG_MULTI_SZ or not isinstance(entries, list): + return 0 + kept, removed = _filter_pending_shim_renames(entries, _hermes_exe_shims(scripts_dir)) + if not removed: + return 0 + if kept: + winreg.SetValueEx(key, _PENDING_RENAME_VALUE, 0, winreg.REG_MULTI_SZ, kept) + else: + winreg.DeleteValue(key, _PENDING_RENAME_VALUE) + return removed + except (OSError, ValueError): + return 0 + + +def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None: + """Roll back ``_quarantine_running_hermes_exe`` if uv didn't write replacements. Safety- + critical: a failed quarantine only aborts an update; a failed restore leaves no ``hermes`` + on PATH. Delegates to the stdlib-only retrying helper shared with ``_install_repair``. + + The outbound rename already retries a lock, so this one must too rather than swallow the first + ``OSError`` in silence. See #75584. + """ + _early_recovery_mod.restore_quarantined_shims(moved) + + +class ShimQuarantineError(RuntimeError): + """A live ``hermes*.exe`` shim could not be renamed aside. Raised by + :func:`_run_quarantined_install` in ``strict_quarantine`` mode BEFORE the install runs: a + process holds the venv hard enough that the sync would die partway — refuse, don't warn. + + See #87331. + """ + + def __init__(self, failed_shims: list[str]): + self.failed_shims = list(failed_shims) + super().__init__("could not quarantine live shim(s): " + ", ".join(self.failed_shims)) + + +def _run_quarantined_install( + cmd: list[str], *, env: dict[str, str] | None = None, scripts_dir: Path | None = None, + strict_quarantine: bool = False, +) -> None: + """Run an editable install, quarantining the running ``hermes.exe`` first. + + Every editable install rewrites the entry-point shims; on Windows the live ``hermes.exe`` + can be neither deleted nor overwritten, so without quarantine ``hermes`` drops off PATH. + ``strict_quarantine=True`` (the update dependency sync): a shim whose rename failed every + retry proves a hard venv hold — the install WILL hit the same lock on .pyd files — so roll + back and raise :class:`ShimQuarantineError` without installing. Non-strict callers already + mutated the venv, so refusing buys nothing. ``scripts_dir is None`` is a pass-through. + + See #87331. + """ + moved: list[tuple[Path, Path]] = [] + failed: list[str] = [] + if scripts_dir is not None: + moved = _quarantine_running_hermes_exe(scripts_dir, failed_out=failed) + if strict_quarantine and failed: + _restore_quarantined_exes(moved) + raise ShimQuarantineError(failed) + try: + _run_install_with_heartbeat(cmd, env=env) + finally: + # Restore on FAILURE and SUCCESS: an already-satisfied editable install is a uv no-op + # that rewrites no entry points. Skips shims the installer replaced; finally re-raises. + if scripts_dir is not None: + _restore_quarantined_exes(moved) + + +# A quarantine file younger than this may belong to an update running RIGHT NOW in +# another process, whose restore step still needs it — the only copy of that shim. +# Restore shims when the installer didn't write replacements — on FAILURE (install died before the +# entry-points step) and on SUCCESS too: uv audits an already-satisfied editable install as a no-op and +# rewrites no entry points, which would otherwise leave the shims quarantined aside and `hermes` missing +# from PATH after a green install (#75584). _restore_quarantined_exes skips any shim the installer actually +# replaced, so this never clobbers fresh output. Errors are not swallowed — the finally re-raises whatever +# escaped. +_QUARANTINE_GRACE_SECONDS = 15 * 60 + + +def _quarantine_stamp_ms(stale: Path) -> int | None: + """The ``.old.`` stamp in a quarantine filename; ``None`` (not ours — neither rescued + nor deleted) otherwise. Parsed from the NAME, not ``st_mtime``: ``rename`` preserves the + shim's mtime (when uv wrote it), not when it was quarantined.""" + try: + return int(stale.name.rsplit(".old.", 1)[1]) + except (IndexError, ValueError): + return None + + +def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None: + """Sweep — and where necessary RESCUE — ``hermes.exe.old.*`` from updates. + + Called early on every invocation. Two cases an unconditional ``unlink()`` gets wrong: + (1) orphan rescue — ``hermes.exe`` missing while ``hermes.exe.old.*`` exists means the .old + file is the ONLY surviving copy (update died between rename and uv's write); put it back via + the same retry-and-report helper the update-time restore uses. (2) concurrency — a fresh + quarantine file may belong to an update in flight in another process; leave anything inside + the grace window alone. Silent no-op on non-Windows, nothing to do, or locked/permission errors. + + Deleting it converts a one-rename recovery into a full reinstall. See #75584. + """ + if not _is_windows(): + return + scripts_dir = scripts_dir if scripts_dir is not None else _venv_scripts_dir() + if scripts_dir is None: + return + _cleanup_pending_shim_renames(scripts_dir) + now = _time.time() + try: + candidates = [ + (stamp, stale) for stale in scripts_dir.glob("*.exe.old.*") + if (stamp := _quarantine_stamp_ms(stale)) is not None] + except OSError: + return + # Newest first by PARSED stamp: lexicographic order breaks when a stray ``.old.999`` exists. + candidates.sort(key=lambda pair: pair[0], reverse=True) + for stamp, stale in candidates: + try: + original = stale.with_name(stale.name.rsplit(".old.", 1)[0]) + if not original.exists(): + # Orphan rescue: last copy of the shim — retry ladder + recovery message. + _early_recovery_mod.restore_quarantined_shims([(original, stale)]) + continue + if now - stamp / 1000.0 < _QUARANTINE_GRACE_SECONDS: + continue # may be a live quarantine from a concurrent update + stale.unlink() + except OSError: + pass # still locked or in use — try again next run + + +# Import probes for venv corruption after a failed lazy ``uv pip install`` (metadata can +# look fine while ``.py`` files were removed mid-install). Canonical tables live in the +# stdlib-only ``_early_recovery`` module so the early and full recovery layers never drift. +# See #57828. +_LAZY_REFRESH_IMPORT_PROBES: tuple[tuple[str, str], ...] = ( + _early_recovery_mod.LAZY_REFRESH_IMPORT_PROBES) +_LAZY_REFRESH_REPAIR_PACKAGES: dict[str, str] = _early_recovery_mod.LAZY_REFRESH_REPAIR_PACKAGES + + +def _run_package_only_install(cmd: list[str], *, env: dict[str, str] | None = None) -> None: + """Package-only pip/uv install — no shim quarantine: ``--force-reinstall `` never rewrites + ``hermes.exe``, and the quarantine path would rename shims uv then never recreates. + + See #57828. + """ + _run_install_with_heartbeat(cmd, env=env) + + +def _lazy_refresh_repair_specs(packages: list[str]) -> list[str]: + """Map repair package names to their declared pin specs in pyproject.toml.""" + project = _pyproject_project("lazy refresh repair spec lookup failed: %s") + if project is None: + return packages + name_to_spec = { + name.lower(): head + for name, _, head in _parse_requirements(project.get("dependencies", []) or [])} + return [name_to_spec.get(pkg.lower(), pkg) for pkg in packages] + + +def _venv_probe(venv_python: Path, script: str, *args: str, env: dict[str, str] | None): + """Run ``script`` in the target venv's interpreter, capturing UTF-8 stdout.""" + return subprocess.run( + [str(venv_python), "-c", script, *args], + capture_output=True, + text=True, encoding="utf-8", errors="replace", + check=False, + env=env) + + +def _nonblank_lines(text: str) -> list[str]: + return [line.strip() for line in text.splitlines() if line.strip()] + + +def _detect_broken_lazy_refresh_imports( + install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> list[str] | None: + """Probe lazy-refresh packages via real imports: ``[]`` all clean, ``[dist, ...]`` failures, + ``None`` when the probe could not run (no venv Python, subprocess failure, non-zero exit) + — *indeterminate*, not healthy.""" + venv_python = _resolve_install_target_python(install_cmd_prefix, env) + if venv_python is None: + return None + probe_lines = "\n".join( + f" ({mod!r}, {attr!r})," for mod, attr in _LAZY_REFRESH_IMPORT_PROBES) + check_script = ( + "import os\n" + "import sys\n" + "probes = [\n" + f"{probe_lines}\n" + "]\n" + "broken = []\n" + "for mod, attr in probes:\n" + " try:\n" + " imported = __import__(mod)\n" + " if not hasattr(imported, attr):\n" + " broken.append(mod)\n" + " elif mod == 'certifi':\n" + " # The module can import cleanly while cacert.pem is\n" + " # missing/corrupt (brew Python upgrade, interrupted venv\n" + " # rebuild) - every TLS call then fails (#29866).\n" + " bundle = imported.where()\n" + " if not os.path.isfile(bundle) or os.path.getsize(bundle) < 1024:\n" + " broken.append(mod)\n" + " except Exception:\n" + " broken.append(mod)\n" + "print('\\n'.join(broken))\n") + try: + result = _venv_probe(venv_python, check_script, env=env) + except Exception as exc: + logger.debug("lazy refresh import probe failed: %s", exc) + return None + if result.returncode != 0: + logger.debug("lazy refresh import probe exited %s: %s", + result.returncode, (result.stderr or "")[:200]) + return None + packages: list[str] = [] + for mod in _nonblank_lines(result.stdout): + pkg = _LAZY_REFRESH_REPAIR_PACKAGES.get(mod) + if pkg and pkg not in packages: + packages.append(pkg) + return packages + + +def _repair_broken_lazy_refresh_imports( + install_cmd_prefix: list[str], packages: list[str], *, env: dict[str, str] | None = None +) -> bool: + """Force-reinstall ``packages`` and re-probe imports. Never raises.""" + if not packages: + return True + specs = _lazy_refresh_repair_specs(packages) + if not _run_repair_step( + _run_package_only_install, install_cmd_prefix + ["install", "--force-reinstall", *specs], + env=env, log_msg="lazy refresh venv repair failed: %s", fail_msg=None): + return False + # Indeterminate re-probe is not confirmed success. + return _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env) == [] + + +def _repair_venv_via_import_probes( + install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> str: + """Probe imports and force-reinstall any broken lazy-refresh packages. + + Real ``import`` checks (not distribution metadata) catch a venv where METADATA remains + but ``.py`` files were wiped mid-install. Package-only reinstall — never rewrites + ``hermes.exe``. Never raises. Returns ``"healthy"``, ``"repaired"``, ``"failed"`` + (repair did not confirm clean) or ``"indeterminate"`` (probes could not run; NOT healthy). + + See #57828. + """ + broken = _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env) + if broken is None: + print(" ⚠ Import probes unavailable — cannot confirm venv package health.") + return "indeterminate" + if not broken: + return "healthy" + print( + " → Detected corrupted venv packages via import probes: " + f"{', '.join(broken)}; repairing...") + if _repair_broken_lazy_refresh_imports(install_cmd_prefix, broken, env=env): + print(" ✓ Venv repair succeeded") + return "repaired" + manual = " ".join(shlex.quote(s) for s in _lazy_refresh_repair_specs(broken)) + print(" ⚠ Venv repair incomplete. Run manually, then `hermes update`:") + print(f" {' '.join(install_cmd_prefix)} install --force-reinstall {manual}") + return "failed" + + +def _is_uv_command(install_cmd_prefix: list[str]) -> bool: + """True for a uv/uvx binary (bare or path) or ``python -m uv`` / ``python -m uvx``.""" + if not install_cmd_prefix: + return False + first = str(install_cmd_prefix[0]).lower() + if "uv" in Path(first).name: + return True + return ( + len(install_cmd_prefix) >= 3 + and first.endswith(("python", "python.exe")) + and install_cmd_prefix[1] == "-m" + and install_cmd_prefix[2] in ("uv", "uvx")) + + +def _insert_python_pin(args: list[str]) -> list[str]: + """Insert ``--python `` into a uv command line; an explicit caller ``--python`` wins.""" + if "--python" in args: + return args + return [args[0], "--python", str(sys.executable), *args[1:]] + + +def _interpreter_scripts_dir() -> Path | None: + """Scripts/bin dir of ``sys.executable``: on a site-packages install ``PROJECT_ROOT/venv`` + does not exist and the shims uv rewrites live next to the interpreter. Layout via the + canonical ``venv_bin_dir`` (hand-rolling Scripts/bin is lint-tested against). + + See #76105. + """ + from hermes_constants import venv_bin_dir + exe = Path(sys.executable) + # sys.executable lives IN the bin/Scripts dir; parent.parent is the env root. + cand = venv_bin_dir(exe.parent.parent, windows=_is_windows()) + if cand.is_dir(): + return cand + return exe.parent if exe.parent.is_dir() else None + + +def _install_python_dependencies_with_optional_fallback( + install_cmd_prefix: list[str], *, env: dict[str, str] | None = None, group: str = "all" +) -> None: + """Install base deps plus as many optional extras as the environment supports. + + Targets ``.[all]`` by default; Termux callers pass ``group='termux-all'``. On Windows every + attempt quarantines the live ``hermes*.exe`` shims first. When ``env`` carries a + ``VIRTUAL_ENV`` that does not exist (pip / site-packages install), ``uv pip`` fails with + ``Failed to inspect Python interpreter from active virtual environment`` before doing any + work — pin the install at the running interpreter instead. + + Pin the install at the running interpreter instead so the update/recovery path succeeds on those + installs (#71510 fixed the ZIP path, #83335 fixed lazy-deps; this closes the shared helper for the + remaining callers). + """ + scripts_dir = _venv_scripts_dir() if _is_windows() else None + + # Only uv needs the explicit pin; pip resolves the target from sys.executable itself. + pin_python = bool( + env and env.get("VIRTUAL_ENV") and not Path(env["VIRTUAL_ENV"]).is_dir() + and install_cmd_prefix and _is_uv_command(install_cmd_prefix)) + if pin_python: + env = {**env} + env.pop("VIRTUAL_ENV", None) + # Pinned to sys.executable, the shims uv rewrites live in THAT interpreter's Scripts + # dir, not PROJECT_ROOT/venv; quarantining the wrong dir leaves hermes.exe locked. + if scripts_dir is None and _is_windows(): + scripts_dir = _interpreter_scripts_dir() + + def _install(args: list[str]) -> None: + if pin_python: + args = _insert_python_pin(args) + # strict_quarantine: this is the UPDATE dependency sync; ShimQuarantineError propagates + # to the sync boundary, which defers via the update-incomplete marker instead. + # A shim that cannot be renamed aside proves a hard venv hold; running uv anyway is how installs + # strand half-updated (#87331). + _run_quarantined_install( + install_cmd_prefix + args, env=env, scripts_dir=scripts_dir, strict_quarantine=True) + + try: + _install(["install", "-e", f".[{group}]"]) + _verify_console_scripts_installed(install_cmd_prefix, env=env) + return + except subprocess.CalledProcessError: + print( + " ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..." + ) + + _install(["install", "-e", "."]) + failed_extras: list[str] = [] + installed_extras: list[str] = [] + for extra in _load_installable_optional_extras(group=group): + try: + _install(["install", "-e", f".[{extra}]"]) + installed_extras.append(extra) + except subprocess.CalledProcessError: + failed_extras.append(extra) + if installed_extras: + print(f" ✓ Reinstalled optional extras individually: {', '.join(installed_extras)}") + if failed_extras: + print(f" ⚠ Skipped optional extras that still failed: {', '.join(failed_extras)}") + _warn_configured_features_missing_deps(install_cmd_prefix, env=env) + # uv's incremental resolver has left newly added base deps silently missing on a half-stale + # venv, surfacing hours later as a downstream ModuleNotFoundError. Verify here instead. + _verify_core_dependencies_installed(install_cmd_prefix, env=env, group=group) + _verify_console_scripts_installed(install_cmd_prefix, env=env) + + +_CONFIGURED_FEATURES_SCRIPT = ( + "import importlib.util\n" + "try:\n" + " from gateway.config import load_gateway_config\n" + " from gateway.platform_registry import platform_registry\n" + " for platform in load_gateway_config().get_connected_platforms():\n" + " entry = platform_registry.get(platform.value)\n" + " if entry is not None and not entry.check_fn():\n" + " print(entry.label + '\\t' + (entry.install_hint or 'reinstall the %r extra' % platform.value))\n" + "except Exception:\n" + " pass\n" + "try:\n" + " from hermes_cli.config import load_config\n" + " if (load_config().get('mcp_servers') or {}) and importlib.util.find_spec('mcp') is None:\n" + " print('MCP servers\\tinstall the ' + repr('mcp') + ' extra')\n" + "except Exception:\n" + " pass\n" +) + + +def _configured_features_missing_deps( + install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> list[tuple[str, str]]: + """``(feature, hint)`` for every configured platform / MCP whose optional deps are not importable + in the TARGET venv. A running gateway masks the gap until its next restart (#10651), so the + update has to say which configured feature will fail to load. Probed in a fresh interpreter: + the updater's own process may be the outer Python and its import caches predate the install, + so an in-process ``check_fn`` reports stale state.""" + venv_python = _resolve_install_target_python(install_cmd_prefix, env) + if venv_python is None: + return [] + try: + result = _venv_probe(venv_python, _CONFIGURED_FEATURES_SCRIPT, env=env) + except Exception as exc: # the update must finish even when the probe cannot run + logger.debug("configured-feature dependency check skipped: %s", exc) + return [] + missing: list[tuple[str, str]] = [] + for line in _nonblank_lines(result.stdout): + feature, _, hint = line.partition("\t") + missing.append((feature, hint or "reinstall its optional extra")) + return missing + + +def _warn_configured_features_missing_deps(install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None: + missing = _configured_features_missing_deps(install_cmd_prefix, env=env) + if not missing: + return + prefix = " ".join(shlex.quote(part) for part in install_cmd_prefix) + print(" ⚠ Configured features whose dependencies are still missing — the gateway will fail to load them on restart:") + for feature, hint in missing: + print(f" - {feature}: {hint}") + print(f" Retry with: {prefix} install -e '.[all]'") + + +def _load_console_script_names() -> list[str]: + """Return ``[project.scripts]`` entry-point names from pyproject.toml.""" + project = _pyproject_project("console script verification: failed to read pyproject.toml: %s") + scripts = (project or {}).get("scripts", {}) or {} + return [str(name) for name in scripts if name] + + +def _verify_console_scripts_installed( + install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None: + """Ensure every declared console_script shim exists on disk after install. + + On Windows ``uv pip install -e .`` can register ``hermes.exe`` in the wheel RECORD while the + file never lands (live shim locked, launcher write skipped), so ``hermes`` drops off PATH + after a "successful" install. Missing shims get ``--reinstall -e .`` under quarantine. + + The symptom is ``hermes-agent.exe`` and ``hermes-acp.exe`` present but ``hermes.exe`` missing, so + ``hermes`` drops off PATH even though the install reported success (issue #52931). + """ + if not _is_windows(): + return + scripts_dir = _venv_scripts_dir() + names = _load_console_script_names() if scripts_dir is not None else [] + if not names: + return + + def _missing() -> list[str]: + return [name for name in names if not (scripts_dir / f"{name}.exe").is_file()] + + missing = _missing() + if not missing: + return + print( + f" ⚠ Verification: {len(missing)} console script(s) missing on disk: " + f"{', '.join(missing)}") + print(" → Reinstalling entry points with --reinstall...") + if not _run_repair_step( + _run_quarantined_install, install_cmd_prefix + ["install", "--reinstall", "-e", "."], + env=env, scripts_dir=scripts_dir, + log_msg="console script verification: repair install failed: %s", + fail_msg=( + " ⚠ Entry point repair failed; try `hermes update --force` after " + "closing other hermes processes.")): + return + _report_still_missing( + _missing(), "Workaround: python -m hermes_cli.main ", + ok=" ✓ All console entry points restored") + + +def _applicable_dependency_names(raw_deps: list[str]) -> list[str]: + """Declared dep names whose ``;`` markers apply here (else ``ptyprocess ; sys_platform != + 'win32'`` would false-positive on Windows). An unevaluable marker counts as applicable.""" + applicable: list[str] = [] + for name, marker, _ in _parse_requirements(raw_deps): + try: + if marker is None or marker.evaluate(): # type: ignore[union-attr] + applicable.append(name) + except Exception: + applicable.append(name) + return applicable + + +_MISSING_DEPS_SCRIPT = ( + "import importlib.metadata as md, sys\n" + "missing=[]\n" + "for name in sys.argv[1:]:\n" + " try: md.version(name)\n" + " except md.PackageNotFoundError: missing.append(name)\n" + "print('\\n'.join(missing))\n") + + +def _verify_core_dependencies_installed( + install_cmd_prefix: list[str], *, env: dict[str, str] | None = None, group: str = "all" +) -> None: + """Check that every base dep from pyproject.toml is installed in the target venv; if not, retry. + + Reads ``pyproject.toml`` directly (not the venv's stale metadata), drops deps whose ``;`` + markers don't apply here, and probes ``importlib.metadata.version()`` in the venv + interpreter. Missing deps trigger a base-group ``--reinstall``, then a per-package force + install. The final state is a warning, not a hard failure, so one broken-on-PyPI dep can't + block an otherwise-successful update — but the partial install is visible where it happened. + """ + project = _pyproject_project("dep verification: failed to read pyproject.toml: %s") + if project is None: + return + raw_deps = project.get("dependencies", []) or [] + applicable = _applicable_dependency_names(raw_deps) + if not applicable: + return + # Probe inside the venv Python — sys.executable may be the outer Python that drove + # ``hermes update``; the install prefix/env encode which environment we targeted. + venv_python = _resolve_install_target_python(install_cmd_prefix, env) + if venv_python is None: + return + + def _missing_deps() -> list[str]: + try: + result = _venv_probe(venv_python, _MISSING_DEPS_SCRIPT, *applicable, env=env) + except Exception as e: + logger.debug("dep verification: subprocess failed: %s", e) + return [] + return _nonblank_lines(result.stdout) + + missing = _missing_deps() + if not missing: + return + print( + f" ⚠ Verification: {len(missing)} declared dep(s) missing after install: " + f"{', '.join(missing[:8])}{'...' if len(missing) > 8 else ''}") + print(" → Reinstalling base group with --reinstall to repair...") + # Base group only, not ``[{group}]``: the missing dep is a *base* dep and the all-extras + # install costs minutes. Quarantine first: ``--reinstall -e .`` rewrites the shims. + scripts_dir = _venv_scripts_dir() if _is_windows() else None + if not _run_repair_step( + _run_quarantined_install, install_cmd_prefix + ["install", "--reinstall", "-e", "."], + env=env, scripts_dir=scripts_dir, + log_msg="dep verification: repair install failed: %s", + fail_msg=" ⚠ Repair install failed; check `hermes update` output above."): + return + still_missing = _missing_deps() + if not still_missing: + print(" ✓ All declared core dependencies now installed") + return + # Last-ditch: install each remaining missing dep with its pin directly — uv's + # resolver can think the env is satisfied while on-disk metadata disagrees. + name_to_spec = dict(_naive_requirement(spec) for spec in raw_deps) + specs = [name_to_spec.get(n, n) for n in still_missing] + print(f" → Force-installing remaining missing dep(s): {', '.join(specs)}") + if not _run_repair_step( + _run_install_with_heartbeat, install_cmd_prefix + ["install", "--reinstall", *specs], + env=env, + log_msg="dep verification: per-package repair failed: %s", + fail_msg=( + f" ⚠ Could not install: {', '.join(still_missing)}. " + "Run `hermes update --force` after closing other hermes processes.")): + return + _report_still_missing( + _missing_deps(), "Run `hermes update --force` after closing other hermes processes.", + ok=" ✓ All declared core dependencies now installed") + + +def _resolve_install_target_python( + install_cmd_prefix: list[str], env: dict[str, str] | None) -> Path | None: + """Python interpreter the install targeted: ``VIRTUAL_ENV`` from ``env`` for the + ``[uv, pip]`` shape, else ``install_cmd_prefix[0]`` for ``[sys.executable, -m, pip]``.""" + if env and "VIRTUAL_ENV" in env: + from hermes_constants import venv_python_path + candidate = venv_python_path(Path(env["VIRTUAL_ENV"]), windows=_is_windows()) + if candidate.exists(): + return candidate + if install_cmd_prefix: + first = Path(install_cmd_prefix[0]) + if first.exists() and "uv" not in first.name.lower(): + return first + return None + + +def _is_termux_env(env: dict[str, str] | None = None) -> bool: + from hermes_cli.main import _is_termux_startup_environment + return _is_termux_startup_environment(env) + + +def _is_windows_npm_path(npm_path: str) -> bool: + """True if ``npm_path`` points at a Windows npm shim (WSL ``/mnt/c`` interop, ``.cmd``/``.exe``, UNC). + + Callers use this only on a POSIX host — on native Windows ``npm.cmd`` is correct. + """ + low = npm_path.lower() + return low.endswith((".exe", ".cmd", ".bat")) or low.startswith("/mnt/") or "\\" in npm_path + + +def _resolve_node_runtime_npm() -> str | None: + """Resolve an npm executable that belongs to the host's Node runtime. + + On WSL, PATH interop can hand back a Windows npm that fails with EISDIR / symlink errors over + ``\\\\wsl.localhost\\...`` UNC paths. Refuse it on a POSIX host and re-scan PATH minus the + ``/mnt/*`` drive mounts. ``None`` when no suitable npm is reachable. + + On WSL/Linux ``shutil.which("npm")`` may resolve a Windows npm exposed through PATH interop. See #30271. + """ + from hermes_constants import find_node_executable + npm = find_node_executable("npm") + if _is_windows(): + return npm + if not npm: + return None + if not _is_windows_npm_path(npm): + return npm + for directory in os.environ.get("PATH", "").split(os.pathsep): + if not directory or directory.lower().startswith("/mnt/"): + continue + candidate = shutil.which("npm", path=directory) + if candidate and not _is_windows_npm_path(candidate): + return candidate + return None + + +def _resolve_update_branch(args) -> str: + """Normalize ``args.branch`` to a non-empty name (default ``main``; blank/whitespace = default).""" + return (getattr(args, "branch", None) or "main").strip() or "main" diff --git a/hermes_cli/main_provider_setup.py b/hermes_cli/main_provider_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..b44c02a0f5f587646ce3884cb6bc603dab3e34a0 --- /dev/null +++ b/hermes_cli/main_provider_setup.py @@ -0,0 +1,946 @@ +"""Provider setup wizard helpers: provider picker, custom-provider save/remove, auxiliary-model +routing menu, API-key/reasoning prompts, Anthropic OAuth. + +Split out of ``hermes_cli/main.py``. Names that still live in main are imported lazily at call time. +""" + +import contextlib + +from typing import Optional +from hermes_cli.model_setup_flows_common import _ask, _ensure_dict_section, _print_numbered, _radiolist, _say + + +def _is_profile_api_key_provider(provider_id: str) -> bool: + """True when *provider_id* maps to a profile with ``auth_type='api_key'`` — the catch-all + in select_provider_and_model() so plugin providers dispatch to the generic key flow.""" + try: + from providers import get_provider_profile + _p = get_provider_profile(provider_id) + return _p is not None and _p.auth_type == "api_key" + except Exception: + return False + + +_GENERIC_API_KEY_PROVIDERS = frozenset({ + "openai-api", "gemini", "deepseek", "xai", "zai", "kimi-coding-cn", + "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", + "opencode-free", "alibaba", "huggingface", "xiaomi", "arcee", "gmi", + "nvidia", "ollama-cloud", "tencent-tokenhub", "tencent-tokenplan", "lmstudio"}) + + +def _short_url(url: str) -> str: + """``https://host/path/`` -> ``host/path`` for menu labels.""" + return url.replace("https://", "").replace("http://", "").rstrip("/") + + +def _clear_stale_openai_base_url(): + """Remove OPENAI_BASE_URL from ~/.hermes/.env unless the active provider is 'custom' — a + leftover value routes provider:auto auxiliary clients to the old custom endpoint.""" + from hermes_cli.config import get_env_value, save_env_value, load_config + model_cfg = load_config().get("model", {}) + provider = (model_cfg.get("provider") or "").strip().lower() if isinstance(model_cfg, dict) else "" + if provider == "custom" or not provider: + return # custom provider legitimately uses OPENAI_BASE_URL + + stale_url = get_env_value("OPENAI_BASE_URL") + if stale_url: + save_env_value("OPENAI_BASE_URL", "") + shown = f"{stale_url[:40]}..." if len(stale_url) > 40 else stale_url + print(f"Cleared stale OPENAI_BASE_URL from .env (was: {shown})") + + +# (task_key, display_name, short_description) +_AUX_TASKS: list[tuple[str, str, str]] = [ + ("vision", "Vision", "image/screenshot analysis"), + ("compression", "Compression", "context summarization"), + ("approval", "Approval", "smart command approval"), + ("mcp", "MCP", "MCP tool reasoning"), + ("title_generation", "Title generation", "session titles"), + ("review", "Review", "/review reviewer subagent"), + ("memory_query_rewrite", "Memory query rewrite", "memory retrieval queries"), + ("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"), + ("profile_describer", "Profile describer", "auto profile descriptions"), + ("curator", "Curator", "skill-usage review pass")] + +# Special non-auxiliary task surfaced in the same picker: subagent delegation. Routing lives +# under top-level `delegation.*` (NOT `auxiliary.delegation`) because delegate_task spawns full +# child agents via tools/delegate_tool.py::_resolve_delegation_credentials(), which reads that +# section directly. "auto" means "inherit the parent agent" and is stored as empty strings — +# never persist the literal "auto", or it would be resolved as a provider name. +_DELEGATION_TASK_KEY = "delegation" +_DELEGATION_TASK_NAME = "Delegation" +_DELEGATION_TASK_DESC = "subagent model (delegate_task)" + + +def _all_aux_tasks() -> list[tuple[str, str, str]]: + """Built-in aux tasks (in order) followed by plugin-registered ones + (:meth:`hermes_cli.plugins.PluginContext.register_auxiliary_task`).""" + tasks = list(_AUX_TASKS) + # Plugin discovery failure must not break the aux config UI. + with contextlib.suppress(Exception): + from hermes_cli.plugins import get_plugin_auxiliary_tasks + for entry in get_plugin_auxiliary_tasks(): + tasks.append((entry["key"], entry["display_name"], entry["description"])) + return tasks + + +def _format_aux_current(task_cfg: dict) -> str: + """Render the current aux config for display in the task menu.""" + if not isinstance(task_cfg, dict): + return "auto" + base_url = str(task_cfg.get("base_url") or "").strip() + provider = str(task_cfg.get("provider") or "auto").strip() or "auto" + model = str(task_cfg.get("model") or "").strip() + effort = _aux_effort_word(task_cfg) + suffix = (f" · {model}" if model else "") + (f" · {effort}" if effort else "") + if base_url: + return f"custom ({_short_url(base_url)})" + suffix + if provider == "auto": + return "auto" + suffix + if model: + return f"{provider}{suffix}" + return provider + suffix + + +def _aux_effort_word(task_cfg: dict) -> str: + """The stored ``reasoning_effort`` as a display word ("" = provider default; YAML False = "none").""" + raw = task_cfg.get("reasoning_effort") if isinstance(task_cfg, dict) else None + if raw is False: + return "none" + return str(raw or "").strip().lower() + + +def _delegation_cfg_as_task(cfg: dict) -> dict: + """Project the top-level ``delegation`` section into aux-task shape (provider/model/ + base_url/api_key); an empty provider means "inherit parent" and renders as "auto".""" + d = cfg.get("delegation") + if not isinstance(d, dict): + d = {} + out = {k: str(d.get(k) or "").strip() for k in ("provider", "model", "base_url", "api_key")} + out["reasoning_effort"] = d.get("reasoning_effort", "") + return out + + +def _aux_task_cfg(cfg: dict, task: str) -> dict: + """The stored routing dict for *task* (delegation reads its top-level section).""" + if task == _DELEGATION_TASK_KEY: + return _delegation_cfg_as_task(cfg) + aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {} + return aux.get(task, {}) if isinstance(aux.get(task), dict) else {} + + +def _aux_task_display_name(task: str) -> str: + """Display name for a task key, covering the special delegation entry.""" + if task == _DELEGATION_TASK_KEY: + return _DELEGATION_TASK_NAME + return next((name for key, name, _ in _all_aux_tasks() if key == task), task) + + +def _save_aux_choice(task: str, *, provider: str, model: str = "", base_url: str = "", + api_key: str = "", reasoning_effort: Optional[str] = None) -> None: + """Persist an aux task's four routing fields (timeout etc. untouched; main model config never + modified). ``delegation`` writes the top-level section, with "auto" stored as an empty provider. + ``reasoning_effort``: a level word or "" (provider default) to write; None leaves the key alone.""" + from hermes_cli.config import load_config, save_config + cfg = load_config() + if task == _DELEGATION_TASK_KEY: + entry = _ensure_dict_section(cfg, "delegation") + provider = "" if provider == "auto" else provider + else: + entry = _ensure_dict_section(_ensure_dict_section(cfg, "auxiliary"), task) + entry["provider"] = provider + entry["model"] = model or "" + entry["base_url"] = base_url or "" + entry["api_key"] = api_key or "" + if reasoning_effort is not None and _aux_task_takes_reasoning(task): + entry["reasoning_effort"] = reasoning_effort + save_config(cfg) + + +def _aux_task_takes_reasoning(task: str) -> bool: + """Whether the task's config block honours ``reasoning_effort``. MoA slots and + ``memory_query_rewrite`` omit the key by design (``config_defaults._aux``); ``review`` + routes through delegation which reads ``delegation.reasoning_effort``, not its own block.""" + if task == _DELEGATION_TASK_KEY: + return True + from hermes_cli.config_defaults import DEFAULT_CONFIG + block = (DEFAULT_CONFIG.get("auxiliary") or {}).get(task) + if isinstance(block, dict): + return "reasoning_effort" in block + return True # plugin-registered task: the runtime folds the key in via _get_task_extra_body + + +def _prompt_aux_reasoning_effort(task: str, current: str) -> Optional[str]: + """Effort step for an aux task: a level, "none", "" (provider default / inherit parent), or None to + keep current. The empty-value row is "Inherit parent" for delegation (a child inherits the parent's + effort; wording from #105431 by @fangliquanflq) and "Provider default" for aux tasks.""" + from hermes_constants import VALID_REASONING_EFFORTS + label = "Inherit parent" if task == _DELEGATION_TASK_KEY else "Provider default" + return _prompt_reasoning_effort_selection( + list(VALID_REASONING_EFFORTS), current_effort=current, default_label=label) + + +def _reset_aux_to_auto() -> int: + """Reset every known aux task (built-in + plugin) back to auto/empty. Returns number reset.""" + from hermes_cli.config import load_config, save_config + def _clear(entry: dict, auto: str) -> bool: + # Only the routing fields; timeout/download_timeout (aux) and max_concurrent_children + # etc. (delegation) are user-tuned and preserved. *auto* is the reset provider value + # ("auto" for aux tasks, "" for delegation); anything else counts as a change. + changed = False + if entry.get("provider") not in {None, "", auto}: + entry["provider"] = auto + changed = True + for field in ("model", "base_url", "api_key", "reasoning_effort"): + if entry.get(field) or entry.get(field) is False: + entry[field] = "" + changed = True + return changed + + cfg = load_config() + aux = _ensure_dict_section(cfg, "auxiliary") + count = sum(_clear(_ensure_dict_section(aux, task), "auto") for task, _name, _desc in _all_aux_tasks()) + dele = cfg.get("delegation") + if isinstance(dele, dict): + count += _clear(dele, "") + save_config(cfg) + return count + + +def _aux_config_menu() -> None: + """Top-level auxiliary-model picker; loops until the user picks "Back".""" + from hermes_cli.config import load_config + while True: + cfg = load_config() + _say("", " Auxiliary models — side-task routing", "", + " Side tasks (vision, compression, web extraction, etc.) default", + ' to your main chat model. "auto" means "use my main model" —', + " Hermes only falls back to a lightweight backend (OpenRouter,", + " Nous Portal) if the main model is unavailable. Override a", + " task below if you want it pinned to a specific provider/model.", "") + + menu_tasks = _all_aux_tasks() + [(_DELEGATION_TASK_KEY, _DELEGATION_TASK_NAME, _DELEGATION_TASK_DESC)] + name_col = max(len(name) for _, name, _ in menu_tasks) + 2 + desc_col = max(len(desc) for _, _, desc in menu_tasks) + 4 + entries = [ + (task_key, f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}" + f"{_format_aux_current(_aux_task_cfg(cfg, task_key))}") + for task_key, name, desc in menu_tasks] + entries.append(("__reset__", "Reset all to auto")) + entries.append(("__back__", "Back")) + + idx = _prompt_provider_choice([label for _, label in entries], default=0) + if idx is None: + return + key = entries[idx][0] + if key == "__back__": + return + if key == "__reset__": + n = _reset_aux_to_auto() + _say(f"Reset {n} auxiliary task(s) to auto." if n else "All auxiliary tasks were already set to auto.", + "") + continue + _aux_select_for_task(key) + + +def _aux_select_for_task(task: str) -> None: + """Pick a provider + model for one aux task and persist it. Rows come from + ``build_aux_picker_rows()`` (shared substrate): only already-configured providers appear.""" + from hermes_cli.config import load_config + from hermes_cli.inventory import build_aux_picker_rows, format_aux_picker_entries + task_cfg = _aux_task_cfg(load_config(), task) + current_provider = str(task_cfg.get("provider") or "auto").strip() or "auto" + current_model = str(task_cfg.get("model") or "").strip() + current_base_url = str(task_cfg.get("base_url") or "").strip() + display_name = _aux_task_display_name(task) + + try: + providers = build_aux_picker_rows(current_provider=current_provider, current_model=current_model, + current_base_url=current_base_url) + except Exception as exc: + print(f"Could not detect authenticated providers: {exc}") + providers = [] + + # (slug, label, models); "auto" always first + auto_marker = " ← current" if current_provider == "auto" and not current_base_url else "" + auto_label = "auto (inherit main agent)" if task == _DELEGATION_TASK_KEY else "auto (recommended)" + entries: list[tuple[str, str, list[str]]] = [("__auto__", f"{auto_label}{auto_marker}", [])] + entries.extend(format_aux_picker_entries(providers, current_provider=current_provider, + current_base_url=current_base_url)) + custom_marker = " ← current" if current_base_url else "" + entries.append(("__custom__", f"Custom endpoint (direct URL){custom_marker}", [])) + entries.append(("__back__", "Back", [])) + + _say("", f" Configure {display_name} — current: {_format_aux_current(task_cfg)}", "") + idx = _prompt_provider_choice([label for _, label, _ in entries], default=0) + if idx is None: + return + slug, _label, models = entries[idx] + if slug == "__back__": + return + if slug == "__auto__": + _save_aux_choice(task, provider="auto", model="", base_url="", api_key="", reasoning_effort="") + print(f"{display_name}: reset to auto.") + elif slug == "__custom__": + _aux_flow_custom_endpoint(task, task_cfg) + else: + _aux_flow_provider_model(task, slug, models, current_model, current_effort=_aux_effort_word(task_cfg)) + + +def _aux_flow_provider_model(task: str, provider_slug: str, curated_models: list, + current_model: str = "", current_effort: str = "") -> None: + """Prompt for a model under an already-authenticated provider (then its reasoning effort), + save to aux.""" + from hermes_cli.auth import _prompt_model_selection + from hermes_cli.models_pricing import get_pricing_for_provider + display_name = _aux_task_display_name(task) + try: + pricing = get_pricing_for_provider(provider_slug) or {} + except Exception: + pricing = {} + + model_list = list(curated_models) + # _prompt_model_selection supports "Enter custom model name" and cancel; with no curated + # list (rare) fall back to a raw input prompt. + if not model_list: + _say(f"No curated model list for {provider_slug}.", "Enter a model slug manually (blank = use provider default):") + selected = _ask("Model: ", cancel_msg="") + if selected is None: + return + else: + selected = _prompt_model_selection(model_list, current_model=current_model, pricing=pricing, + confirm_provider=provider_slug) + if selected is None: + print("No change.") + return + + effort = _prompt_aux_reasoning_effort(task, current_effort) if _aux_task_takes_reasoning(task) else None + _save_aux_choice(task, provider=provider_slug, model=selected or "", base_url="", api_key="", + reasoning_effort=effort) + effort_note = f" · reasoning {effort}" if effort else "" + if selected: + print(f"{display_name}: {provider_slug} · {selected}{effort_note}") + else: + print(f"{display_name}: {provider_slug} (provider default model){effort_note}") + + +def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None: + """Prompt for a direct OpenAI-compatible base_url + optional api_key/model.""" + display_name = _aux_task_display_name(task) + current_base_url = str(task_cfg.get("base_url") or "").strip() + current_model = str(task_cfg.get("model") or "").strip() + + _say("", f" Custom endpoint for {display_name}", + " Provide an OpenAI-compatible base URL (e.g. http://localhost:11434/v1)", "") + url = _ask(f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: ", cancel_msg="") + if url is None: + return + url = url or current_base_url + if not url: + print("No URL provided. No change.") + return + model = _ask(f"Model slug (optional) [{current_model}]: " if current_model else "Model slug (optional): ", + cancel_msg="") + if model is None: + return + model = model or current_model + api_key = _ask("API key (optional, blank = use OPENAI_API_KEY): ", secret=True, cancel_msg="") + if api_key is None: + return + effort = (_prompt_aux_reasoning_effort(task, _aux_effort_word(task_cfg)) + if _aux_task_takes_reasoning(task) else None) + + _save_aux_choice(task, provider="custom", model=model, base_url=url, api_key=api_key, reasoning_effort=effort) + print(f"{display_name}: custom ({_short_url(url)})" + (f" · {model}" if model else "") + + (f" · reasoning {effort}" if effort else "")) + + +_CANCELLED = object() + + +def _ask_index(prompt: str, count: int, *, echo_cancel: bool): + """Numbered-menu input loop: 0-based index in ``range(count)``, ``None`` on blank, + ``_CANCELLED`` on Ctrl-C/EOF (*echo_cancel* prints a blank line first).""" + while True: + try: + val = input(prompt).strip() + if not val: + return None + idx = int(val) - 1 + if 0 <= idx < count: + return idx + print(f"Please enter 1-{count}") + except ValueError: + print("Please enter a number") + except (KeyboardInterrupt, EOFError): + if echo_cancel: + print() + return _CANCELLED + + +def _prompt_provider_choice(choices, *, default=0, title="Select provider:"): + """Provider menu with curses arrow keys; numbered-list fallback when curses is unavailable + (piped stdin, non-TTY). Returns the selected index, or None if the user cancels.""" + with contextlib.suppress(Exception): + from hermes_cli.setup import _curses_prompt_choice + idx = _curses_prompt_choice(title, choices, default) + if idx >= 0: + print() + return idx + + _print_numbered(title, choices, default) + print() + idx = _ask_index(f"Choice [1-{len(choices)}] ({default + 1}): ", len(choices), echo_cancel=True) + if idx is None: + return default + return None if idx is _CANCELLED else idx + + +_DEFAULT_QWEN_PORTAL_MODELS = [ + "qwen3-coder-plus", "qwen3-coder"] + +# (mode value, label, description, accepted answers); "" = auto-detect +_CUSTOM_API_MODES = ( + ("", "Auto-detect", "Use Hermes URL heuristics; best for standard OpenAI-compatible endpoints.", + ("1", "auto", "detect", "auto-detect")), + ("chat_completions", "Chat Completions", "Use /chat/completions for standard OpenAI-compatible servers.", + ("2", "chat", "chat_completions", "completions")), + ("codex_responses", "Responses / Codex", "Use /responses for Codex-compatible tool-calling backends.", + ("3", "responses", "codex", "codex_responses")), + ("anthropic_messages", "Anthropic Messages", "Use /v1/messages for Anthropic-compatible endpoints.", + ("4", "anthropic", "anthropic_messages", "messages"))) +_CUSTOM_API_MODE_ANSWERS = {answer: value for value, _, _, answers in _CUSTOM_API_MODES for answer in answers} + + +def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]: + """Prompt for a custom provider API mode: an explicit mode string, or None for auto-detect.""" + from hermes_cli.runtime_provider import _detect_api_mode_for_url + detected_mode = _detect_api_mode_for_url(base_url) + default_mode = str(current_api_mode or "").strip().lower() or detected_mode or "" + + _say("", "Select API compatibility mode:") + for idx, (value, label, description, _answers) in enumerate(_CUSTOM_API_MODES, 1): + markers = [m for m, hit in (("detected", value == detected_mode), ("current", value == default_mode)) if hit] + suffix = f" [{' / '.join(markers)}]" if markers else "" + _say(f" {idx}. {label}{suffix}", f" {description}") + + try: + raw = input("Choice [1-4, Enter to keep current/detected]: ").strip().lower() + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + raise + + if not raw: + return default_mode or None + if raw in _CUSTOM_API_MODE_ANSWERS: + return _CUSTOM_API_MODE_ANSWERS[raw] or None + print(f"Invalid API mode choice: {raw}. Falling back to auto-detect.") + return None + + +def _auto_provider_name(base_url: str) -> str: + """Display name from a custom endpoint URL, e.g. "Local (localhost:11434)" or + "RunPod (xyz.runpod.io)" — the default offered during custom endpoint setup.""" + import re + name = re.sub(r"/v1/?$", "", _short_url(base_url)).split("/")[0] + if "localhost" in name or "127.0.0.1" in name: + return f"Local ({name})" + if "runpod" in name.lower(): + return f"RunPod ({name})" + return name.capitalize() + + +def _custom_provider_api_key_config_value(provider_info, resolved_api_key=""): + """Return the value that should be persisted for a custom provider key.""" + api_key_ref = str(provider_info.get("api_key_ref", "") or "").strip() + if api_key_ref: + return api_key_ref + key_env = str(provider_info.get("key_env", "") or "").strip() + if key_env and not str(provider_info.get("api_key", "") or "").strip(): + return f"${{{key_env}}}" + return str(resolved_api_key or "").strip() + + +def _custom_provider_base_url_config_value(provider_info, resolved_base_url=""): + """Return the value that should be persisted for a custom provider URL.""" + return str(provider_info.get("base_url_ref", "") or "").strip() or str(resolved_base_url or "").strip() + + +def _save_custom_provider(base_url, api_key="", model="", context_length=None, name=None, api_mode=None, + key_env=""): + """Save a custom endpoint to ``custom_providers`` in config.yaml, deduplicated by base_url (an + existing entry gets model / context_length / api_mode updated). *key_env* set means the caller + already wrote the key to ``.env``; the entry references it instead of inlining the secret. + + See #69449. + """ + from hermes_cli.config import load_config, save_config + cfg = load_config() + providers = cfg.get("custom_providers") or [] + if not isinstance(providers, list): + providers = [] + for entry in providers: + if not (isinstance(entry, dict) and entry.get("base_url", "").rstrip("/") == base_url.rstrip("/")): + continue + changed = False + if model and entry.get("model") != model: + entry["model"] = model + changed = True + if model and context_length: + _ensure_dict_section(entry, "models")[model] = {"context_length": context_length} + changed = True + if api_mode: + if entry.get("api_mode") != api_mode: + entry["api_mode"] = api_mode + changed = True + elif "api_mode" in entry: + entry.pop("api_mode", None) + changed = True + if key_env and (entry.get("key_env") != key_env or entry.get("api_key")): + entry["key_env"] = key_env + entry.pop("api_key", None) + changed = True + if changed: + cfg["custom_providers"] = providers + save_config(cfg) + return # already saved, updated if needed + + name = name or _auto_provider_name(base_url) + entry = {"name": name, "base_url": base_url} + if key_env: + entry["key_env"] = key_env + elif api_key: + entry["api_key"] = api_key + if model: + entry["model"] = model + if api_mode: + entry["api_mode"] = api_mode + if model and context_length: + entry["models"] = {model: {"context_length": context_length}} + + providers.append(entry) + cfg["custom_providers"] = providers + save_config(cfg) + print(f' 💾 Saved to custom providers as "{name}" (edit in config.yaml)') + + +def _remove_custom_provider(config): + """Let the user remove a saved custom provider from config.yaml.""" + from hermes_cli.config import load_config, save_config + cfg = load_config() + providers = cfg.get("custom_providers") or [] + if not isinstance(providers, list) or not providers: + print("No custom providers configured.") + return + + print("Remove a custom provider:\n") + choices = [ + f"{entry.get('name', 'unnamed')} ({_short_url(entry.get('base_url', ''))})" if isinstance(entry, dict) else str(entry) + for entry in providers] + choices.append("Cancel") + + idx = _radiolist("Select provider to remove:", list(choices)) + if idx is not None: + print() + if idx < 0: + idx = None + else: + for i, c in enumerate(choices, 1): + print(f" {i}. {c}") + print() + try: + val = input(f"Choice [1-{len(choices)}]: ").strip() + idx = int(val) - 1 if val else None + except (ValueError, KeyboardInterrupt, EOFError): + idx = None + + if idx is None or idx >= len(providers): + print("No change.") + return + + removed = providers.pop(idx) + cfg["custom_providers"] = providers + save_config(cfg) + removed_name = removed.get("name", "unnamed") if isinstance(removed, dict) else str(removed) + print(f'✅ Removed "{removed_name}" from custom providers.') + + +def _prompt_reasoning_effort_selection(efforts, current_effort="", *, default_label=""): + """Prompt for a reasoning effort. Returns a level, 'none', "" (only with *default_label*: the + "use the provider default" row), or None to keep current.""" + deduped = list(dict.fromkeys(str(effort).strip().lower() for effort in efforts if str(effort).strip())) + canonical_order = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra") + ordered = [effort for effort in canonical_order if effort in deduped] + ordered.extend(effort for effort in deduped if effort not in canonical_order) + if not ordered: + return None + + def _label(effort): + return f"{effort} ← currently in use" if effort == current_effort else effort + + disable_label = "Disable reasoning" + skip_label = "Skip (keep current)" + # (return value, label) for the rows after the ladder; "" = provider default (aux tasks only). + tail: list[tuple[Optional[str], str]] = [("none", disable_label)] + if default_label: + tail.append(("", default_label + (" ← currently in use" if current_effort == "" else ""))) + tail.append((None, skip_label)) + tail_values = [v for v, _ in tail] + if current_effort == "none": + default_idx = len(ordered) + tail_values.index("none") + elif current_effort in ordered: + default_idx = ordered.index(current_effort) + elif default_label and current_effort == "": + default_idx = len(ordered) + tail_values.index("") + elif "medium" in ordered: + default_idx = ordered.index("medium") + else: + default_idx = 0 + + n = len(ordered) + rows = [_label(effort) for effort in ordered] + [label for _, label in tail] + idx = _radiolist("Select reasoning effort:", rows, default_idx) + if idx is not None: + if idx < 0: + return None + print() + else: + print("Select reasoning effort:") + for i, row in enumerate(rows, 1): + print(f" {i}. {row}") + print() + idx = _ask_index(f"Choice [1-{len(rows)}] (default: keep current): ", len(rows), echo_cancel=False) + if idx is None or idx is _CANCELLED: + return None + if idx < n: + return ordered[idx] + return tail_values[idx - n] + + +def _offer_reasoning_after_pick(model_before: str) -> None: + """Post-flow effort step for ``select_provider_and_model``: when a flow saved a different + ``model.default`` (every flow persists through ``_save_model_choice``), offer the effort for + the new model + provider. A flow that made no change (cancel, "No change.") never prompts.""" + from hermes_cli.config import load_config + model_cfg = load_config().get("model") + if not isinstance(model_cfg, dict): + return + model = str(model_cfg.get("default") or "").strip() + if not model or model == model_before: + return # same model re-picked or nothing saved: the "Reasoning effort" row covers that + _prompt_main_reasoning_effort(model, str(model_cfg.get("provider") or "")) + + +def _prompt_main_reasoning_effort(model: str, provider: str) -> None: + """The effort step every ``hermes model`` flow shares: after a main-model pick, offer the + model's supported levels (Copilot publishes a per-model set; everything else gets the full + ladder) and persist ``agent.reasoning_effort``. Skipped when the catalog says the route has + no reasoning control; "Skip" leaves the current value alone.""" + from hermes_cli.config import load_config, save_config + from hermes_cli.setup import _current_reasoning_effort, _set_reasoning_effort + efforts = _main_model_reasoning_efforts(model, provider) + if efforts is None: + return + selected = _prompt_reasoning_effort_selection(efforts, current_effort=_current_reasoning_effort(load_config())) + if selected is None: + return + cfg = load_config() + _set_reasoning_effort(cfg, selected) + save_config(cfg) + print("Reasoning disabled for this model." if selected == "none" else f"Reasoning effort set to: {selected}") + + +def _main_model_reasoning_efforts(model: str, provider: str) -> Optional[list[str]]: + """Levels to offer for *model* on *provider*: None when the route has no reasoning control.""" + from hermes_constants import VALID_REASONING_EFFORTS + slug = (provider or "").strip().lower() + if slug == "copilot": + from hermes_cli.models import github_model_reasoning_efforts + return github_model_reasoning_efforts(model) or None + try: + from agent.models_dev import get_model_capabilities + meta = get_model_capabilities(slug, model) + except Exception: + meta = None + if meta is not None and not meta.supports_reasoning: + return None + return list(VALID_REASONING_EFFORTS) + + +def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "", existing_source: str = "") -> tuple: + """API-key entry for ``hermes setup`` / ``hermes model``: first-time entry, or [K]eep / [R]eplace / + [C]lear when a key exists (a malformed paste is recoverable without editing ``.env``). + Returns ``(resolved_key, abort)``; ``abort=True`` means the caller must ``return`` at once.""" + from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER + from hermes_cli.config import save_env_value + key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" + + def _prompt_new_key(*, allow_lmstudio_default: bool) -> str: + lmstudio_default = provider_id == "lmstudio" and allow_lmstudio_default + if lmstudio_default: + prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): " + else: + prompt = f"{key_env} (or Enter to cancel): " + entered = _ask(prompt, secret=True, cancel_msg="") + if entered is None: + return "" + if not entered and lmstudio_default: + return LMSTUDIO_NOAUTH_PLACEHOLDER + return entered + + if not existing_key: + print(f"No {pconfig.name} API key configured.") + if not key_env: + return "", True + new_key = _prompt_new_key(allow_lmstudio_default=True) + if not new_key: + print("Cancelled.") + return "", True + save_env_value(key_env, new_key) + _say("API key saved.", "") + return new_key, False + + # Already configured — offer K / R / C + from hermes_cli.env_loader import format_secret_source_suffix + source_suffix = format_secret_source_suffix(key_env) if key_env else "" + print(f" {pconfig.name} API key: {existing_key[:8]}... ✓{source_suffix}") + if not key_env: + # Nothing we can rewrite; just acknowledge and move on. + print() + return existing_key, False + pool_backed = existing_source.startswith("credential_pool:") + menu = " [K]eep / [R]eplace (default K): " if pool_backed else " [K]eep / [R]eplace / [C]lear (default K): " + choice = _ask(menu, raw=True, cancel_msg="", on_cancel="k").lower() + + if choice.startswith("r"): + new_key = _prompt_new_key(allow_lmstudio_default=False) + if not new_key: + _say(" No change.", "") + return existing_key, False + save_env_value(key_env, new_key) + _say(" API key updated.", "") + return new_key, False + if choice.startswith("c") and not pool_backed: + save_env_value(key_env, "") + print(f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again.") + return "", True + # Keep (default, or any other input) + print() + return existing_key, False + + +def _infer_stepfun_region(base_url: str) -> str: + """Infer the current StepFun region from the configured endpoint.""" + return "china" if "api.stepfun.com" in (base_url or "").strip().lower() else "international" + + +def _stepfun_base_url_for_region(region: str) -> str: + from hermes_cli.auth import STEPFUN_STEP_PLAN_CN_BASE_URL, STEPFUN_STEP_PLAN_INTL_BASE_URL + return STEPFUN_STEP_PLAN_CN_BASE_URL if region == "china" else STEPFUN_STEP_PLAN_INTL_BASE_URL + + +def _run_anthropic_oauth_flow(save_env_value): + """Run the Claude OAuth setup-token flow. Returns True if credentials were saved.""" + from agent.anthropic_credentials import run_oauth_setup_token, read_claude_code_credentials, is_claude_code_token_valid + from hermes_cli.config import save_anthropic_oauth_token, use_anthropic_claude_code_credentials + + def _activate_claude_code_credentials_if_available() -> bool: + try: + creds = read_claude_code_credentials() + except Exception: + creds = None + if creds and (is_claude_code_token_valid(creds) or bool(creds.get("refreshToken"))): + use_anthropic_claude_code_credentials(save_fn=save_env_value) + print(" ✓ Claude Code credentials linked.") + from hermes_constants import display_hermes_home as _dhh_fn + print(f" Hermes will use Claude's credential store directly instead of copying a setup-token into {_dhh_fn()}/.env.") + return True + return False + + def _paste_token(prompt: str): + """Manual setup-token entry: True saved, False empty, None cancelled.""" + token = _ask(prompt, secret=True, cancel_msg="") + if not token: + return token + save_anthropic_oauth_token(token, save_fn=save_env_value) + print(" ✓ Setup-token saved.") + return True + + try: + _say("", " Running 'claude setup-token' — follow the prompts below.", + " A browser window will open for you to authorize access.", "") + token = run_oauth_setup_token() + if token: + if _activate_claude_code_credentials_if_available(): + return True + save_anthropic_oauth_token(token, save_fn=save_env_value) + print(" ✓ OAuth credentials saved.") + return True + + # Subprocess completed but no token auto-detected — ask user to paste + _say("", " If the setup-token was displayed above, paste it here:", "") + saved = _paste_token(" Paste setup-token (or Enter to cancel): ") + if saved is None: + return False + if saved: + return True + print(" ⚠ Could not detect saved credentials.") + return False + + except FileNotFoundError: + # Claude CLI not installed — guide user through manual setup + _say("", " The 'claude' CLI is required for OAuth login.", "", " To install and authenticate:", "", + " 1. Install Claude Code: npm install -g @anthropic-ai/claude-code", + " 2. Run: claude setup-token", + " 3. Follow the browser prompts to authorize", + " 4. Re-run: hermes model", "", + " Or paste an existing setup-token now (sk-ant-oat-...):", "") + saved = _paste_token(" Setup-token (or Enter to cancel): ") + if saved is None: + return False + if saved: + return True + print(" Cancelled — install Claude Code and try again.") + return False + + +def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: + """Saved custom providers keyed by slug, with raw ``${ENV}`` refs preserved.""" + from hermes_cli.config import get_compatible_custom_providers, read_raw_config + from hermes_cli.providers import custom_provider_slug + + # Raw (un-expanded) templates keyed by identity. ``get_compatible_custom_providers( + # read_raw_config())`` is deliberately bypassed: its normalize step ``urlparse()``s + # ``base_url`` and drops entries whose base_url is itself an env-ref template. + raw_api_key_refs: dict[tuple, str] = {} + raw_base_url_refs: dict[tuple, str] = {} + raw_cfg = read_raw_config() + + raw_entries: list[tuple[str, str, dict]] = [] + raw_list = raw_cfg.get("custom_providers") + if isinstance(raw_list, list): + raw_entries.extend((e.get("name", ""), "", e) for e in raw_list if isinstance(e, dict)) + raw_providers = raw_cfg.get("providers") + if isinstance(raw_providers, dict): + raw_entries.extend((e.get("name", "") or k, k, e) for k, e in raw_providers.items() if isinstance(e, dict)) + for name, provider_key, raw_entry in raw_entries: + template = str(raw_entry.get("api_key", "") or "").strip() + base_template = str(raw_entry.get("base_url", "") or raw_entry.get("url", "") or raw_entry.get("api", "") or "").strip() + name = str(name or "").strip() + provider_key = str(provider_key or "").strip() + model = str(raw_entry.get("model", "") or raw_entry.get("default_model", "") or "").strip() + # Index by every identity the loaded (expanded) config might present: (name), + # (name, model), (provider_key), (provider_key, model); case-insensitive names. + keys = [k.lower() for k in (name, provider_key) if k] + identities = [(k,) for k in keys] + [(k, model) for k in keys] + for refs, tmpl in ((raw_api_key_refs, template), (raw_base_url_refs, base_template)): + if "${" in tmpl: + for identity in identities: + refs.setdefault(identity, tmpl) + + def _lookup_ref(refs: dict[tuple, str], name: str, provider_key: str, model: str) -> str: + name_lc = str(name or "").strip().lower() + pkey_lc = str(provider_key or "").strip().lower() + model = str(model or "").strip() + return next((refs[i] for i in ((pkey_lc, model), (pkey_lc,), (name_lc, model), (name_lc,)) if i[0] and i in refs), "") + + custom_provider_map = {} + for entry in get_compatible_custom_providers(cfg): + if not isinstance(entry, dict): + continue + name = (entry.get("name") or "").strip() + base_url = (entry.get("base_url") or "").strip() + if not name or not base_url: + continue + provider_key = (entry.get("provider_key") or "").strip() + model = entry.get("model", "") + custom_provider_map[custom_provider_slug(name, provider_key)] = { + "name": name, + "base_url": base_url, + "api_key": entry.get("api_key", ""), + "key_env": entry.get("key_env") or entry.get("api_key_env", ""), + "key_cmd": entry.get("key_cmd", ""), + "model": model, + "models": entry.get("models", {}), + "models_discovered": entry.get("models_discovered", False), + "extra_headers": entry.get("extra_headers", {}), + "discover_models": entry.get("discover_models", True), + "api_mode": entry.get("api_mode", ""), + "provider_key": provider_key, + "api_key_ref": _lookup_ref(raw_api_key_refs, name, provider_key, model), + "base_url_ref": _lookup_ref(raw_base_url_refs, name, provider_key, model)} + return custom_provider_map + + +def _build_provider_picker_rows(config: dict, active: str, provider_labels: dict[str, str], + custom_provider_map: dict[str, dict[str, str]]) -> tuple[list[tuple[str, str, list[str]]], int]: + """Rows for the ``hermes model`` provider picker plus the pre-selected index. Canonical providers + fold into display groups (PROVIDER_GROUPS): a group row's ``members`` drive a sub-picker, leaf + rows have ``members == []``; saved custom providers and trailing actions stay flat. Honors + ``model_catalog.excluded_providers`` (slug or alias, case-insensitive) like the gateway/TUI.""" + from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_ALIASES + from hermes_cli.models_catalog_static import group_providers, provider_group_for_slug + canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS} + _cli_excluded = { + str(p).strip().lower() + for p in (config.get("model_catalog", {}) or {}).get("excluded_providers") or [] + if p} + if _cli_excluded: + # A canonical provider is hidden if its slug OR any alias is excluded. + _names_for: dict[str, set[str]] = {_p.slug: {_p.slug.lower()} for _p in CANONICAL_PROVIDERS} + for _alias, _canon in _PROVIDER_ALIASES.items(): + _names_for.setdefault(_canon, {_canon.lower()}).add(_alias.lower()) + _visible_slugs = [p.slug for p in CANONICAL_PROVIDERS if not _names_for.get(p.slug, {p.slug.lower()}) & _cli_excluded] + else: + _visible_slugs = [p.slug for p in CANONICAL_PROVIDERS] + + # The active provider's group when grouped, otherwise the active slug itself. + active_group = provider_group_for_slug(active) if active else "" + + # (key, label, members): members == [] → leaf row (provider slug / action); + # members != [] → group row, key is "group:" + ordered: list[tuple[str, str, list[str]]] = [] + default_idx = 0 + + def _add(key, label, members, is_active): + nonlocal default_idx + if is_active: + label = f"{label} ← currently active" + default_idx = len(ordered) + ordered.append((key, label, members)) + + for row in group_providers(_visible_slugs): + if row["kind"] == "group": + gid = row["group_id"] + group_desc = row.get("description", "") + label = f"{row['label']} ▸ ({group_desc})" if group_desc else f"{row['label']} ▸" + _add(f"group:{gid}", label, row["members"], bool(active_group) and gid == active_group) + else: + slug = row["slug"] + label = canonical_descs.get(slug, provider_labels.get(slug, slug)) + if slug == "nous": + # Same free-tier rule as the gateway/TUI pickers: relabel for a guest, hide + # when nous.guest is off, untouched for a real account. + from hermes_cli.model_switch_providers import _free_tier_nous_row + tier_row = _free_tier_nous_row({"name": label, "models": []}) + if tier_row is None: + continue + label = tier_row["name"] + _add(slug, label, [], bool(active) and slug == active) + + for key, provider_info in custom_provider_map.items(): + saved_model = provider_info.get("model", "") + model_hint = f" — {saved_model}" if saved_model else "" + _add(key, f"{provider_info['name']} ({_short_url(provider_info['base_url'])}){model_hint}", [], + bool(active) and key == active) + + ordered.append(("custom", "Custom endpoint (enter URL manually)", [])) + if isinstance(config.get("custom_providers"), list) and config.get("custom_providers"): + ordered.append(("remove-custom", "Remove a saved custom provider", [])) + ordered.append(("reasoning", "Reasoning effort for the current model...", [])) + ordered.append(("aux-config", "Configure auxiliary models...", [])) + ordered.append(("cancel", "Leave unchanged", [])) + return ordered, default_idx \ No newline at end of file diff --git a/hermes_cli/main_tui_launch.py b/hermes_cli/main_tui_launch.py new file mode 100644 index 0000000000000000000000000000000000000000..b3e274560815529c894f774ccbe210001b9b384b --- /dev/null +++ b/hermes_cli/main_tui_launch.py @@ -0,0 +1,873 @@ +"""TUI (ui-tui) launcher: node/npm bootstrap, workspace/rebuild checks, argv/env assembly. + +Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...) +are imported lazily inside the functions that use them (avoids an import cycle). +""" + +import logging +import contextlib +import json +import os +import shutil +import subprocess +import sys + +from pathlib import Path +from typing import Optional + +# Log-record parity with the origin module. +logger = logging.getLogger("hermes_cli.main") + + +def _read_tui_active_session_file(path: Optional[str]) -> Optional[str]: + if not path: + return None + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + return str(data.get("session_id") or "").strip() or None + except Exception: + return None + + +def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Optional[str] = None) -> None: + """Print a shell-visible epilogue after TUI exits.""" + from hermes_cli.main import _resolve_last_session + target = ( + _read_tui_active_session_file(active_session_file) or session_id or _resolve_last_session(source="tui") + ) + if not target: + return + + db = None + try: + from hermes_state import SessionDB + db = SessionDB(read_only=True) # exit epilogue only reads + session = db.get_session(target) + if not session: + return + + title = db.get_session_title(target) + message_count = int(session.get("message_count") or 0) + if message_count == 0: + return # No real conversation — don't show resume info + tokens = { + k: int(session.get(f"{k}_tokens") or 0) + for k in ("input", "output", "cache_read", "cache_write", "reasoning")} + except Exception: + return + finally: + if db is not None: + db.close() + + print(f"\nResume this session with:\n hermes --tui --resume {target}") + if title: + print(f' hermes --tui -c "{title}"') + print(f"\nSession: {target}") + if title: + print(f"Title: {title}") + print(f"Messages: {message_count}") + print( + "Tokens: " + f"{sum(tokens.values())} (in {tokens['input']}, out {tokens['output']}, " + f"cache {tokens['cache_read'] + tokens['cache_write']}, reasoning {tokens['reasoning']})" + ) + + +_NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert", "peer", "dev", "extraneous", "hasInstallScript", "optional"}) +"""Lockfile fields npm writes non-deterministically at install time. + +``ideallyInert`` marks packages npm skipped (per-platform opt-outs); ``peer`` is +dropped from the hidden ``.package-lock.json`` on dev-deps that are also peers. +``dev`` / ``optional`` / ``extraneous`` / ``hasInstallScript`` are boolean +annotations npm populates differently in the hidden lock (npm >= 10/11), and +may differ even when present in both. None indicate a real declared-vs-installed +skew — the authoritative check is the ``resolved``/``integrity`` pair, which the +intersection comparison in :func:`_tui_need_npm_install` always catches. +""" + + +def _workspace_root(dir: Path) -> Path: + """The npm workspace root for *dir*: its parent when *dir* has ``package.json`` but the + lockfile lives one level up (hoisted node_modules), else *dir* (standalone / prebuilt). + Shared by the install check, TUI launcher and web build so their cwd can't diverge.""" + if ( + (dir / "package.json").is_file() + and not (dir / "package-lock.json").is_file() + and (dir.parent / "package-lock.json").is_file()): + return dir.parent + return dir + + +def _child_workspace_dirs(dir: Path): + """Sorted ``dir/packages/*`` subdirs that carry a ``package.json``.""" + packages_dir = dir / "packages" + if not packages_dir.is_dir(): + return + for child in sorted(packages_dir.iterdir()): + if child.is_dir() and (child / "package.json").is_file(): + yield child + + +def _termux_workspace_install_context( + dir: Path, *, include_child_workspaces: bool = False) -> tuple[Path, tuple[str, ...]]: + """Return Termux-only ``(cwd, npm_args)`` for installing deps for *dir* only.""" + ws_root = _workspace_root(dir) + if ws_root == dir: + return dir, () + + try: + workspace = dir.relative_to(ws_root).as_posix() + except ValueError: + return ws_root, () + + workspace_args: list[str] = ["--workspace", workspace] + if include_child_workspaces: + for child in _child_workspace_dirs(dir): + workspace_args.extend(["--workspace", child.relative_to(ws_root).as_posix()]) + workspace_args.append("--include-workspace-root=false") + return ws_root, tuple(workspace_args) + + +def _npm_lock_workspace_closure(packages: dict, starts) -> Optional[set]: + """Package-map keys reachable from the selected workspaces (*starts*: set or str) via npm resolution. + + ``devDependencies`` are followed for each start (npm installs every selected + workspace's dev toolchain) but not for transitive deps. None when no start is + in *packages* so callers fall back to the full comparison — which would report + every OTHER workspace's deps (``apps/desktop``, ``web``) as missing and + reinstall on every launch. Names resolve by walking up ``node_modules`` + ancestors; ``link: true`` entries are followed to their real package. + + The launch install is scoped with ``npm install --workspace ui-tui`` (see ``_make_tui_argv``), so only + the ui-tui workspace's dependency closure is written to the hidden ``.package-lock.json``. On Termux it + additionally selects ui-tui's child ``packages/*`` workspaces, so their devDependencies join the closure + too. See #66978. + """ + start_set = {starts} if isinstance(starts, str) else {s for s in starts if s} + present = [s for s in start_set if s in packages] + if not present: + return None + + def resolve(from_key: str, dep: str) -> Optional[str]: + base = from_key + while True: + candidate = f"{base}/node_modules/{dep}" if base else f"node_modules/{dep}" + if candidate in packages: + return candidate + if not base: + return None + base = base.rsplit("/", 1)[0] if "/" in base else "" + + seen: set = set() + stack = list(present) + while stack: + key = stack.pop() + if key in seen: + continue + seen.add(key) + entry = packages.get(key) + if not isinstance(entry, dict): + continue + resolved = entry.get("resolved") + if entry.get("link") and isinstance(resolved, str) and resolved in packages: + stack.append(resolved) + fields = ["dependencies", "optionalDependencies", "peerDependencies"] + if key in start_set: + fields.append("devDependencies") + for field in fields: + deps = entry.get(field) + if not isinstance(deps, dict): + continue + for dep in deps: + target = resolve(key, dep) + if target is not None: + stack.append(target) + return seen + + +def _tui_selected_workspace_keys(tui_dir: Path, ws_root: Path) -> set: + """Lock-map keys the launch install scopes to: ui-tui, plus its child ``packages/*`` on Termux + (each a dev-included closure root). Empty when ui-tui isn't under *ws_root*.""" + from hermes_cli.main import _is_termux_startup_environment + try: + keys = {tui_dir.relative_to(ws_root).as_posix()} + except ValueError: + return set() + if _is_termux_startup_environment(): + for child in _child_workspace_dirs(tui_dir): + try: + keys.add(child.relative_to(ws_root).as_posix()) + except ValueError: + continue + return keys + + +def _tui_need_npm_install(root: Path) -> bool: + """True when @hermes/ink is missing or node_modules is behind package-lock.json. + + Prebuilt bundle (``dist/entry.js``, no lockfile): nothing to install. The root + lock is compared to npm's hidden ``node_modules/.package-lock.json`` by CONTENT + (git bumps mtimes without changing deps): missing from hidden → reinstall + unless ``optional``/``peer``/``link`` or outside ``node_modules/``; present in + both → compare the intersection of non-null fields minus + ``_NPM_LOCK_RUNTIME_KEYS`` (``resolved``/``integrity`` are always in both). + Hidden-only entries are ignored; unparseable lockfiles fall back to mtime. + """ + entry = root / "dist" / "entry.js" + ws_root = _workspace_root(root) + lock = ws_root / "package-lock.json" + if entry.is_file() and not lock.is_file(): + return False + + if not (ws_root / "node_modules" / "@hermes" / "ink" / "package.json").is_file(): + return True + if not lock.is_file(): + return False + marker = ws_root / "node_modules" / ".package-lock.json" + if not marker.is_file(): + return True + + try: + wanted = json.loads(lock.read_text(encoding="utf-8")).get("packages") or {} + installed = json.loads(marker.read_text(encoding="utf-8")).get("packages") or {} + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return lock.stat().st_mtime > marker.stat().st_mtime + + def entries_differ(pkg: dict, installed_pkg: dict) -> bool: + a = {k: v for k, v in pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS} + b = {k: v for k, v in installed_pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS} + return any(a[k] is not None and b[k] is not None and a[k] != b[k] for k in a.keys() & b.keys()) + + # Shared workspace checkout: the launch install is scoped to ui-tui (+ child + # packages on Termux), so limit the comparison to that closure. Standalone / + # own-lockfile layouts do a full install and keep the full comparison. + # Limit the comparison to the same selected-workspace closure so unrelated workspace deps (apps/desktop, + # web, …) don't force a reinstall every launch (#66978). + closure: Optional[set] = None + if ws_root != root: + selected = _tui_selected_workspace_keys(root, ws_root) + if selected: + closure = _npm_lock_workspace_closure(wanted, selected) + + for name, pkg in wanted.items(): + if not name or (closure is not None and name not in closure) or not isinstance(pkg, dict): + continue + if name not in installed: + # Workspace link entries are never materialized by a partial + # `npm install --workspace ui-tui`; don't force a reinstall for them. + # Workspace link entries (`"link": true`, paths outside node_modules/ like `apps/desktop`, + # `node_modules/web`) are never materialized by a partial `npm install --workspace ui-tui` — + # they're deliberately skipped (see #38772) and would otherwise force a reinstall on every + # launch. + if pkg.get("optional") or pkg.get("peer") or pkg.get("link"): + continue + if not name.startswith("node_modules/"): + continue + return True + if isinstance(installed[name], dict) and entries_differ(pkg, installed[name]): + return True + + return False + + +_TUI_BUILD_INPUT_DIRS = ("src", "packages/hermes-ink/src") + +_TUI_BUILD_INPUT_FILES = ( + "package.json", + "package-lock.json", + "tsconfig.json", + "tsconfig.build.json", + "babel.compiler.config.cjs", + "scripts/build.mjs", + "packages/hermes-ink/package.json", + "packages/hermes-ink/index.js", + "packages/hermes-ink/text-input.js", +) + +_TUI_BUILD_INPUT_SUFFIXES = frozenset({".cjs", ".js", ".jsx", ".json", ".mjs", ".ts", ".tsx"}) + + +def _iter_tui_build_inputs(root: Path): + """Yield source/config files that affect ``ui-tui/dist/entry.js``.""" + for rel in _TUI_BUILD_INPUT_FILES: + path = root / rel + if path.is_file(): + yield path + + for rel in _TUI_BUILD_INPUT_DIRS: + base = root / rel + if not base.is_dir(): + continue + for path in base.rglob("*"): + if path.is_file() and path.suffix in _TUI_BUILD_INPUT_SUFFIXES: + yield path + + +def _tui_need_rebuild(root: Path) -> bool: + """True when ``dist/entry.js`` is missing or older than TUI inputs (Termux cold-start saver); + ``HERMES_TUI_FORCE_BUILD=1`` forces a rebuild.""" + force = (os.environ.get("HERMES_TUI_FORCE_BUILD") or "").strip().lower() + if force in {"1", "true", "yes", "on"}: + return True + + try: + output_mtime = (root / "dist" / "entry.js").stat().st_mtime + except OSError: + return True + + for path in _iter_tui_build_inputs(root): + try: + if path.stat().st_mtime > output_mtime: + return True + except OSError: + return True + return False + + +def _ensure_tui_node() -> None: + """Ensure `node` + `npm` are on PATH: else run node-bootstrap.sh `ensure_node` and prepend + the resolved node dir to PATH. ``HERMES_SKIP_NODE_BOOTSTRAP=1`` disables auto-install.""" + from hermes_cli.main import PROJECT_ROOT + if shutil.which("node") and shutil.which("npm"): + return + if os.environ.get("HERMES_SKIP_NODE_BOOTSTRAP"): + return + + helper = PROJECT_ROOT / "scripts" / "lib" / "node-bootstrap.sh" + if not helper.is_file(): + return + + from hermes_constants import get_hermes_home + hermes_home = str(get_hermes_home()) + try: + # Helper logs to stderr; stdout carries `command -v node` — subshell PATH + # edits don't leak back into Python, so the capture is the bridge. + result = subprocess.run( + ["bash", "-c", f'source "{helper}" >&2 && ensure_node >&2 && command -v node'], + env={**os.environ, "HERMES_HOME": hermes_home}, + capture_output=True, text=True, encoding="utf-8", errors="replace", check=False) + except (OSError, subprocess.SubprocessError): + return + + parts = os.environ.get("PATH", "").split(os.pathsep) + resolved = (result.stdout or "").strip() + extras = [Path(resolved).resolve().parent] if resolved else [] + extras += [Path(hermes_home) / "node" / "bin", Path.home() / ".local" / "bin"] + for extra in extras: + s = str(extra) + if extra.is_dir() and s not in parts: + parts.insert(0, s) + os.environ["PATH"] = os.pathsep.join(parts) + + +def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None: + """Find a pre-built TUI entry.js bundled in the wheel.""" + if hermes_cli_dir is None: + hermes_cli_dir = Path(__file__).parent + bundled = hermes_cli_dir / "tui_dist" / "entry.js" + return bundled if bundled.is_file() else None + + +def _restore_tui_workspace(tui_dir: Path) -> bool: + """Best-effort ``git restore`` of a missing ``ui-tui/`` (Windows AV/NTFS filters can delete + tracked files after ``hermes update``); True when the directory exists afterwards. + + On Windows an antivirus / NTFS filter driver can leave tracked ``ui-tui/`` files deleted in the working + tree after ``hermes update`` (HEAD stays intact; the files just vanish — see issue #49145). Those files + are tracked, so ``git restore`` puts them back deterministically. Best-effort: returns False (rather + than raising) when git is unavailable, this isn't a checkout, or the restore leaves the directory still + missing — the caller then prints the manual-recovery message. + """ + git = shutil.which("git") + if not git or not (tui_dir.parent / ".git").exists(): + return False + try: + subprocess.run( + [git, "restore", "--", tui_dir.name], cwd=str(tui_dir.parent), capture_output=True, + text=True, encoding="utf-8", errors="replace", check=False) + except OSError: + return False + return tui_dir.is_dir() + + +def _ensure_tui_workspace(tui_dir: Path) -> None: + """Ensure ``ui-tui/`` exists before it is used as a subprocess cwd (else ``NotADirectoryError`` + / ``WinError 267`` with no usable message): git-restore first, then abort with recovery steps. + + Without this, a missing workspace falls through to ``subprocess.run(..., cwd=)``, which + crashes with ``NotADirectoryError`` (``WinError 267`` on Windows) instead of a usable message (#49145). + We first try to self-heal via ``git restore``; only if that can't recover the directory do we abort with + concrete manual-recovery steps. + """ + if tui_dir.is_dir(): + return + + if _restore_tui_workspace(tui_dir): + if not os.environ.get("HERMES_QUIET"): + print(f"Restored missing TUI workspace: {tui_dir}") + return + + print( + "Error: the TUI workspace is missing from this Hermes checkout.\n" + f"Expected directory: {tui_dir}\n" + "This usually means `hermes update` left tracked ui-tui files deleted.\n" + "Recovery:\n" + " 1. From the Hermes checkout, run `git restore -- ui-tui`\n" + " 2. Run `npm install --silent --no-fund --no-audit --progress=false`\n" + " 3. Retry `hermes --tui`\n" + "If the checkout is still inconsistent, run `hermes update --force`.", + file=sys.stderr) + sys.exit(1) + + +def _npm_lifecycle_env(env: dict[str, str] | None = None) -> dict[str, str]: + """Build a clean environment for the pinned UI toolchain lifecycle.""" + run_env = {**os.environ, **(env or {}), "CI": "1"} + # esbuild treats this as an executable override. If a shell points it at a + # different release, the pinned package's postinstall rejects that binary. + run_env.pop("ESBUILD_BINARY_PATH", None) + # The repo-root ``.npmrc`` is git-tracked, so the updater's autostash parks + # any mirror/proxy line added there and every update reinstalls without it + # (restricted networks then prune optional native deps like get-windows and + # the rebuild fails). ``$HERMES_HOME`` lives outside the git tree and the + # update hand-off already carries ``HERMES_HOME`` down to every npm child. + # An explicit ``NPM_CONFIG_USERCONFIG`` wins (#106373). + from hermes_constants import get_hermes_home + npmrc = get_hermes_home() / "npmrc" + if npmrc.is_file(): + run_env.setdefault("NPM_CONFIG_USERCONFIG", os.fspath(npmrc)) + return run_env + + +def _tui_node_bin(bin: str) -> str: + """Resolve ``node``/``npm`` for the TUI launch, or exit with a hint. ``HERMES_NODE`` wins for node; + ``find_node_executable()`` sees the managed ``$HERMES_HOME/node`` tree a bare which() misses.""" + if bin == "node": + env_node = os.environ.get("HERMES_NODE") + if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK): + return env_node + from hermes_constants import find_node_executable + path = find_node_executable(bin) + if not path and bin == "node": + with contextlib.suppress(Exception): + from hermes_cli.dep_ensure import ensure_dependency + if ensure_dependency("node"): + path = find_node_executable("node") + if not path: + print(f"{bin} not found — install Node.js to use the TUI.") + sys.exit(1) + return path + + +def _exit_on_npm_failure(result: subprocess.CompletedProcess, message: str, *, sep: str) -> None: + """Print *message* plus the last 30 lines of npm output and exit 1 on a non-zero rc.""" + if result.returncode == 0: + return + combined = f"{result.stdout or ''}{sep}{result.stderr or ''}".strip() + preview = "\n".join(combined.splitlines()[-30:]) + print(message) + if preview: + print(preview) + sys.exit(1) + + +def _run_tui_npm_build(npm: str, cwd: Path, failure_message: str) -> None: + """``npm run build`` in *cwd*; exit with *failure_message* + output tail on failure.""" + result = subprocess.run( + [npm, "run", "build"], cwd=str(cwd), capture_output=True, text=True, encoding="utf-8", + errors="replace", env=_npm_lifecycle_env()) + _exit_on_npm_failure(result, failure_message, sep="") + + +def _install_tui_dependencies(tui_dir: Path, *, termux_startup: bool) -> None: + """``npm install`` for the TUI workspace, with one EBADENGINE repair retry. Exits on failure. + + ``--workspace ui-tui`` avoids resolving apps/desktop (Electron + node-pty) and + is omitted when ui-tui/ has its own lockfile. ``--include=dev``: the build + toolchain is in devDependencies and an inherited ``NODE_ENV=production`` / + ``omit=dev`` would silently skip it. + """ + npm = _tui_node_bin("npm") + if not os.environ.get("HERMES_QUIET"): + print("Installing TUI dependencies…") + npm_cwd = _workspace_root(tui_dir) + # --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty). See #38772. When ui-tui/ has + # its own package-lock.json (e.g. curl install), _workspace_root() returns tui_dir itself. Passing + # --workspace in that case fails because npm cannot find a workspace named "ui-tui" inside ui-tui/. See + # #42973. + npm_workspace_args: tuple[str, ...] = () if npm_cwd == tui_dir else ("--workspace", "ui-tui") + if termux_startup: + npm_cwd, npm_workspace_args = _termux_workspace_install_context(tui_dir, include_child_workspaces=True) + npm_install_cmd = [ + npm, "install", *npm_workspace_args, + "--include=dev", "--silent", "--no-fund", "--no-audit", "--progress=false", + ] + + def _run_tui_install() -> subprocess.CompletedProcess: + from hermes_constants import with_hermes_node_path + # Managed tree first on PATH: if the EBADENGINE repair provisioned a + # managed Node, npm's shebang/lifecycle scripts must resolve that node. + return subprocess.run( + npm_install_cmd, cwd=str(npm_cwd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8", errors="replace", + env=_npm_lifecycle_env(with_hermes_node_path())) + + result = _run_tui_install() + if result.returncode != 0: + # An npm outside the root `engines.npm` range fails before doing any work; + # repair once (upgrade a managed npm in place, or provision a managed + # runtime) and retry rather than dumping EBADENGINE at the user. + from hermes_cli.npm_engine import maybe_repair_npm_engine + repaired_npm = maybe_repair_npm_engine(npm, f"{result.stdout or ''}\n{result.stderr or ''}") + if repaired_npm: + npm_install_cmd[0] = repaired_npm + result = _run_tui_install() + _exit_on_npm_failure(result, "npm install failed.", sep="\n") + + +def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: + """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild).""" + from hermes_cli.main import _is_termux_startup_environment + _ensure_tui_node() + + # Footgun: --dev against a prebuilt bundle that has no source/node_modules. + ext_dir = os.environ.get("HERMES_TUI_DIR") + if tui_dev and ext_dir: + print( + f"Error: --dev is incompatible with HERMES_TUI_DIR={ext_dir}\n" + f"The prebuilt TUI has no source code to hot-reload.\n" + f"Unset HERMES_TUI_DIR (e.g. `unset HERMES_TUI_DIR`) to use --dev from a checkout.", + file=sys.stderr) + sys.exit(1) + + # 1. Prebuilt bundle (nix / packaged release / Docker image): just run it. + # Must run BEFORE _ensure_tui_workspace(): a prebuilt install ships + # hermes_cli/tui_dist/entry.js but never ui-tui/ (git checkouts only). + # 1. A prebuilt install (Docker image, Nix build, or prior `npm run build`) ships + # hermes_cli/tui_dist/entry.js but never ships ui-tui/ at all (that directory only exists in a git + # checkout) — so requiring the workspace to exist first made every prebuilt dashboard Chat tab + # connection hard-exit before it ever got a chance to try the bundled entry.js it already has. See + # #56665. + if not tui_dev: + if ext_dir: + p = Path(ext_dir) + if (p / "dist" / "entry.js").is_file(): + return [_tui_node_bin("node"), "--expose-gc", str(p / "dist" / "entry.js")], p + + bundled = _find_bundled_tui() + if bundled is not None: + return [_tui_node_bin("node"), "--expose-gc", str(bundled)], bundled.parent + + # About to npm install/build from source, so the workspace must exist. + if not ext_dir: + _ensure_tui_workspace(tui_dir) + + # 2. Normal flow: npm install if needed, esbuild, then node dist/entry.js. + # --dev: npm install if needed, then tsx src/entry.tsx. + termux_startup = _is_termux_startup_environment() + termux_need_rebuild = termux_startup and not tui_dev and _tui_need_rebuild(tui_dir) + skip_install_for_fresh_termux_bundle = termux_startup and not tui_dev and not termux_need_rebuild + did_install = False + if not skip_install_for_fresh_termux_bundle and _tui_need_npm_install(tui_dir): + _install_tui_dependencies(tui_dir, termux_startup=termux_startup) + did_install = True + + if tui_dev: + # --dev runs src/entry.tsx directly, but @hermes/ink resolves through + # packages/hermes-ink/dist/entry-exports.js; a stale dist after a pull + # leaves newer hooks/components missing at runtime. Prebuild it here. + npm = _tui_node_bin("npm") + _run_tui_npm_build(npm, tui_dir / "packages" / "hermes-ink", "TUI dev prebuild failed.") + tsx = tui_dir / "node_modules" / ".bin" / "tsx" + if tsx.exists(): + return [str(tsx), "src/entry.tsx"], tui_dir + return [npm, "start"], tui_dir + + # Desktop/dev launches always rebuild; Termux cold starts use the freshness + # check because esbuild startup is expensive on old mobile CPUs. + if not termux_startup or did_install or termux_need_rebuild: + _run_tui_npm_build(_tui_node_bin("npm"), tui_dir, "TUI build failed.") + + return [_tui_node_bin("node"), "--expose-gc", str(tui_dir / "dist" / "entry.js")], tui_dir + + +def _split_comma_items(items, *, split_non_str: bool = True) -> list[str]: + """Flatten str / list (comma-separated) input into stripped non-empty parts.""" + raw_items = [items] if isinstance(items, str) else items + if not isinstance(raw_items, (list, tuple)): + raw_items = [raw_items] + normalized: list[str] = [] + for item in raw_items: + if split_non_str or isinstance(item, str): + normalized.extend(part.strip() for part in str(item).split(",")) + else: + normalized.append(str(item).strip()) + return [item for item in normalized if item] + + +def _normalize_tui_toolsets(toolsets: object) -> list[str]: + """Normalize argparse/Fire-style toolset input for the TUI subprocess.""" + try: + from hermes_cli.oneshot import _normalize_toolsets + return _normalize_toolsets(toolsets) or [] + except (AttributeError, ImportError): + return _split_comma_items(toolsets, split_non_str=False) if toolsets else [] + + +def _read_cgroup_memory_limit() -> Optional[int]: + """Container memory limit in bytes, or None if unconstrained (v2 ``memory.max``, then v1). + + V8 is NOT cgroup-aware: a flat 8GB heap grows past a smaller container limit + and the OOM-killer SIGKILLs Node with no breadcrumb (bare ``stdin EOF``). + """ + candidates = ( + "/sys/fs/cgroup/memory.max", # cgroup v2 + "/sys/fs/cgroup/memory/memory.limit_in_bytes", # cgroup v1 + ) + for path in candidates: + try: + with open(path, "r", encoding="utf-8") as f: + raw = f.read().strip() + except (OSError, ValueError): + continue + if raw == "max": + return None + if not raw: + continue # don't mistake an empty v2 file for "unlimited" + try: + limit = int(raw) + except ValueError: + continue + if limit <= 0: + continue + if limit >= (1 << 50): # >= ~1 PB is the v1 "unlimited" sentinel + return None + return limit + return None + + +def _resolve_tui_heap_mb(default_mb: int = 8192) -> int: + """V8 ``--max-old-space-size`` (MB) that fits the container: ``default_mb`` when unconstrained, + else 75% of the cgroup limit (headroom for non-heap RSS + the gateway child), floored at + 1536MB when the container is > 2GB (below that V8 GC-thrashes).""" + limit = _read_cgroup_memory_limit() + if not limit: + return default_mb + limit_mb = limit // (1024 * 1024) + sized = int(limit_mb * 0.75) + if sized >= default_mb: + return default_mb + # Below the floor, honor the limit-derived value anyway: a graceful V8 exit + # beats a silent cgroup kill. + return max(1536, sized) if limit_mb > 2048 else sized + + +def _safe_tui_cwd(env: Optional[dict] = None) -> str: + """Return a stable cwd value for the Node TUI child environment.""" + from hermes_cli.main import PROJECT_ROOT + try: + return os.getcwd() + except FileNotFoundError: + candidate = ((env or {}).get("PWD") or os.environ.get("PWD") or "").strip() + if candidate and Path(candidate).is_dir(): + return candidate + return str(PROJECT_ROOT) + + +def _apply_tui_python_env(env: dict) -> None: + """Seed/repair Python-related env vars shared by CLI and dashboard TUI launches.""" + from hermes_cli.main import PROJECT_ROOT + src_root = str(env.get("HERMES_PYTHON_SRC_ROOT") or "").strip() + if not src_root or not Path(src_root).is_dir(): + env["HERMES_PYTHON_SRC_ROOT"] = str(PROJECT_ROOT) + + cwd = str(env.get("HERMES_CWD") or "").strip() + if not cwd or not Path(cwd).is_dir(): + env["HERMES_CWD"] = _safe_tui_cwd(env) + + python = str(env.get("HERMES_PYTHON") or "").strip() + if os.path.dirname(python): + python_path = Path(python) + if not python_path.is_absolute(): + python_path = Path(env["HERMES_CWD"]) / python_path + python_is_executable = python_path.is_file() and os.access(python_path, os.X_OK) + else: + python_is_executable = bool(shutil.which(python, path=env.get("PATH"))) + if not python_is_executable: + env["HERMES_PYTHON"] = sys.executable + + +def _setup_tui_worktree() -> dict: + """Create the ``--worktree`` checkout for a TUI launch (prune + async pack maintenance); exits on failure.""" + wt_info = None + try: + from cli import _git_repo_root, _maintain_pack_health, _prune_stale_worktrees, _setup_worktree + repo = _git_repo_root() + if repo: + _prune_stale_worktrees(repo) + # Repack on pack sprawl so `worktree add` never crawls on a + # multi-agent box; on a thread so it can't block launch. + import threading as _threading + + _threading.Thread( + target=_maintain_pack_health, args=(repo,), name="pack-maintenance", daemon=True).start() + wt_info = _setup_worktree() + except Exception as exc: + print(f"✗ Failed to create TUI worktree: {exc}", file=sys.stderr) + if not wt_info: + sys.exit(1) + return wt_info + + +def _launch_tui( + resume_session_id: Optional[str] = None, tui_dev: bool = False, model: Optional[str] = None, + provider: Optional[str] = None, toolsets: object = None, skills: object = None, + verbose: Optional[bool] = None, quiet: bool = False, query: Optional[str] = None, + image: Optional[str] = None, worktree: bool = False, checkpoints: bool = False, + pass_session_id: bool = False, max_turns: Optional[int] = None, accept_hooks: bool = False): + """Replace current process with the TUI.""" + from hermes_cli.main import PROJECT_ROOT + tui_dir = PROJECT_ROOT / "ui-tui" + + import tempfile + # TUI child is a hermes process: propagate the profile-home contract via + # the single factory; keep secrets (the TUI/agent needs provider creds). + from tools.environments.local import build_subprocess_env + env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=True) + from hermes_cli.shared_session_attach import configure_tui_attachment + try: + configure_tui_attachment(env, resume_session_id) + except (ValueError, RuntimeError) as exc: + print(f"Error: {exc}", file=sys.stderr) + raise SystemExit(1) from None + try: + from hermes_cli.config import apply_terminal_config_to_env + apply_terminal_config_to_env(env=env) + except Exception: + logger.debug("Failed to apply terminal config bridge for TUI launch", exc_info=True) + active_session_fd, active_session_file = tempfile.mkstemp( + prefix="hermes-tui-active-session-", suffix=".json") + os.close(active_session_fd) + env["HERMES_TUI_ACTIVE_SESSION_FILE"] = active_session_file + env.setdefault("NODE_ENV", "development" if tui_dev else "production") + + wt_info = None + if worktree: + wt_info = _setup_tui_worktree() + env["HERMES_CWD"] = wt_info["path"] + env["TERMINAL_CWD"] = wt_info["path"] + + _apply_tui_python_env(env) + + skills_value = "" + if skills: + skills_value = ( + ",".join(_split_comma_items(skills)) if isinstance(skills, (list, tuple)) else str(skills).strip()) + for key, value in ( + ("HERMES_MODEL", model), ("HERMES_INFERENCE_MODEL", model), + ("HERMES_TUI_PROVIDER", provider), ("HERMES_INFERENCE_PROVIDER", provider), + ("HERMES_TUI_TOOLSETS", ",".join(_normalize_tui_toolsets(toolsets))), + ("HERMES_TUI_SKILLS", skills_value), + ("HERMES_TUI_QUERY", query), ("HERMES_TUI_IMAGE", image), + ("HERMES_TUI_CHECKPOINTS", "1" if checkpoints else None), + ("HERMES_TUI_PASS_SESSION_ID", "1" if pass_session_id else None), + ("HERMES_TUI_MAX_TURNS", str(max_turns) if max_turns is not None else None), + ("HERMES_TUI_TOOL_PROGRESS", "verbose" if verbose else "off" if quiet else None), + ("HERMES_ACCEPT_HOOKS", "1" if accept_hooks else None)): + if value: + env[key] = value + # Generous V8 heap (8GB target; default cap can fatal-OOM on long sessions), + # sized below the cgroup limit by _resolve_tui_heap_mb() so V8 exits + # gracefully instead of being reaped silently. Token-level merge respects a + # user-supplied --max-old-space-size. --expose-gc is NOT added here: Node + # rejects it in NODE_OPTIONS; _make_tui_argv() passes it as a direct flag. + _tokens = env.get("NODE_OPTIONS", "").split() + if not any(t.startswith("--max-old-space-size=") for t in _tokens): + _tokens.append(f"--max-old-space-size={_resolve_tui_heap_mb()}") + env["NODE_OPTIONS"] = " ".join(_tokens) + # HERMES_TUI_RESUME is an internal hand-off to the Ink app. We start from a + # full os.environ snapshot, so a stale exported value would make a plain + # `hermes --tui` try to resume a non-existent session; only forward the id + # argparse resolved for this invocation. + env.pop("HERMES_TUI_RESUME", None) + if resume_session_id: + env["HERMES_TUI_RESUME"] = resume_session_id + + argv, cwd = _make_tui_argv(tui_dir, tui_dev) + code: Optional[int] = None + try: + try: + code = subprocess.call(argv, cwd=str(cwd), env=env) + except KeyboardInterrupt: + code = 130 + + if code in {0, 130}: + _print_tui_exit_summary(resume_session_id, active_session_file) + finally: + with contextlib.suppress(OSError): + os.unlink(active_session_file) + if wt_info: + with contextlib.suppress(Exception): + from cli import _cleanup_worktree + _cleanup_worktree(wt_info) + + # Exit code 42 = TUI requested an update. Relaunch as `hermes update`; + # preserve_inherited=False keeps --tui and other flags out of the subcommand. + if code == 42: + from hermes_cli.relaunch import relaunch + print("\n☤ Launching update...\n") + relaunch(["update"], preserve_inherited=False) + + sys.exit(code) + + +def _pin_kanban_board_env() -> None: + """Pin the active kanban board into ``HERMES_KANBAN_BOARD`` so in-process tools and shelled-out + ``hermes kanban`` calls agree even if a concurrent ``boards switch`` flips the file mid-turn. + + Without this, in-process tools (``kanban_*``) and shelled-out CLI calls (``hermes kanban …``) resolve + the board on different paths: the env-pin if set, otherwise the global ``/kanban/current`` file. A + concurrent ``hermes kanban boards switch`` from another session can flip the file mid-turn, so the same + chat sees its tool calls hit board A while its shell calls hit board B (#20074). Pinning at chat boot + mirrors what the dispatcher already does for spawned workers. + """ + if os.environ.get("HERMES_KANBAN_BOARD"): + return + with contextlib.suppress(Exception): + from hermes_cli.kanban_db import get_current_board + os.environ["HERMES_KANBAN_BOARD"] = get_current_board() + + +def _sync_bundled_skills_quietly() -> None: + """Seed ``~/.hermes/skills/`` with the bundled library (idempotent, milliseconds when synced). + Failures are swallowed: skills are an enhancement, not a hard dependency.""" + with contextlib.suppress(Exception): + from tools.skills_sync import sync_skills + sync_skills(quiet=True) + + +def _resolve_use_tui(args) -> bool: + """Decide whether to launch the TUI: ``--cli`` → classic; ``--tui`` → TUI; no TTY → classic; + ``HERMES_TUI=1`` → TUI; ``display.interface`` config; default classic. + + The TTY gate is load-bearing: ambient preferences must never hijack a piped + ``hermes chat -q`` (kanban workers, cron) — the Ink no-TTY bail-out exits 0 and + the worker dies with a protocol violation. Explicit ``--tui`` still bails out. + """ + if getattr(args, "cli", False): + return False + if getattr(args, "tui", False): + return True + try: + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return False + except Exception: + return False + if os.environ.get("HERMES_TUI") == "1": + return True + try: + from hermes_cli.config import load_config + iface = (load_config().get("display", {}) or {}).get("interface", "cli") + return isinstance(iface, str) and iface.strip().lower() == "tui" + except Exception: + return False diff --git a/hermes_cli/mcp_picker.py b/hermes_cli/mcp_picker.py new file mode 100644 index 0000000000000000000000000000000000000000..aa80163a5e05620358b7748f0f5197d24e91407c --- /dev/null +++ b/hermes_cli/mcp_picker.py @@ -0,0 +1,242 @@ +"""MCP picker — interactive `hermes mcp picker` (also the default `hermes mcp`).""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import List, Optional + +from hermes_cli.colors import Colors +from hermes_cli.cli_output import prompt_yes_no +from hermes_cli.curses_ui import curses_single_select +from hermes_cli.mcp_catalog import ( + CatalogEntry, CatalogError, catalog_diagnostics, install_entry, is_enabled, is_installed, + list_catalog, installed_servers, remove_server, server_enabled, uninstall_entry, _say, +) +from hermes_cli.config import load_config, save_config + +_STATUS_NOT_INSTALLED = "available" +_STATUS_DISABLED = "installed (disabled)" +_STATUS_ENABLED = "enabled" +_STATUS_CUSTOM_ENABLED = "custom — enabled" +_STATUS_CUSTOM_DISABLED = "custom — disabled" + + +@dataclass +class _Row: + """A picker row. ``entry`` is set for catalog rows; custom MCPs carry only name/description/status.""" + + name: str + description: str + status: str + entry: Optional[CatalogEntry] = None # None for non-catalog (custom) rows + + @property + def is_custom(self) -> bool: + return self.entry is None + + +def _build_rows() -> List[_Row]: + """Return catalog rows + any custom (non-catalog) MCPs found in config.""" + catalog_entries = list_catalog() + catalog_names = {e.name for e in catalog_entries} + servers = installed_servers() + + rows: List[_Row] = [] + for entry in catalog_entries: + cfg = servers.get(entry.name) + if entry.name not in servers: + status = _STATUS_NOT_INSTALLED + elif cfg and server_enabled(cfg): + status = _STATUS_ENABLED + else: + status = _STATUS_DISABLED + rows.append(_Row(entry.name, entry.description, status, entry)) + # Custom (non-catalog) MCPs: the transport URL/command doubles as the description. + for name, cfg in sorted(servers.items()): + if name in catalog_names: + continue + status = _STATUS_CUSTOM_ENABLED if server_enabled(cfg) else _STATUS_CUSTOM_DISABLED + rows.append(_Row(name, str(cfg.get("url") or cfg.get("command") or "(no transport)"), status)) + return rows + + +def _format_row(row: _Row) -> str: + return f"{row.name:<18} {row.status:<24} {row.description}" + + + +def _enable_disable(name: str, *, enable: bool) -> None: + cfg = load_config() + servers = cfg.get("mcp_servers") or {} + server = servers.get(name) + if not server: + _say(f" '{name}' is not installed.", Colors.RED) + return + server["enabled"] = enable + cfg["mcp_servers"] = servers + save_config(cfg) + _say( + f" ✓ '{name}' {'enabled' if enable else 'disabled'}. " + "Start a new Hermes session for changes to take effect." + ) + + +def _configure_tools(name: str) -> None: + """Open the tool selection checklist for an already-installed MCP.""" + from argparse import Namespace + from hermes_cli.mcp_config import cmd_mcp_configure + + cmd_mcp_configure(Namespace(name=name)) + + +def _remove_custom(name: str) -> None: + """Remove a non-catalog MCP entry from config.yaml.""" + if not is_installed(name): + _say(f" '{name}' is not configured.", Colors.RED) + return + if not prompt_yes_no(f"Remove '{name}' from mcp_servers?", default=False): + return + remove_server(name) + _say(f" ✓ Removed '{name}'") + + +def _install(entry: CatalogEntry, verb: str) -> bool: + """Install *entry*, printing (not raising) a CatalogError. True on success.""" + try: + install_entry(entry, enable=True) + except CatalogError as exc: + _say(f" ✗ {verb} failed: {exc}", Colors.RED) + return False + return True + + +def _uninstall(name: str) -> None: + if not prompt_yes_no(f"Uninstall '{name}'?", default=False): + return + if uninstall_entry(name): + _say( + f" ✓ Uninstalled '{name}'. " + "Credentials in .env preserved — delete manually if no longer needed." + ) + else: + _say(f" '{name}' was not installed", Colors.DIM) + + +def _run_submenu(title: str, actions: list) -> None: + """Show a single-select of ``(label, callback)`` pairs and run the picked callback.""" + choice = curses_single_select(title, [label for label, _ in actions]) + if choice is not None: + actions[choice][1]() + + +def _handle_row(row: _Row) -> None: + """Act on the picked row based on its current status.""" + if row.entry and not is_installed(row.name): + _install(row.entry, "install") + return + if row.entry and not is_enabled(row.name): + _enable_disable(row.name, enable=True) + return + if row.is_custom: + enabled = is_enabled(row.name) + _run_submenu(f"Action for '{row.name}' (custom)", [ + ("Configure tools (probe server + re-pick)", lambda: _configure_tools(row.name)), + ("Enable" if not enabled else "Disable", + lambda: _enable_disable(row.name, enable=not is_enabled(row.name))), + ("Remove from config", lambda: _remove_custom(row.name))]) + return + # Catalog row, installed + enabled + print() + _say(f" '{row.name}' is already enabled.", Colors.DIM) + _run_submenu(f"Action for '{row.name}'", [ + ("Configure tools (probe server + re-pick)", lambda: _configure_tools(row.name)), + ("Disable (keep config, stop loading on next session)", + lambda: _enable_disable(row.name, enable=False)), + ("Uninstall (remove config and any cloned files)", lambda: _uninstall(row.name)), + ("Reinstall (re-clone, re-prompt for credentials)", + lambda: _install(row.entry, "reinstall"))]) + + +def _print_rows_text(rows: List[_Row]) -> None: + """Plain-text catalog dump: `hermes mcp catalog` output and the non-curses fallback.""" + print() + if not rows: + _say(" No MCPs in the catalog or configured.", Colors.DIM) + print() + return + + _say(" MCP Catalog + configured servers:", Colors.CYAN + Colors.BOLD) + print() + print(f" {'Name':<18} {'Status':<24} Description") + print(f" {'-' * 18} {'-' * 24} {'-' * 11}") + for row in rows: + print(f" {_format_row(row)}") + print() + _say(" Install: hermes mcp install Picker: hermes mcp", Colors.DIM) + # Manifest-version warnings: the user's Hermes is too old to install everything listed. + future = [d for d in catalog_diagnostics() if d[1] == "future_manifest"] + if future: + print() + for name, _, _msg in future: + _say(f" ⚠ '{name}' requires a newer Hermes — run `hermes update` to install this entry.", Colors.YELLOW) + print() + print() + + +def show_catalog() -> None: + """`hermes mcp catalog` — print the curated list + custom servers, no interaction.""" + _print_rows_text(_build_rows()) + + +def run_picker() -> None: + """`hermes mcp picker` (and default `hermes mcp`) — interactive selector; re-renders after each + action until ESC/q.""" + while True: + rows = _build_rows() + if not rows or not sys.stdin.isatty(): + _print_rows_text(rows) # non-interactive: degrade to the text dump + return + idx = curses_single_select( + "MCP Catalog — ↑↓ navigate ENTER act on entry ESC/q quit", + [_format_row(r) for r in rows], + ) + if idx is None: + return + _handle_row(rows[idx]) + + +def install_by_name(identifier: str) -> int: + """`hermes mcp install ` — non-interactive entry-point.""" + from hermes_cli.mcp_catalog import get_entry + + entry = get_entry(identifier) + if entry is None: + _say( + f" ✗ '{identifier}' is not in the catalog. Run `hermes mcp catalog` to see available entries.", + Colors.RED, + ) + return 1 + return 0 if _install(entry, "install") else 1 + + +# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- +# Names external plugins imported from this module before the Sep 2026 decomposition. +# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does). +# The whole block is removed by reverting the commit that added it. + + +_PLUGIN_COMPAT_LAZY = { + 'color': ('hermes_cli.colors', 'color'), +} + + +def __getattr__(name): # PEP 562 — lazy so no import cycles + target = _PLUGIN_COMPAT_LAZY.get(name) + if target is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + import importlib + from hermes_cli.plugin_compat import warn_once + warn_once(__name__, name, *target) + return getattr(importlib.import_module(target[0]), target[1]) +# ---- END PLUGIN-COMPAT ---- diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..07f4d5266682858e0b037c92994eddaa0fdbed10 --- /dev/null +++ b/hermes_cli/memory_setup.py @@ -0,0 +1,466 @@ +"""hermes memory setup|status — configure memory provider plugins.""" + +from __future__ import annotations + +import os +import re +import sys +import shlex + +from hermes_constants import get_hermes_home +from hermes_cli.secret_prompt import masked_secret_prompt + +_CANCELLED = -1 + +# pip name → import name mapping for packages where they differ +_IMPORT_NAMES = { + "honcho-ai": "honcho", + "mem0ai": "mem0", + "hindsight-client": "hindsight_client", + "hindsight-all": "hindsight"} + + +def _provider_pip_dependencies(provider_name: str, declared: list) -> list: + """Return the pip deps a provider actually needs on THIS install. + + ``plugin.yaml`` declares the baseline bridge packages; some providers add mode-dependent extras + at setup time that the manifest can't express. + + Hindsight's ``local_embedded`` mode installs ``hindsight-all`` (daemon + embedder + client) during + ``hermes memory setup`` — if the update-time refresh only reinstalled the declared ``hindsight-client``, + the embedded daemon would stay broken after a venv rebuild stripped ``hindsight-embed`` (#70636). + """ + deps = list(declared or []) + if provider_name == "hindsight": + try: + import json + cfg_path = get_hermes_home() / "hindsight" / "config.json" + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) if cfg_path.exists() else {} + # "local" is a legacy alias for "local_embedded" + if cfg.get("mode", "") in {"local", "local_embedded"}: + deps.append("hindsight-all") + except Exception: + pass + return deps + + +def _curses_select( + title: str, items: list[tuple[str, str]], default: int = 0, *, cancel_returns: int | None = None +) -> int: + """Interactive single-select with arrow keys.""" + from hermes_cli.curses_ui import curses_radiolist + + if cancel_returns is None: + cancel_returns = default + display_items = [f"{label} - {desc}" if desc else label for label, desc in items] + result = curses_radiolist(title, display_items, selected=default, cancel_returns=cancel_returns) + _clear_interactive_transition() + return result + + +def _print_cancelled_setup() -> None: + print("\n Cancelled. No changes saved.\n") + + +def _clear_interactive_transition() -> None: + """Clear stale curses content before entering a follow-up setup screen.""" + if not sys.stdout.isatty(): + return + sys.stdout.write("\033[2J\033[H") + sys.stdout.flush() + + +def _prompt(label: str, default: str | None = None, secret: bool = False) -> str: + """Prompt for a value with optional default and secret masking.""" + suffix = f" [{default}]" if default else "" + if secret: + val = masked_secret_prompt(f" {label}{suffix}: ") + else: + sys.stdout.write(f" {label}{suffix}: ") + sys.stdout.flush() + val = sys.stdin.readline().strip() + return val or (default or "") + + +def _install_dependencies(provider_name: str, *, force: bool = False) -> None: + """Install pip dependencies declared in ``plugin.yaml``. + + With ``force`` every declared dependency goes to the installer even if it imports (the resolver + no-ops when nothing drifted) — how ``hermes update`` heals a provider after a venv rebuild. + + When ``force`` is true, every declared dependency is handed to the installer even if its import + currently succeeds — the resolver then reinstalls anything missing or version-drifted and no-ops on + satisfied ranges. This is how ``hermes update`` heals the active memory provider after a venv + rebuild/sync removed or downgraded its bridge packages (#53272, #70636). + """ + import subprocess + from plugins.memory import find_provider_dir + + plugin_dir = find_provider_dir(provider_name) + if not plugin_dir: + return + yaml_path = plugin_dir / "plugin.yaml" + if not yaml_path.exists(): + return + try: + import yaml + with open(yaml_path, encoding="utf-8") as f: + meta = yaml.safe_load(f) or {} + except Exception: + return + + pip_deps = _provider_pip_dependencies(provider_name, meta.get("pip_dependencies", [])) + if not pip_deps: + return + + missing = [] + for dep in pip_deps: + if force: + missing.append(dep) + continue + dep_name = re.match(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*", dep) + base = dep_name.group(0) if dep_name else dep + import_name = _IMPORT_NAMES.get(base, base.replace("-", "_").split("[")[0]) + try: + __import__(import_name) + except ImportError: + missing.append(dep) + if not missing: + return + + print(f"\n Installing dependencies: {', '.join(missing)}") + + # install_specs routes to the durable target on sealed hosted images (HERMES_LAZY_INSTALL_TARGET) + # and is venv-scoped on normal installs. + from tools.lazy_deps import install_specs + + manual_cmd = f"uv pip install {' '.join(missing)}" + try: + outcome = install_specs(missing, timeout=120) + if outcome.ok: + print(f" ✓ Installed {', '.join(missing)}") + elif outcome.blocked: + print(f" ⚠ Cannot install {', '.join(missing)}: {outcome.reason}") + else: + print(f" ⚠ Failed to install {', '.join(missing)}") + stderr = (outcome.stderr or "")[:200] + if stderr: + print(f" {stderr}") + print(f" Run manually: {manual_cmd}") + except Exception as e: + print(f" ⚠ Install failed: {e}") + print(f" Run manually: {manual_cmd}") + + # Also show external (non-pip) dependencies that are missing. + for dep in meta.get("external_dependencies", []): + check_cmd = dep.get("check", "") + install_cmd = dep.get("install", "") + if check_cmd: + try: + subprocess.run(shlex.split(check_cmd), check=True, capture_output=True, timeout=5) + except Exception: + if install_cmd: + print(f"\n ⚠ '{dep.get('name', '')}' not found. Install with:") + print(f" {install_cmd}") + + +def _schema_of(provider) -> list: + return provider.get_config_schema() if hasattr(provider, "get_config_schema") else [] + + +def _get_available_providers() -> list: + """Discover memory providers from plugins/memory/ as ``(name, setup_hint, provider)`` tuples.""" + try: + from plugins.memory import discover_memory_providers, load_memory_provider + raw = discover_memory_providers() + except Exception: + raw = [] + + results = [] + for name, desc, available in raw: + try: + provider = load_memory_provider(name) + if not provider: + continue + except Exception: + continue + schema = _schema_of(provider) + has_secrets = any(f.get("secret") for f in schema) + has_non_secrets = any(not f.get("secret") for f in schema) + if has_secrets and has_non_secrets: + setup_hint = "API key / local" + elif has_secrets: + setup_hint = "requires API key" + elif not schema: + setup_hint = "no setup needed" + else: + setup_hint = "local" + results.append((name, setup_hint, provider)) + return results + + +def _find_provider(providers: list, provider_name: str): + return next((p for p in providers if p[0] == provider_name), None) + + +def _post_setup_hook(provider, config: dict) -> bool: + """Normalize the ``memory`` block; True when the provider's ``post_setup`` took over (it owns + config, connection test and activation), so the caller must stop.""" + if not isinstance(config.get("memory"), dict): + config["memory"] = {} + if hasattr(provider, "post_setup"): + provider.post_setup(str(get_hermes_home()), config) + return True + return False + + +def cmd_setup_provider(provider_name: str) -> None: + """Run memory setup for a specific provider, skipping the picker.""" + from hermes_cli.config import load_config, save_config + + match = _find_provider(_get_available_providers(), provider_name) + if not match: + print(f"\n Memory provider '{provider_name}' not found.") + print(" Run 'hermes memory setup' to see available providers.\n") + return + name, _, provider = match + + _clear_interactive_transition() + _install_dependencies(name) + config = load_config() + if _post_setup_hook(provider, config): + return + # Fallback: generic schema-based setup (same as cmd_setup) + config["memory"]["provider"] = name + save_config(config) + print(f"\n Memory provider: {name}") + print(" Activation saved to config.yaml\n") + + +def _prompt_schema_fields(name: str, schema: list, provider_config: dict, env_writes: dict) -> bool: + """Walk a provider's config schema, prompting per field. False when the user cancelled.""" + print(f"\n Configuring {name}:\n") + for field in schema: + key = field["key"] + desc = field.get("description", key) + default = field.get("default") + # Dynamic default: look up default from another field's value + default_from = field.get("default_from") + if default_from and isinstance(default_from, dict): + ref_value = provider_config.get(default_from.get("field", ""), "") + ref_map = default_from.get("map", {}) + if ref_value and ref_value in ref_map: + default = ref_map[ref_value] + is_secret = field.get("secret", False) + choices = field.get("choices") + env_var = field.get("env_var") + url = field.get("url") + + when = field.get("when") + if when and isinstance(when, dict) and not all(provider_config.get(k) == v for k, v in when.items()): + continue + + if choices and not is_secret: + current = provider_config.get(key, default) + current_idx = choices.index(current) if current and current in choices else 0 + sel = _curses_select( + f" {desc}", [(c, "") for c in choices], default=current_idx, cancel_returns=_CANCELLED + ) + if sel == _CANCELLED: + _print_cancelled_setup() + return False + provider_config[key] = choices[sel] + elif is_secret: + existing = os.environ.get(env_var, "") if env_var else "" + if existing: + masked = f"...{existing[-4:]}" if len(existing) > 4 else "set" + val = _prompt(f"{desc} (current: {masked}, blank to keep)", secret=True) + else: + if url: + print(f" Get yours at {url}") + val = _prompt(desc, secret=True) + if val and env_var: + env_writes[env_var] = val + else: + effective_default = provider_config.get(key) or default + val = _prompt(desc, default=str(effective_default) if effective_default else None) + if val: + provider_config[key] = val + if env_var and env_var not in env_writes: + env_writes[env_var] = val + return True + + +def cmd_setup(args) -> None: + """Interactive memory provider setup wizard.""" + from hermes_cli.config import load_config, save_config + + providers = _get_available_providers() + if not providers: + print("\n No memory provider plugins detected.") + print(" Install a plugin to ~/.hermes/plugins/ and try again.\n") + return + + items = [(name, f"— {desc}") for name, desc, _ in providers] + items.append(("Built-in only", "— MEMORY.md / USER.md (default)")) + builtin_idx = len(items) - 1 + selected = _curses_select("Memory provider setup", items, default=builtin_idx, cancel_returns=_CANCELLED) + if selected == _CANCELLED: + _print_cancelled_setup() + return + + config = load_config() + if not isinstance(config.get("memory"), dict): + config["memory"] = {} + if selected >= len(providers): + config["memory"]["provider"] = "" + save_config(config) + print("\n ✓ Memory provider: built-in only") + print(" Saved to config.yaml\n") + return + + name, _, provider = providers[selected] + _clear_interactive_transition() + _install_dependencies(name) + if _post_setup_hook(provider, config): + return + + provider_config = config["memory"].get(name, {}) + if not isinstance(provider_config, dict): + provider_config = {} + env_writes: dict = {} + schema = _schema_of(provider) + if schema and not _prompt_schema_fields(name, schema, provider_config, env_writes): + return + + config["memory"]["provider"] = name + save_config(config) + + if provider_config and hasattr(provider, "save_config"): + try: + provider.save_config(provider_config, str(get_hermes_home())) + except Exception as e: + print(f" Failed to write provider config: {e}") + if env_writes: + _write_env_vars(env_writes) + + print(f"\n Memory provider: {name}") + print(" Activation saved to config.yaml") + if provider_config: + print(" Provider config saved") + if env_writes: + print(" API keys saved to .env") + print("\n Start a new session to activate.\n") + + +def _write_env_vars( + env_writes: dict, hermes_home: str | os.PathLike[str] | None = None) -> None: + """Persist memory-provider env vars through the canonical ``.env`` writer. + + ``save_env_value`` applies the shared gate (name regex, ``LD_PRELOAD``/``PYTHONPATH``/``HERMES_HOME`` + denylist, CR/LF stripping, atomic 0o600 writes). ``ValueError`` is reported and skipped so one + bad key doesn't sink the batch; filesystem errors propagate. ``hermes_home`` is applied via the + context-local override, not ``os.environ``. + """ + from hermes_cli.config import save_env_value + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + token = set_hermes_home_override(hermes_home) if hermes_home is not None else None + try: + for key, val in env_writes.items(): + try: + save_env_value(key, val) + except ValueError as exc: + print(f" Skipping {key}: {exc}") + finally: + if token is not None: + reset_hermes_home_override(token) + + +def _mark(enabled) -> str: + return "enabled ✓" if enabled else "disabled ✗" + + +def cmd_status(args) -> None: + """Show current memory provider config.""" + from hermes_cli.config import load_config + + config = load_config() + mem_config = config.get("memory", {}) + provider_name = mem_config.get("provider", "") + + # Memory tool enablement for the CLI platform via the canonical resolver, respecting the + # check_fn gate when both stores are disabled. + from hermes_cli.tools_config import _get_platform_tools + from tools.memory_tool import check_memory_requirements + cli_tools = _get_platform_tools(config, "cli", include_default_mcp_servers=False) + memory_tool_enabled = ("memory" in cli_tools) and check_memory_requirements() + + print("\nMemory status\n" + "─" * 40) + print(" Built-in (MEMORY.md / USER.md):") + print(f" Memory injection: {_mark(mem_config.get('memory_enabled', True))}") + print(f" User profile: {_mark(mem_config.get('user_profile_enabled', True))}") + print(f" Memory tool: {_mark(memory_tool_enabled)}") + print(f" Provider: {provider_name or '(none — built-in only)'}") + + providers = _get_available_providers() + match = _find_provider(providers, provider_name) + provider = match[2] if match else None + + if provider_name: + provider_config = mem_config.get(provider_name, {}) + display_config = provider_config + if provider and hasattr(provider, "get_status_config"): + try: + display_config = provider.get_status_config(provider_config) + except Exception as e: + display_config = dict(provider_config) if isinstance(provider_config, dict) else provider_config + if isinstance(display_config, dict): + display_config["status_config_error"] = str(e) + if display_config: + print(f"\n {provider_name} config:") + for key, val in display_config.items(): + print(f" {key}: {val}") + + if provider: + print("\n Plugin: installed ✓") + if provider.is_available(): + print(" Status: available ✓") + else: + print(" Status: not available ✗") + # All fields with env_var (secret and non-secret) + required_fields = [f for f in _schema_of(provider) if f.get("env_var")] + if required_fields: + print(" Missing:") + for f in required_fields: + env_var = f.get("env_var", "") + url = f.get("url", "") + is_set = bool(os.environ.get(env_var)) + line = f" {'✓' if is_set else '✗'} {env_var}" + if url and not is_set: + line += f" → {url}" + print(line) + print(" Note: systemd/gateway services do not inherit ~/.hermes/.env —") + print(" set any variables above in the service environment.") + else: + print("\n Plugin: NOT installed ✗") + print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/") + + if providers: + print("\n Installed plugins:") + for pname, desc, _ in providers: + active = " ← active" if pname == provider_name else "" + print(f" • {pname} ({desc}){active}") + print() + + +def memory_command(args) -> None: + """Route memory subcommands.""" + if getattr(args, "memory_command", None) == "setup": + provider = getattr(args, "provider", None) + if provider: + cmd_setup_provider(provider) + else: + cmd_setup(args) + else: + cmd_status(args) diff --git a/hermes_cli/middleware.py b/hermes_cli/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..897e4afc07ba0d78ba928baf39a8573422b648be --- /dev/null +++ b/hermes_cli/middleware.py @@ -0,0 +1,239 @@ +"""Hermes middleware contract helpers. + +Observer hooks report what happened. Middleware can change what happens by rewriting a request or +wrapping the actual execution callback. Agent-loop call sites and plugins share this vocabulary. +""" + +from __future__ import annotations + +import logging +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List + +logger = logging.getLogger(__name__) + +OBSERVER_SCHEMA_VERSION = "hermes.observer.v1" +MIDDLEWARE_SCHEMA_VERSION = "hermes.middleware.v1" + +TOOL_REQUEST_MIDDLEWARE = "tool_request" +TOOL_EXECUTION_MIDDLEWARE = "tool_execution" +LLM_REQUEST_MIDDLEWARE = "llm_request" +LLM_EXECUTION_MIDDLEWARE = "llm_execution" + +VALID_MIDDLEWARE: set[str] = { + TOOL_REQUEST_MIDDLEWARE, TOOL_EXECUTION_MIDDLEWARE, LLM_REQUEST_MIDDLEWARE, LLM_EXECUTION_MIDDLEWARE, +} + + +@dataclass +class RequestMiddlewareResult: + """Result of applying request middleware to a mutable payload.""" + + payload: Any + original_payload: Any + changed: bool = False + trace: List[Dict[str, Any]] = field(default_factory=list) + + +def observer_payload(**kwargs: Any) -> Dict[str, Any]: + kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION) + return kwargs + + +def middleware_payload(**kwargs: Any) -> Dict[str, Any]: + kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION) + kwargs.setdefault("middleware_schema_version", MIDDLEWARE_SCHEMA_VERSION) + return kwargs + + +def _safe_copy(payload: Any) -> Any: + """Deep-copy a request payload, tolerating non-deepcopyable members. + + An LLM request can carry clients/callbacks/file handles; a hard ``deepcopy`` failure would + otherwise abort the whole request-middleware pass. + """ + try: + return deepcopy(payload) + except Exception as exc: # pragma: no cover - exercised via fallback test + logger.debug("deepcopy failed for request payload (%s); using shallow copy", exc) + return dict(payload) if isinstance(payload, dict) else payload + + +def _apply_request_chain( + kind: str, payload_key: str, trace: List[Dict[str, Any]], original: Any, **kwargs: Any +) -> RequestMiddlewareResult: + """Feed ``kwargs[payload_key]`` through every ``kind`` middleware; each may return ``{payload_key: {...}}``.""" + from hermes_cli.plugins import invoke_middleware + + current = kwargs[payload_key] + for result in invoke_middleware(kind, **middleware_payload(**kwargs)): + if not isinstance(result, dict): + continue + next_payload = result.get(payload_key) + if not isinstance(next_payload, dict): + continue + current = _safe_copy(next_payload) + entry = { + key: value + for key in ("source", "reason", "name") + if isinstance(value := result.get(key), str) and value + } + trace.append(entry or {"source": "plugin"}) + return RequestMiddlewareResult( + payload=current, original_payload=original, changed=bool(trace), trace=trace, + ) + + +def apply_llm_request_middleware(request: Dict[str, Any], **context: Any) -> RequestMiddlewareResult: + """Apply registered LLM request middleware; ``{"request": {...}}`` replaces the provider kwargs.""" + from hermes_cli.plugins import has_middleware + + if not has_middleware(LLM_REQUEST_MIDDLEWARE): + return RequestMiddlewareResult(payload=request, original_payload=request) + + original_request = _safe_copy(request) + return _apply_request_chain( + LLM_REQUEST_MIDDLEWARE, "request", [], original_request, + request=_safe_copy(original_request), original_request=original_request, **context, + ) + + +def apply_tool_request_middleware( + tool_name: str, args: Dict[str, Any], **context: Any +) -> RequestMiddlewareResult: + """Apply registered tool request middleware; ``{"args": {...}}`` replaces the effective tool + arguments before hooks, guardrails, approvals, and execution see them.""" + original_args = _safe_copy(args) + current_args = _safe_copy(original_args) + trace: List[Dict[str, Any]] = [] + + session_id = str(context.get("session_id") or "") + skip_relay = bool(context.pop("skip_relay", False)) + if session_id and not skip_relay: + from agent import relay_runtime + + relay_args = relay_runtime.apply_tool_request_intercepts( + session_id=session_id, tool_name=tool_name, args=current_args) + if relay_args != current_args: + current_args = _safe_copy(relay_args) + trace.append({"source": "nemo_relay"}) + + from hermes_cli.plugins import has_middleware + + if not has_middleware(TOOL_REQUEST_MIDDLEWARE): + return RequestMiddlewareResult( + payload=args if not trace else current_args, original_payload=args, + changed=bool(trace), trace=trace, + ) + return _apply_request_chain( + TOOL_REQUEST_MIDDLEWARE, "args", trace, original_args, + tool_name=tool_name, args=current_args, original_args=original_args, **context, + ) + + +def run_llm_execution_middleware( + request: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any) -> Any: + """Run provider execution through registered LLM execution middleware.""" + return _run_execution_chain( + LLM_EXECUTION_MIDDLEWARE, next_call, + request=request, original_request=context.pop("original_request", request), **context) + + +def run_tool_execution_middleware( + tool_name: str, args: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any, +) -> Any: + """Run tool execution through registered tool execution middleware.""" + return _run_execution_chain( + TOOL_EXECUTION_MIDDLEWARE, next_call, + tool_name=tool_name, args=args, original_args=context.pop("original_args", args), **context) + + +class _DownstreamExecutionError(Exception): + """Marks an exception raised BELOW a middleware frame so the frame's own failure handling + (skip-and-continue) doesn't swallow it.""" + + def __init__(self, original: BaseException) -> None: + super().__init__(str(original)) + self.original = original + + +def _run_execution_chain(kind: str, terminal_call: Callable[[Any], Any], **kwargs: Any) -> Any: + from hermes_cli.plugins import get_plugin_manager + + payload_key = "request" if "request" in kwargs else "args" + callbacks = list(get_plugin_manager()._middleware.get(kind, [])) + if not callbacks: + return terminal_call(kwargs[payload_key]) + + def call_at(index: int, payload: Any) -> Any: + if index >= len(callbacks): + return terminal_call(payload) + + callback = callbacks[index] + next_called = False + next_succeeded = False + next_result: Any = None + + def next_call(next_payload: Any = None) -> Any: + nonlocal next_called, next_succeeded, next_result + # Single-use per frame: a second call would re-run the downstream provider/tool, so it + # is a contract violation, not a retry. + if next_called: + raise RuntimeError( + f"Middleware '{kind}' callback " + f"{getattr(callback, '__name__', repr(callback))} called " + "next_call() more than once; downstream execution is single-use" + ) + next_called = True + try: + next_result = call_at(index + 1, payload if next_payload is None else next_payload) + next_succeeded = True + return next_result + except Exception as exc: + raise _DownstreamExecutionError(exc) from exc + + call_kwargs = middleware_payload(**kwargs) + call_kwargs[payload_key] = payload + call_kwargs["next_call"] = next_call + try: + return callback(**call_kwargs) + except _DownstreamExecutionError as exc: + raise exc.original + except Exception as exc: + logger.warning( + "Middleware '%s' callback %s raised: %s", + kind, getattr(callback, "__name__", repr(callback)), exc) + if next_succeeded: + return next_result + if next_called: + raise + return call_at(index + 1, payload) + + return call_at(0, kwargs[payload_key]) + + +# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- +# Names external plugins imported from this module before the Sep 2026 decomposition. +# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does). +# The whole block is removed by reverting the commit that added it. + +API_EXECUTION_MIDDLEWARE = LLM_EXECUTION_MIDDLEWARE + +API_REQUEST_MIDDLEWARE = LLM_REQUEST_MIDDLEWARE + +def apply_api_request_middleware( + request: Dict[str, Any], + **context: Any, +) -> RequestMiddlewareResult: + """Compatibility wrapper for older ``api_request`` naming.""" + return apply_llm_request_middleware(request, **context) + +def run_api_execution_middleware( + request: Dict[str, Any], + next_call: Callable[[Dict[str, Any]], Any], + **context: Any, +) -> Any: + """Compatibility wrapper for older ``api_execution`` naming.""" + return run_llm_execution_middleware(request, next_call, **context) +# ---- END PLUGIN-COMPAT ---- diff --git a/hermes_cli/models_pricing.py b/hermes_cli/models_pricing.py new file mode 100644 index 0000000000000000000000000000000000000000..a87652fea945a13c9fe75176fdccf0c7a573f178 --- /dev/null +++ b/hermes_cli/models_pricing.py @@ -0,0 +1,591 @@ +"""Live model pricing. + +OpenRouter-compatible ``/v1/models`` pricing fetch with a per-endpoint/per-credential cache, +Nous Portal sale chrome and org-policy filtering, and the Vercel AI Gateway / Novita / Fireworks / +DeepInfra pricing adapters. Split out of ``hermes_cli.models``; helpers still defined there are +looked up on ``hermes_cli.models`` at call time so ``patch("hermes_cli.models.")`` mocks keep +intercepting. +""" + +from __future__ import annotations + +import json +import os +import time +import urllib.request +from typing import Any, Optional +from hermes_cli.models_reasoning_caps import _seed_reasoning_caps + + +# Cache: maps model_id → {"prompt": str, "completion": str} per endpoint +_pricing_cache: dict[str, dict[str, dict[str, str]]] = {} +# (profile key, provider) → endpoint cache key last fetched, so cached_only reads find the right entry. +_pricing_provider_cache_keys: dict[tuple[str, str], str] = {} + +# A failed fetch caches its empty result too, so an unreachable endpoint isn't re-dialed on every +# call — but only until this deadline. Cached forever, one blip at startup would mean no live model +# discovery for the life of a process that runs for weeks (gateway, desktop backend), silently: +# every caller falls back to a curated list meanwhile. +_FAILED_CATALOG_TTL_SECONDS = 120.0 + +_pricing_cache_retry_after: dict[str, float] = {} + + +def _cached_catalog(cache_key: str) -> Optional[dict[str, dict[str, Any]]]: + """The cached catalog for *cache_key*, or None to go fetch it.""" + cached = _pricing_cache.get(cache_key) + if cached is None: + return None + retry_after = _pricing_cache_retry_after.get(cache_key) + if retry_after is not None and time.monotonic() >= retry_after: + _pricing_cache.pop(cache_key, None) + _pricing_cache_retry_after.pop(cache_key, None) + return None + return cached + + +def _cache_catalog( + cache_key: str, + result: dict[str, dict[str, Any]], + ttl_seconds: Optional[float] = None, +) -> dict[str, dict[str, Any]]: + """Cache a catalog result, giving an empty one an expiry. *ttl_seconds* expires a non-empty + result too — only for a catalog whose contents depend on server-side state the client cannot + observe (an org's model policy can change while a long-lived process holds the entry).""" + _pricing_cache[cache_key] = result + if not result: + _pricing_cache_retry_after[cache_key] = time.monotonic() + _FAILED_CATALOG_TTL_SECONDS + elif ttl_seconds: + _pricing_cache_retry_after[cache_key] = time.monotonic() + ttl_seconds + else: + _pricing_cache_retry_after.pop(cache_key, None) + return result + + +# NUL cannot appear in a URL, so this cannot collide with a real base URL. +_PRICING_AUTH_KEY_PREFIX = "\x00auth:" + + +def _pricing_auth_fingerprint(api_key: str | None) -> str: + """Cache-key suffix identifying the credential a catalog was read with: a governed endpoint + answers each token with the catalog its org may reach, so two credentials cannot share an + entry. blake2b for fingerprinting only (same rationale as ``_custom_endpoint_fingerprint``).""" + if not api_key: + return "" + import hashlib + + digest = hashlib.blake2b(api_key.encode("utf-8", errors="replace"), digest_size=8) + return _PRICING_AUTH_KEY_PREFIX + digest.hexdigest() + + +def peek_cached_pricing(base_url: str) -> dict[str, dict[str, Any]]: + """Pricing already cached for *base_url* (with or without ``/v1``), or ``{}``; never fetches. + Prefers an authenticated catalog, scanning newest first (callers hold no credential) and + skipping expired entries so a rotated credential does not answer from its predecessor's.""" + root = _strip_v1((base_url or "").rstrip("/")) + authed_prefix = root + _PRICING_AUTH_KEY_PREFIX + for key in reversed(list(_pricing_cache)): + if key.startswith(authed_prefix): + cached = _cached_catalog(key) + if cached: + return cached + return _cached_catalog(root) or {} + + +def _strip_v1(url: str) -> str: + return url[:-3].rstrip("/") if url.endswith("/v1") else url + + +def _format_price_per_mtok(per_token_str: str) -> str: + """Per-token price string → $/Mtok string. Always 2 decimals so right-justified prices align; + sub-cent prices (deep-discount cache-hit promos) widen precision until the value shows, keep + one extra digit and trim trailing zeros instead of collapsing to "$0.00".""" + try: + val = float(per_token_str) + except (TypeError, ValueError): + return "?" + if val == 0: + return "free" + per_m = val * 1_000_000 + text = f"{per_m:.2f}" + if per_m < 0.01: + prec = 3 + while prec < 12 and round(per_m, prec) == 0: + prec += 1 + text = f"{per_m:.{min(prec + 1, 12)}f}".rstrip("0").rstrip(".") + return f"${text}" + + +def _price_float(raw: Any, *, positive: bool) -> float | None: + """*raw* as a finite float (> 0, or >= 0 when not *positive*); None when unset/invalid/NaN.""" + if raw in (None, ""): + return None + try: + n = float(raw) + except (TypeError, ValueError): + return None + if n != n or (n <= 0 if positive else n < 0): + return None + return n + + +def _sale_pct(current: Any, original: Any) -> int | None: + """Percent discount when *current* is strictly below *original* (both positive finite).""" + cur, orig = _price_float(current, positive=True), _price_float(original, positive=True) + if cur is None or orig is None or cur >= orig: + return None + return int(round((1.0 - (cur / orig)) * 100)) + + +def compute_sale_discount(prompt: str, completion: str, original: Any) -> tuple[int, str, str] | None: + """Sale chrome from gateway ``pricing.original`` (Nous Portal only; callers gate on the provider + and opted in via ``include_sale_original=True``): ``(discount_percent, was_prompt_raw, + was_completion_raw)`` when ``original`` is a dict and the current prompt (fallback: completion) + rate is strictly below the original. Free / $0 models get a flat 100% off, with "was" prices + only when the gateway served an original (a natively-free stealth model gets bare "-100%").""" + orig_dict = original if isinstance(original, dict) else {} + was_prompt = orig_dict.get("prompt") + was_completion = orig_dict.get("completion") + was_prompt_str = str(was_prompt) if was_prompt not in (None, "") else "" + was_completion_str = str(was_completion) if was_completion not in (None, "") else "" + + if _price_float(prompt, positive=False) == 0 and _price_float(completion, positive=False) in (0, None): + return (100, was_prompt_str, was_completion_str) + + if not isinstance(original, dict) or (not was_prompt_str and not was_completion_str): + return None + + pct = _sale_pct(prompt, was_prompt) + if pct is not None: + return (pct, was_prompt_str, was_completion_str) if pct >= 1 else None + pct = _sale_pct(completion, was_completion) + if pct is not None: + return (pct, was_prompt_str, was_completion_str) if pct >= 1 else None + return None + + +def _get_json(url: str, headers: dict[str, str], timeout: float, opener=None) -> Optional[dict]: + """GET *url* as JSON via the origin's catalog opener (or *opener*); None on any failure.""" + from hermes_cli.models import _urlopen_model_catalog_request + + try: + req = urllib.request.Request(url, headers=headers) + with (opener or _urlopen_model_catalog_request)(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except Exception: + return None + + +def _pricing_entry(pricing: dict, prompt_key: str = "prompt", completion_key: str = "completion") -> dict[str, Any]: + """Picker-shape ``{prompt, completion[, input_cache_read, input_cache_write]}`` from a catalog + ``pricing`` block whose cache fields already use the hermes names.""" + entry: dict[str, Any] = { + "prompt": str(pricing.get(prompt_key, "")), + "completion": str(pricing.get(completion_key, "")), + } + for key in ("input_cache_read", "input_cache_write"): + if pricing.get(key): + entry[key] = str(pricing[key]) + return entry + + +def _per_token(per_mtok: Any) -> str: + """$/MTok → the per-token price string the picker expects.""" + return str(float(per_mtok) / 1_000_000) + + +def _catalog_items(payload: dict) -> list[dict]: + return [item for item in payload.get("data", []) if isinstance(item, dict)] + + +def fetch_models_with_pricing( + api_key: str | None = None, + base_url: str = "https://openrouter.ai/api", + timeout: float = 8.0, + *, + force_refresh: bool = False, + include_sale_original: bool = False, + cache_ttl_seconds: Optional[float] = None, +) -> dict[str, dict[str, Any]]: + """Fetch ``/v1/models`` (any OpenRouter-compatible endpoint) → ``{model_id: {prompt, completion, + ...}}``, cached per *base_url* and per credential so one caller's catalog never answers + another's read. *include_sale_original* (Nous Portal only) copies the gateway's pre-discount + ``pricing.original`` rates through as a nested ``original`` dict for sale chrome.""" + from hermes_cli.models import _HERMES_USER_AGENT + url_root = (base_url or "").rstrip("/") + cache_key = url_root + _pricing_auth_fingerprint(api_key) + if not force_refresh: + cached = _cached_catalog(cache_key) + if cached is not None: + return cached + + url = url_root + "/v1/models" + headers = {"Accept": "application/json", "User-Agent": _HERMES_USER_AGENT} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + payload = _get_json(url, headers, timeout) + if payload is None: + return _cache_catalog(cache_key, {}) + + # Same document the reasoning-capability fetch would pull — mirror it so a later hot-path + # lookup (and the next process) has an answer without its own round-trip. + _seed_reasoning_caps(url, payload.get("data")) + + result: dict[str, dict[str, Any]] = {} + for item in payload.get("data", []): + mid, pricing = item.get("id"), item.get("pricing") + if mid and isinstance(pricing, dict): + entry = _pricing_entry(pricing) + # Sale chrome is Nous Portal-only; never copy pricing.original for other catalogs. + original = pricing.get("original") if include_sale_original else None + if isinstance(original, dict): + orig_entry = {key: str(original[key]) for key in ("prompt", "completion", "input_cache_read", "input_cache_write") + if original.get(key) not in (None, "")} + if orig_entry.get("prompt") or orig_entry.get("completion"): + entry["original"] = orig_entry + result[mid] = entry + + return _cache_catalog(cache_key, result, cache_ttl_seconds) + + +def fetch_ai_gateway_pricing(timeout: float = 8.0, *, force_refresh: bool = False) -> dict[str, dict[str, str]]: + """Vercel AI Gateway /v1/models pricing, translating its ``input`` / ``output`` field names to + the picker's ``prompt`` / ``completion`` (cache read/write names already match).""" + from hermes_constants import AI_GATEWAY_BASE_URL + + cache_key = AI_GATEWAY_BASE_URL.rstrip("/") + if not force_refresh: + cached = _cached_catalog(cache_key) + if cached is not None: + return cached + + payload = _get_json(f"{cache_key}/models", {"Accept": "application/json"}, timeout, opener=urllib.request.urlopen) + if payload is None: + return _cache_catalog(cache_key, {}) + + result: dict[str, dict[str, str]] = {} + for item in _catalog_items(payload): + mid, pricing = item.get("id"), item.get("pricing") + if mid and isinstance(pricing, dict): + result[mid] = _pricing_entry(pricing, "input", "output") + return _cache_catalog(cache_key, result) + + +def _resolve_openrouter_api_key() -> str: + """Best-effort OpenRouter API key for pricing fetch.""" + return os.getenv("OPENROUTER_API_KEY", "").strip() + + +_DEFAULT_NOUS_INFERENCE_BASE = "https://inference-api.nousresearch.com" + + +def _resolve_nous_pricing_credentials() -> tuple[str, str]: + """``(api_key, base_url)`` for Nous Portal pricing; base_url is the bare origin (no ``/v1``). + Precedence mirrors runtime credential resolution: ``NOUS_INFERENCE_BASE_URL`` (staging / + preview) → resolved credential ``base_url`` → production default. Without the override a + staging profile's sale ``pricing.original`` would never reach the pickers.""" + try: + from hermes_cli.auth import _nous_inference_env_override + + env_base = _nous_inference_env_override() + except Exception: + env_base = None + api_key = creds_base = "" + try: + from hermes_cli.auth import resolve_nous_runtime_credentials + + creds = resolve_nous_runtime_credentials() + if creds: + api_key = creds.get("api_key", "") or "" + creds_base = (creds.get("base_url", "") or "").strip() + except Exception: + pass + base_url = (env_base or creds_base or _DEFAULT_NOUS_INFERENCE_BASE).rstrip("/") + if base_url.endswith("/v1"): + base_url = base_url[:-3] + return (api_key, base_url) + + +# How long a Nous catalog stays trusted. Its contents depend on the org's policy, which an admin +# can change at any time and the client cannot observe, so a long-lived process must re-ask. +# Other providers' catalogs carry no such state and keep the default no-expiry caching. +_NOUS_CATALOG_TTL_SECONDS = 300.0 + + +def _fetch_nous_pricing(api_key: str, base_url: str, *, force_refresh: bool) -> dict[str, dict[str, Any]]: + """Shared by pricing and policy lookups so both read one cache entry.""" + return fetch_models_with_pricing( + api_key=api_key, + base_url=base_url, + force_refresh=force_refresh, + include_sale_original=True, # Sale chrome (pricing.original) is Nous Portal-only. + cache_ttl_seconds=_NOUS_CATALOG_TTL_SECONDS, + ) + + +def nous_policy_allowed_ids(*, force_refresh: bool = False) -> Optional[set[str]]: + """The Nous model ids the caller's org may reach (keys of an authenticated ``GET /v1/models``, + which omits policy-blocked rows), or ``None`` to not filter: no policy (or a token too old to + say), an anonymous read (unfiltered catalog), or an empty read (a fetch failure, not an org + that may reach nothing).""" + try: + from hermes_cli.nous_account import nous_policy_present + + if nous_policy_present() is not True: + return None + except Exception: + return None + + api_key, base_url = _resolve_nous_pricing_credentials() + if not api_key or not base_url: + return None + return set(_fetch_nous_pricing(api_key, base_url, force_refresh=force_refresh)) or None + + +# Past this size an allowed set reads as a whole catalog rather than an allowlist, and is not +# worth showing in place of an empty picker. +_NOUS_POLICY_APPEND_MAX = 64 + + +def restrict_to_nous_policy( + model_ids: list[str], + allowed: Optional[set[str]], + *, + rescue_empty: bool = False, +) -> list[str]: + """*model_ids* narrowed to *allowed*, preserving order. A ``:free`` sibling is kept when its + base model is reachable (the gateway admits a row when any requestable id passes); over-listing + costs a 403 from the authoritative gate, hiding a servable row is unrecoverable client-side. + *rescue_empty*: an allowlist naming only models the curated manifest lacks would leave an empty + picker — worse than no filter — so return the allowlist itself. Opt-in per list: an already- + empty list (a paid tier's gated models) means "nothing to gate", not "nothing survived".""" + if not allowed: + return list(model_ids) + kept = [mid for mid in model_ids if mid in allowed or mid.split(":", 1)[0] in allowed] + if rescue_empty and not kept and len(allowed) <= _NOUS_POLICY_APPEND_MAX: + return sorted(allowed) + return kept + + +def _remember_provider_cache_key(provider: str, cache_key: str) -> None: + from hermes_cli.models import _pricing_profile_key + _pricing_provider_cache_keys[(_pricing_profile_key(), provider)] = cache_key + + +def _fetch_openrouter_pricing(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]: + _remember_provider_cache_key("openrouter", _OPENROUTER_PRICING_BASE) + return fetch_models_with_pricing( + api_key=_resolve_openrouter_api_key(), + base_url=_OPENROUTER_PRICING_BASE, + force_refresh=force_refresh, + ) + + +def _fetch_ai_gateway_pricing_for_provider(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]: + _remember_provider_cache_key("ai-gateway", _ai_gateway_pricing_scope()) + return fetch_ai_gateway_pricing(force_refresh=force_refresh) + + +def _fetch_novita_pricing_for_provider(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]: + _remember_provider_cache_key("novita", _novita_pricing_scope()) + return _fetch_novita_pricing(force_refresh=force_refresh) + + +def _fetch_fireworks_pricing_for_provider(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]: + _remember_provider_cache_key("fireworks", _FIREWORKS_PRICING_KEY) + return _fireworks_pricing_from_models_dev(force_refresh=force_refresh) + + +def _fetch_nous_pricing_for_provider(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]: + api_key, base_url = _resolve_nous_pricing_credentials() + if not base_url: + return {} + _remember_provider_cache_key("nous", base_url.rstrip("/")) + return _fetch_nous_pricing(api_key, base_url, force_refresh=force_refresh) + + +_OPENROUTER_PRICING_BASE = "https://openrouter.ai/api" +_FIREWORKS_PRICING_KEY = "models.dev/fireworks" + + +def _ai_gateway_pricing_scope() -> str: + from hermes_constants import AI_GATEWAY_BASE_URL + return AI_GATEWAY_BASE_URL.rstrip("/") + + +def _novita_pricing_scope() -> str: + return (os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1").rstrip("/") + + +def get_cached_nous_inference_base_url() -> str: + """The profile's persisted Nous endpoint (bare origin, no ``/v1``) without refreshing auth.""" + try: + from hermes_cli.auth import ( + _load_auth_store, _load_provider_state, _optional_base_url, _validate_nous_inference_url_from_network, + ) + + state = _load_provider_state(_load_auth_store(), "nous") or {} + url = _validate_nous_inference_url_from_network(_optional_base_url(state.get("inference_base_url"))) or "" + return url.rstrip("/").removesuffix("/v1") + except Exception: + return "" + + +# Static endpoint identity per provider; dynamic ones (deepinfra, nous) are resolved in pricing_cache_scope. +_STATIC_PRICING_SCOPES = { + "openrouter": lambda: _OPENROUTER_PRICING_BASE, + "ai-gateway": _ai_gateway_pricing_scope, + "novita": _novita_pricing_scope, + "fireworks": lambda: _FIREWORKS_PRICING_KEY, +} + + +def pricing_cache_scope(provider: str, *, current_provider: str = "", current_base_url: str = "") -> str: + """The current endpoint identity a provider's pricing cache is keyed on. Resolves local configuration + only, never fetches: picker prewarm single-flight uses it so an endpoint rotation can start a new + worker while the previous endpoint is still slow or unreachable.""" + from hermes_cli.models import _deepinfra_catalog_url, _pricing_profile_key, normalize_provider + normalized = normalize_provider(provider) + static = _STATIC_PRICING_SCOPES.get(normalized) + if static: + return static() + if normalized == "deepinfra": + return _deepinfra_catalog_url()[0] + if normalized == "nous": + try: + from hermes_cli.auth import _nous_inference_env_override + + env_base = _nous_inference_env_override() + except Exception: + env_base = None + if env_base: + return env_base.rstrip("/").removesuffix("/v1") + if normalize_provider(current_provider) == "nous" and current_base_url: + return current_base_url.rstrip("/").removesuffix("/v1") + persisted_base = get_cached_nous_inference_base_url() + if persisted_base: + return persisted_base + return _pricing_provider_cache_keys.get((_pricing_profile_key(), normalized), _DEFAULT_NOUS_INFERENCE_BASE) + return "" + + +def _cached_only_pricing(normalized: str) -> dict[str, dict[str, str]]: + """Process-resident pricing for *normalized* without any provider I/O.""" + from hermes_cli.models import _deepinfra_catalog_cache, _deepinfra_catalog_url, _pricing_profile_key + if normalized == "deepinfra": + cache_key, _url = _deepinfra_catalog_url() + return _fetch_deepinfra_pricing() if cache_key in _deepinfra_catalog_cache else {} + cache_key = _pricing_provider_cache_keys.get((_pricing_profile_key(), normalized)) + if cache_key is None and normalized in ("openrouter", "ai-gateway", "fireworks"): + cache_key = _STATIC_PRICING_SCOPES[normalized]() + return (_cached_catalog(cache_key) or {}) if cache_key else {} + + +def get_pricing_for_provider( + provider: str, *, force_refresh: bool = False, cached_only: bool = False +) -> dict[str, dict[str, str]]: + """Return live pricing for providers that support it (openrouter, nous, ai-gateway, novita, + deepinfra, fireworks); ``{}`` for everything else. ``cached_only`` never starts provider I/O: + normal picker opens use it so cold endpoints cannot hold the response path, while a background + prewarm fills the same caches for later opens.""" + from hermes_cli.models import normalize_provider + normalized = normalize_provider(provider) + if cached_only: + return _cached_only_pricing(normalized) + fetcher = _PRICING_FETCHERS.get(normalized) + return fetcher(force_refresh=force_refresh) if fetcher else {} + + +def _fireworks_pricing_from_models_dev(*, force_refresh: bool = False) -> dict[str, dict[str, str]]: + """Fireworks picker pricing from the models.dev registry cache (``fetch_models_dev()`` keeps a + shared in-memory + disk cache, 1h TTL) — a pure dict transform, no per-render network call.""" + cache_key = "models.dev/fireworks" + if not force_refresh: + cached = _cached_catalog(cache_key) + if cached is not None: + return cached + + result: dict[str, dict[str, str]] = {} + try: + from agent.models_dev import _get_provider_models + + for mid, entry in (_get_provider_models("fireworks") or {}).items(): + cost = entry.get("cost") if isinstance(entry, dict) else None + if not isinstance(cost, dict): + continue + inp, out = cost.get("input"), cost.get("output") + if inp is None and out is None: + continue + row = {"prompt": _per_token(inp or 0), "completion": _per_token(out or 0)} + if cost.get("cache_read"): + row["input_cache_read"] = _per_token(cost["cache_read"]) + result[str(mid)] = row + except Exception: + result = {} + + return _cache_catalog(cache_key, result) + + +def _fetch_novita_pricing(timeout: float = 8.0, *, force_refresh: bool = False) -> dict[str, dict[str, str]]: + """NovitaAI /v1/models pricing (per-million prices in units of 0.0001 USD → per-token strings), + cached on the resolved base URL so menu renders don't re-hit the network.""" + from hermes_cli.models import _HERMES_USER_AGENT + api_key = os.getenv("NOVITA_API_KEY", "").strip() + if not api_key: + return {} + + cache_key = (os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1").rstrip("/") + if not force_refresh: + cached = _cached_catalog(cache_key) + if cached is not None: + return cached + + headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json", "User-Agent": _HERMES_USER_AGENT} + payload = _get_json(cache_key + "/models", headers, timeout) + if payload is None: + return _cache_catalog(cache_key, {}) + + result: dict[str, dict[str, str]] = {} + for item in _catalog_items(payload): + mid = item.get("id") + inp, out = item.get("input_token_price_per_m"), item.get("output_token_price_per_m") + if not mid or (inp is None and out is None): + continue + result[str(mid)] = { + "prompt": str(float(inp or 0) / 10_000 / 1_000_000), + "completion": str(float(out or 0) / 10_000 / 1_000_000), + } + + return _cache_catalog(cache_key, result) + + +def _fetch_deepinfra_pricing(timeout: float = 5.0, *, force_refresh: bool = False) -> dict[str, dict[str, str]]: + """DeepInfra chat-model pricing: ``input_tokens`` / ``output_tokens`` / ``cache_read_tokens`` in + $/MTok → per-token ``prompt`` / ``completion`` / ``input_cache_read`` (cached by the by-tag + helper).""" + from hermes_cli.models import _fetch_deepinfra_models_by_tag + items = _fetch_deepinfra_models_by_tag("chat", timeout=timeout, force_refresh=force_refresh) + result: dict[str, dict[str, str]] = {} + for item in items or []: + metadata = item.get("metadata") or {} + pricing = metadata.get("pricing") if isinstance(metadata, dict) else None + if not isinstance(pricing, dict): + continue + entry = { + ours: _per_token(pricing[theirs]) + for theirs, ours in (("input_tokens", "prompt"), ("output_tokens", "completion"), ("cache_read_tokens", "input_cache_read")) + if pricing.get(theirs) is not None + } + if entry: + result[item["id"]] = entry + return result + + +_PRICING_FETCHERS = { + "openrouter": _fetch_openrouter_pricing, + "ai-gateway": _fetch_ai_gateway_pricing_for_provider, + "novita": _fetch_novita_pricing_for_provider, + "deepinfra": _fetch_deepinfra_pricing, + "fireworks": _fetch_fireworks_pricing_for_provider, + "nous": _fetch_nous_pricing_for_provider, +} diff --git a/hermes_cli/provider_catalog.py b/hermes_cli/provider_catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..8f381e6a0582ffb383b4c9eb77a046ea29ddbdf0 --- /dev/null +++ b/hermes_cli/provider_catalog.py @@ -0,0 +1,117 @@ +"""Unified provider catalog — one source of truth for the provider universe. + +The provider list shown by ``hermes model`` (CLI/TUI) and the desktop Settings → Providers tabs +(Accounts + API keys) **must be the same set**; providers added after those lists were written +silently went missing from the GUI. ``auth_type`` / ``api_key_env_vars`` / ``base_url_env_var`` +come from :data:`hermes_cli.auth.PROVIDER_REGISTRY` (credential truth); ``display_name`` / +``description`` / ``signup_url`` from the provider's :class:`providers.base.ProviderProfile`, falling +back to the ``CANONICAL_PROVIDERS`` entry's ``label`` / ``tui_desc`` and the ``OPTIONAL_ENV_VARS`` +signup URL (many profiles leave these blank, and lmstudio, openai-api, tencent-tokenhub, xai-oauth +have no profile at all — the fallbacks are load-bearing). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# Auth types that authenticate via an account / sign-in flow rather than a pasted API key; these +# route to the desktop "Accounts" tab, everything else (api_key, and aws_sdk configured via +# AWS_REGION/AWS_PROFILE) to "API keys". Mirrors the auth_type strings in PROVIDER_REGISTRY and +# ProviderProfile: external_process = copilot-acp (spawns `copilot --acp --stdio`), copilot = GitHub +# Copilot token / gh auth. +_ACCOUNTS_AUTH_TYPES: frozenset[str] = frozenset( + {"oauth_device_code", "oauth_external", "oauth_minimax", "external_process", "copilot"} +) + + +@dataclass(frozen=True) +class ProviderDescriptor: + """One provider, as seen by every surface (CLI picker + both GUI tabs).""" + + slug: str # canonical id, e.g. "openai-codex" + label: str # human display name + description: str # one-line description + auth_type: str # api_key | oauth_* | external_process | copilot | aws_sdk + tab: str # "keys" | "accounts" + api_key_env_vars: tuple[str, ...] # credential env vars (may be empty) + base_url_env_var: str # base-URL override env var (may be "") + signup_url: str # signup / console URL (may be "") + order: int # CANONICAL_PROVIDERS index — mirrors `hermes model` + keyless: bool = False # served anonymously — no credential exists to configure + + +def tab_for_auth_type(auth_type: str) -> str: + """Return the desktop tab ("keys"|"accounts") a provider's auth maps to.""" + return "accounts" if auth_type in _ACCOUNTS_AUTH_TYPES else "keys" + + +def _is_url_var(name: str) -> bool: + return name.endswith("_BASE_URL") or name.endswith("_URL") + + +def _split_env_vars(env_vars: tuple[str, ...]) -> tuple[tuple[str, ...], str]: + """Split a profile's ``env_vars`` into (api_key_vars, base_url_var).""" + return tuple(v for v in env_vars if not _is_url_var(v)), next((v for v in env_vars if _is_url_var(v)), "") + + +def _safe_import(module: str, attr: str, default): + """Import ``attr`` from ``module``; ``default`` on ANY failure — this module is on the import + path of the web server and the CLI, and a provider-plugin import error must never blank the + whole catalog.""" + try: + return getattr(__import__(module, fromlist=[attr]), attr) + except Exception: + return default + + +def provider_catalog() -> list[ProviderDescriptor]: + """One descriptor per provider in the ``hermes model`` universe (:data:`CANONICAL_PROVIDERS`, + auto-extended by provider plugins). Auth/env from ``PROVIDER_REGISTRY``; display metadata from + ``ProviderProfile`` with canonical/env fallbacks so profile-less providers still resolve.""" + from hermes_cli.models import CANONICAL_PROVIDERS + PROVIDER_REGISTRY = _safe_import("hermes_cli.auth", "PROVIDER_REGISTRY", {}) + OPTIONAL_ENV_VARS = _safe_import("hermes_cli.config", "OPTIONAL_ENV_VARS", {}) + # Overlays carry auth_type for providers with no registry/profile entry — notably the ``moa`` + # virtual provider (auth_type "virtual"), which has no credential and no network endpoint. + HERMES_OVERLAYS = _safe_import("hermes_cli.providers", "HERMES_OVERLAYS", {}) + try: + from providers import list_providers + profiles = {p.name: p for p in list_providers()} + except Exception: + profiles = {} + out: list[ProviderDescriptor] = [] + for order, entry in enumerate(CANONICAL_PROVIDERS): + slug = entry.slug + cfg = PROVIDER_REGISTRY.get(slug) + prof = profiles.get(slug) + overlay = HERMES_OVERLAYS.get(slug) + # auth_type: registry is authoritative; then profile, then overlay (moa → "virtual"), then api_key. + auth_type = ((cfg.auth_type if cfg else "") or (prof.auth_type if prof else "") + or (overlay.auth_type if overlay else "") or "api_key") + # Credential env vars: registry first (already normalized), else derived from the profile. + if cfg and cfg.api_key_env_vars: + api_key_vars, base_url_var = tuple(cfg.api_key_env_vars), cfg.base_url_env_var or "" + elif prof and prof.env_vars: + api_key_vars, base_url_var = _split_env_vars(tuple(prof.env_vars)) + else: + api_key_vars, base_url_var = (), "" + label = (prof.display_name if prof else "") or entry.label or slug + signup_url = (prof.signup_url if prof else "") or "" + if not signup_url and api_key_vars: + signup_url = (OPTIONAL_ENV_VARS.get(api_key_vars[0]) or {}).get("url") or "" + out.append( + ProviderDescriptor( + slug=slug, label=label, description=(prof.description if prof else "") or entry.tui_desc or label, + auth_type=auth_type, tab=tab_for_auth_type(auth_type), api_key_env_vars=api_key_vars, + base_url_env_var=base_url_var, signup_url=signup_url, order=order, + # Keyless providers (opencode-free) are served anonymously: no key card in the GUI, + # and contract tests exempt them. + keyless=bool(overlay.keyless) if overlay else False, + ) + ) + return out + + +def provider_catalog_by_slug() -> dict[str, ProviderDescriptor]: + """Convenience: the catalog keyed by slug.""" + return {d.slug: d for d in provider_catalog()} diff --git a/hermes_cli/session_export_md.py b/hermes_cli/session_export_md.py new file mode 100644 index 0000000000000000000000000000000000000000..b8732dc67e67303c2f5a30157a36e2b3a50dfd29 --- /dev/null +++ b/hermes_cli/session_export_md.py @@ -0,0 +1,219 @@ +"""Markdown/QMD export helpers for Hermes sessions. + +Filesystem-only: formats already-exported SessionDB dicts and writes them to user-selected export +directories. Must not mutate state.db or call delete/prune/archive APIs. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from hermes_cli.timefmt import coerce_epoch + +EXPORTER_VERSION = "hermes sessions export (md/qmd) v1" +_SHA_LINE_RE = re.compile(r"- SHA256 of exported body: `([0-9a-f]{64})`") +_SHA_PLACEHOLDER = "__SHA256_PLACEHOLDER__" +_VERIFICATION_HEADING = "## Export verification" + + +def _iso_timestamp(value: Any) -> str: + if value is None or value == "": + return "" + if (ts := coerce_epoch(value)) is None: + return str(value) # corrupt cell: odd-looking date, not an aborted export + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z") + + +def _frontmatter_line(key: str, value: Any) -> str: + if value is None or isinstance(value, bool): + shown = {None: "null", True: "true", False: "false"}[value] + else: + shown = json.dumps(value if isinstance(value, (int, float, list)) else str(value), ensure_ascii=False) + return f"{key}: {shown}" + + +def _json_block(value: Any) -> str: + return "```json\n" + json.dumps(value, ensure_ascii=False, indent=2) + "\n```" + + +def _message_heading(message: dict[str, Any]) -> str: + role = str(message.get("role") or "message") + name = message.get("name") or message.get("tool_name") + label = f"Tool — {name}" if role == "tool" and name else role.capitalize() + timestamp = _iso_timestamp(message.get("created_at") or message.get("timestamp")) + return f"### {label}{' — ' + timestamp if timestamp else ''}" + + +def _session_id(session: dict[str, Any]) -> str: + return str(session.get("id") or session.get("session_id") or "unknown-session") + + +def _segments(session: dict[str, Any]) -> list[dict[str, Any]]: + segments = session.get("segments") + return [s for s in segments if isinstance(s, dict)] if isinstance(segments, list) and segments else [session] + + +def _message_count(session: dict[str, Any]) -> int: + return sum(len(seg.get("messages") or []) for seg in _segments(session)) + + +def _render_messages(session: dict[str, Any]) -> str: + segments = _segments(session) + if _message_count(session) == 0: + return "## Messages\n\n_No messages in this session._\n" + parts: list[str] = ["## Messages\n"] + for segment in segments: + if len(segments) > 1: + parts.append(f"## Compression segment: {_session_id(segment)}\n") + for message in list(segment.get("messages") or []): + parts.append(_message_heading(message) + "\n") + content = message.get("content") + rendered = "" if content is None else content.rstrip() if isinstance(content, str) else _json_block(content) + if rendered: + parts.append(rendered + "\n") + if tool_calls := message.get("tool_calls"): + parts.append("\n\n## Tool calls\n\n" + _json_block(tool_calls) + "\n") + parts.append("") + return "\n".join(parts).rstrip() + "\n" + + +def _export_body_without_hash(session: dict[str, Any], *, fmt: str, exported_at: float) -> str: + session_id = _session_id(session) + exported_iso = _iso_timestamp(exported_at) + message_count = _message_count(session) + fields = [ + ("session_id", session_id), + ("title", session.get("title")), + ("source", session.get("source")), + ("created_at", _iso_timestamp(session.get("started_at") or session.get("created_at"))), + ("updated_at", _iso_timestamp(session.get("last_active") or session.get("updated_at"))), + ("ended_at", _iso_timestamp(session.get("ended_at"))), + ("model", session.get("model")), + ("provider", session.get("billing_provider") or session.get("provider")), + ("cwd", session.get("cwd")), + ("archived", bool(session.get("archived"))), + ("message_count", message_count), + ("tool_call_count", session.get("tool_call_count") or 0), + *([("lineage_session_ids", session["lineage_session_ids"])] if session.get("lineage_session_ids") else []), + ("format", fmt), ("exported_at", exported_iso), ("exporter", EXPORTER_VERSION), + ] + parts = [ + "\n".join(["---", *(_frontmatter_line(k, v) for k, v in fields), "---", ""]), + f"# {session.get('title') or session_id}\n", f"Session ID: `{session_id}`\n", + *([f"Source: `{session.get('source')}`\n"] if session.get("source") else []), + *([f"Working directory: `{session.get('cwd')}`\n"] if session.get("cwd") else []), + _render_messages(session), + f"{_VERIFICATION_HEADING}\n", + f"- Session id: `{session_id}`", + f"- Exported messages: `{message_count}`", + f"- Source DB message count at export: `{session.get('message_count', message_count)}`", + f"- Exported at: `{exported_iso}`", + f"- SHA256 of exported body: `{_SHA_PLACEHOLDER}`", + ] + return "\n".join(parts).rstrip() + "\n" + + +def _check_fmt(fmt: str) -> None: + if fmt not in {"md", "qmd"}: + raise ValueError("fmt must be 'md' or 'qmd'") + + +def render_session_markdown( + session: dict[str, Any], *, fmt: str = "md", include_verification: bool = True +) -> str: + """Render a SessionDB export dictionary as Markdown/QMD text.""" + _check_fmt(fmt) + body = _export_body_without_hash(session, fmt=fmt, exported_at=time.time()) + if not include_verification: + return body.split(f"\n{_VERIFICATION_HEADING}\n", 1)[0].rstrip() + "\n" + # The digest covers the body with the SHA line set to `pending`, which is what verify recomputes. + digest_body = body.replace(f"`{_SHA_PLACEHOLDER}`", "`pending`") + return body.replace(_SHA_PLACEHOLDER, hashlib.sha256(digest_body.encode("utf-8")).hexdigest()) + + +def safe_session_filename(session: dict[str, Any], *, fmt: str = "md") -> str: + """Return a deterministic, path-safe filename for a session export.""" + _check_fmt(fmt) + title = str(session.get("title") or "session") + slug = re.sub(r"[^A-Za-z0-9._-]+", "-", title).strip(".-_").lower() or "session" + return f"{_session_id(session)}-{slug[:60]}.{fmt}" + + +def file_sha256(path: Path | str) -> str: + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def verify_export_file(path: Path | str, session: dict[str, Any]) -> tuple[bool, str]: + if not Path(path).exists(): + return False, "file missing" + text = Path(path).read_text(encoding="utf-8") + match = _SHA_LINE_RE.search(text) + if not match: + return False, "sha256 marker missing" + digest_body = _SHA_LINE_RE.sub("- SHA256 of exported body: `pending`", text) + if hashlib.sha256(digest_body.encode("utf-8")).hexdigest() != match.group(1): + return False, "sha256 mismatch" + if f"- Exported messages: `{_message_count(session)}`" not in text: + return False, "message count mismatch" + if f"- Session id: `{_session_id(session)}`" not in text: + return False, "session id mismatch" + return True, "ok" + + +def redact_session_data(session: dict[str, Any]) -> dict[str, Any]: + """Return a copy of a session export dict with secrets redacted. + + Every message's content and tool-call arguments go through the force-mode redaction pass + (``agent.redact.redact_sensitive_text``) so credentials in tool output never land in exports. + """ + from agent.redact import redact_sensitive_text + + def _clean(value: Any) -> Any: + if isinstance(value, str): + return redact_sensitive_text(value, force=True) + if isinstance(value, list): + return [_clean(v) for v in value] + if isinstance(value, dict): + return {k: _clean(v) for k, v in value.items()} + return value + + return {**session, **{k: _clean(session[k]) for k in ("messages", "segments") if session.get(k) is not None}} + + +def _export_dir(output_dir: Path | str) -> Path: + out_dir = Path(output_dir).expanduser() + out_dir.mkdir(parents=True, exist_ok=True) + return out_dir + + +def write_session_markdown( + session: dict[str, Any], output_dir: Path | str, *, fmt: str = "md", force: bool = False +) -> Path: + """Write a Markdown/QMD export file and return its path.""" + path = _export_dir(output_dir) / safe_session_filename(session, fmt=fmt) + if path.exists() and not force: + raise FileExistsError(str(path)) + path.write_text(render_session_markdown(session, fmt=fmt), encoding="utf-8") + return path + + +def append_manifest_entry(output_dir: Path | str, session: dict[str, Any], path: Path | str, *, fmt: str) -> Path: + entry = { + "session_id": _session_id(session), + "lineage_session_ids": session.get("lineage_session_ids") or [_session_id(session)], + "path": str(Path(path)), + "format": fmt, + "message_count": _message_count(session), + "sha256": file_sha256(path), + "exported_at": time.time(), + } + manifest = _export_dir(output_dir) / "manifest.jsonl" + with manifest.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n") + return manifest diff --git a/hermes_cli/shared_session_attach.py b/hermes_cli/shared_session_attach.py new file mode 100644 index 0000000000000000000000000000000000000000..9282c08f7bd7437e954e310896a07446771e8d84 --- /dev/null +++ b/hermes_cli/shared_session_attach.py @@ -0,0 +1,91 @@ +"""Discover a cooperative local runtime without taking its session lease. + +The owner's handshake supplies the existing authenticated WebSocket URL. A +registry entry is discovery information, not authority to mint a credential. +""" +from __future__ import annotations + +import ipaddress +import json +from pathlib import Path +from urllib.parse import urlencode, urlsplit + +import httpx + +from hermes_constants import get_hermes_home +from hermes_cli.active_sessions import active_session_registry_snapshot, session_already_owned_message + + +def _local_origin(url: str, scheme: str) -> tuple[str, int]: + parts = urlsplit(url) + host = parts.hostname or "" + try: + local = ipaddress.ip_address(host).is_loopback + except ValueError: + local = False + if (parts.scheme != scheme or not local or parts.username is not None + or parts.password is not None or parts.fragment or not parts.port): + raise ValueError("Shared runtime endpoint must be an explicit loopback address and port.") + return host, parts.port + + +def discover_attach_url(session_id: str, *, registry_home: str | Path | None = None) -> str | None: + """Return a fenced authenticated URL, None for no owner, or refuse safely. + + This deliberately does not scan ports or read another profile. The runtime + must advertise ``metadata.shared_runtime_url`` and implement the local + ``/api/session-attach`` handshake. Unsupported owners keep their lease. + """ + home = Path(registry_home if registry_home is not None else get_hermes_home()).resolve() + owners = [entry for entry in active_session_registry_snapshot(home, strict=True) + if entry.get("session_id") == session_id] + if not owners: + return None + if len(owners) != 1: + raise ValueError("Session owner identity is ambiguous; no attachment was attempted.") + owner = owners[0] + endpoint = (owner.get("metadata") or {}).get("shared_runtime_url") + if not isinstance(endpoint, str) or not endpoint: + raise ValueError("The live owner does not advertise cooperative attachment. " + + session_already_owned_message(session_id, owner)) + origin = _local_origin(endpoint, "http") + parts = urlsplit(endpoint) + if parts.path not in ("", "/") or parts.query: + raise ValueError("Shared runtime endpoint must be an origin without a path or query.") + query = urlencode({"session_id": session_id, "lease_id": owner["lease_id"], + "profile_home": str(home)}) + try: + # Ignore proxy env and redirects: local discovery must stay on the + # advertised endpoint, including on machines with corporate proxies. + with httpx.Client(trust_env=False, follow_redirects=False, timeout=3.0) as client: + with client.stream("GET", endpoint.rstrip("/") + "/api/session-attach?" + query) as response: + response.raise_for_status() + body = bytearray() + for chunk in response.iter_bytes(): + body.extend(chunk) + if len(body) > 65536: + raise ValueError("Shared runtime handshake response is too large.") + reply = json.loads(body) + except (httpx.HTTPError, json.JSONDecodeError) as exc: + # Never include a remote body or authenticated URL in diagnostics. + raise ValueError("The live owner could not authorize cooperative attachment; " + "its lease was left intact.") from exc + if not isinstance(reply, dict) or any(reply.get(key) != value for key, value in { + "session_id": session_id, "lease_id": owner["lease_id"], "profile_home": str(home), + }.items()): + raise ValueError("Shared runtime handshake identity does not match the requested owner.") + websocket_url = reply.get("websocket_url") + if (not isinstance(websocket_url, str) or _local_origin(websocket_url, "ws") != origin + or urlsplit(websocket_url).path != "/api/ws"): + raise ValueError("Shared runtime handshake returned a different endpoint.") + return websocket_url + + +def configure_tui_attachment(env: dict[str, str], session_id: str | None, *, + registry_home: str | Path | None = None) -> None: + """Retain an explicit transport, otherwise attach a resumed owner's runtime.""" + if not session_id or env.get("HERMES_TUI_GATEWAY_URL", "").strip(): + return + url = discover_attach_url(session_id, registry_home=registry_home) + if url is not None: + env["HERMES_TUI_GATEWAY_URL"] = url diff --git a/hermes_cli/sizefmt.py b/hermes_cli/sizefmt.py new file mode 100644 index 0000000000000000000000000000000000000000..29dddcfa854315dd449e141d58714b4b814b33d8 --- /dev/null +++ b/hermes_cli/sizefmt.py @@ -0,0 +1,20 @@ +"""Small shared size-formatting helpers for CLI/agent output (sibling of ``hermes_cli.timefmt``: +a tiny purpose-named module lightweight consumers can import without the CLI surface).""" + +from __future__ import annotations + + +def format_bytes(n) -> str: + """1234567 -> '1.2 MB' (B/KB/MB/GB/TB; integer bytes, one decimal above). Returns ``"?"`` for + None or unparseable input so display call sites never raise.""" + try: + size = float(n) + except (TypeError, ValueError): + return "?" + if size < 1024: + return f"{int(size)} B" + for unit in ("KB", "MB", "GB"): + size /= 1024.0 + if size < 1024: + return f"{size:.1f} {unit}" + return f"{size / 1024.0:.1f} TB" diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..76b1cd2b79f8169710bcd0fe7b52b72e4a789512 --- /dev/null +++ b/hermes_cli/skin_engine.py @@ -0,0 +1,556 @@ +"""Hermes skin/theme engine — the theme SDK for every surface.""" + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from hermes_constants import get_hermes_home + +logger = logging.getLogger(__name__) + + +@dataclass +class SkinConfig: + """Complete skin configuration.""" + name: str + description: str = "" + colors: Dict[str, str] = field(default_factory=dict) + # Paired palettes for the opposite background polarity (mirrors the desktop app's + # colors/darkColors pairing): a light terminal prefers `light_colors` (falling back to + # `colors`), and vice versa for `dark_colors`. + light_colors: Dict[str, str] = field(default_factory=dict) + dark_colors: Dict[str, str] = field(default_factory=dict) + spinner: Dict[str, Any] = field(default_factory=dict) + branding: Dict[str, str] = field(default_factory=dict) + tool_prefix: str = "┊" + tool_emojis: Dict[str, str] = field(default_factory=dict) # per-tool emoji overrides + banner_logo: str = "" # Rich-markup ASCII art logo (replaces HERMES_AGENT_LOGO) + banner_hero: str = "" # Rich-markup hero art (replaces HERMES_CADUCEUS) + + def get_color(self, key: str, fallback: str = "") -> str: + return self.colors.get(key, fallback) + + def get_branding(self, key: str, fallback: str = "") -> str: + return self.branding.get(key, fallback) + + def get_spinner_wings(self) -> List[Tuple[str, str]]: + """Spinner wing pairs, or empty list if none.""" + return [(str(pair[0]), str(pair[1])) for pair in self.spinner.get("wings", []) + if isinstance(pair, (list, tuple)) and len(pair) == 2] + + +def _branding(who: str, symbol: str, goodbye: str, prompt: str = "", help_header: str = "") -> Dict[str, str]: + """Branding block for a " Agent" persona keyed by its glyph.""" + return { + "agent_name": f"{who} Agent", + "welcome": f"Welcome to {who} Agent! Type your message or /help for commands.", + "goodbye": goodbye, "response_label": f" {symbol} {who} ", "prompt_symbol": prompt or symbol, + "help_header": help_header or f"({symbol}) Available Commands"} + + +def _wings(*glyphs) -> List[List[str]]: + """Spinner wing pairs `⟪g` / `g⟫`; a (left, right) tuple gives asymmetric glyphs.""" + return [[f"⟪{g[0] if isinstance(g, tuple) else g}", f"{g[1] if isinstance(g, tuple) else g}⟫"] + for g in glyphs] + + +# Branding shared by every Hermes-named built-in (mono/daylight override help_header). +_HERMES_BRANDING: Dict[str, str] = _branding( + "Hermes", "☤", "Goodbye! ☤", prompt="❯", help_header="(^_^)? Available Commands") + +_BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { + "default": { + "name": "default", "description": "Classic Hermes — gold and kawaii", + # Dark-authored; values match the TUI's DARK_THEME so both render the same gold. + "colors": { + "banner_border": "#CD7F32", "banner_title": "#FFD700", "banner_accent": "#FFBF00", + "banner_dim": "#B8860B", "banner_text": "#FFF8DC", "ui_accent": "#FFBF00", + "ui_label": "#DAA520", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726", + "prompt": "#FFF8DC", "input_rule": "#CD7F32", "response_border": "#FFD700", + "status_bar_bg": "#1a1a2e", "status_bar_text": "#C0C0C0", + "status_bar_strong": "#FFD700", "status_bar_dim": "#8A7A4A", + "status_bar_good": "#8FBC8F", "status_bar_warn": "#FFD700", "status_bar_bad": "#FF8C00", + "status_bar_critical": "#FF6B6B", "session_label": "#DAA520", + "session_border": "#8B8682", "completion_menu_bg": "#1a1a2e", + "completion_menu_current_bg": "#333355", "selection_bg": "#3a3a55", + "shell_dollar": "#4dabf7", "voice_status_bg": "#1a1a2e"}, + # Light overlay (merged onto `colors`). Goldenrod ladder: on white the vivid + # #FFD700/#FFBF00 read as glare and WCAG-darkened mustard (#867000) as mud; the + # statusbar's goldenrod family (#B8860B/#DAA520) keeps the hue, tames saturation. + # Hierarchy on white: ink body 8.9:1 > fade 5.2 > label 3.7 > muted 3.3 > title 2.7 > + # headers 2.4. Fills (*_bg) flip the dark navy surfaces to light polarity. + "light_colors": { + "banner_title": "#C8961E", "banner_accent": "#D89B04", "banner_dim": "#B8860B", + "banner_text": "#5C4718", "ui_accent": "#D89B04", "ui_label": "#A97E10", + "ui_ok": "#2E7D32", "ui_error": "#C62828", "ui_warn": "#D97706", "prompt": "#5C4718", + "response_border": "#C8961E", "session_label": "#A97E10", "status_bar_text": "#6F6F6F", + "status_bar_strong": "#C8961E", "status_bar_dim": "#9A8A5A", + "status_bar_good": "#2E7D32", "status_bar_warn": "#C8961E", "status_bar_bad": "#C2410C", + "status_bar_critical": "#B91C1C", "shell_dollar": "#1E6FC0", + "completion_menu_bg": "#F5F5F5", "completion_menu_current_bg": "#E0D1BF", + "selection_bg": "#D4E4F7", "status_bar_bg": "#F5F5F5", "voice_status_bg": "#F5F5F5"}, + "spinner": {}, # empty = hardcoded defaults in display.py + "branding": _HERMES_BRANDING, + "tool_prefix": "┊"}, + "ares": { + "name": "ares", "description": "War-god theme — crimson and bronze", + "colors": { + "banner_border": "#A93333", "banner_title": "#C7A96B", "banner_accent": "#DD4A3A", + "banner_dim": "#905151", "banner_text": "#F1E6CF", "ui_accent": "#DD4A3A", + "ui_label": "#C7A96B", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726", + "prompt": "#F1E6CF", "input_rule": "#A93333", "response_border": "#C7A96B", + "status_bar_bg": "#2A1212", "status_bar_text": "#F1E6CF", + "status_bar_strong": "#C7A96B", "status_bar_dim": "#756054", + "status_bar_good": "#7BC96F", "status_bar_warn": "#C7A96B", "status_bar_bad": "#DD4A3A", + "status_bar_critical": "#EF5350", "session_label": "#C7A96B", + "session_border": "#6E584B", "completion_menu_bg": "#2A1212", + "completion_menu_current_bg": "#5C221D", "selection_bg": "#692620", + "shell_dollar": "#DD4A3A", "voice_status_bg": "#2A1212"}, + "spinner": { + "waiting_faces": ["(⚔)", "(⛨)", "(▲)", "(<>)", "(/)"], + "thinking_faces": ["(⚔)", "(⛨)", "(▲)", "(⌁)", "(<>)"], + "thinking_verbs": [ + "forging", "marching", "sizing the field", "holding the line", + "hammering plans", "tempering steel", "plotting impact", "raising the shield"], + "wings": _wings("⚔", "▲", ("╸", "╺"), "⛨")}, + "branding": _branding("Ares", "⚔", "Farewell, warrior! ⚔"), + "tool_prefix": "╎", + "banner_logo": """[bold #A3261F] █████╗ ██████╗ ███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/] +[bold #B73122]██╔══██╗██╔══██╗██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/] +[#C93C24]███████║██████╔╝█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/] +[#D84A28]██╔══██║██╔══██╗██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/] +[#E15A2D]██║ ██║██║ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/] +[#EB6C32]╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""", + "banner_hero": """[#9F1C1C]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤⣤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#9F1C1C]⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⠟⠻⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#C7A96B]⠀⠀⠀⠀⠀⠀⠀⣠⣾⡿⠋⠀⠀⠀⠙⢿⣷⣄⠀⠀⠀⠀⠀⠀⠀[/] +[#C7A96B]⠀⠀⠀⠀⠀⢀⣾⡿⠋⠀⠀⢠⡄⠀⠀⠙⢿⣷⡀⠀⠀⠀⠀⠀[/] +[#DD4A3A]⠀⠀⠀⠀⣰⣿⠟⠀⠀⠀⣰⣿⣿⣆⠀⠀⠀⠻⣿⣆⠀⠀⠀⠀[/] +[#DD4A3A]⠀⠀⠀⢰⣿⠏⠀⠀⢀⣾⡿⠉⢿⣷⡀⠀⠀⠹⣿⡆⠀⠀⠀[/] +[#9F1C1C]⠀⠀⠀⣿⡟⠀⠀⣠⣿⠟⠀⠀⠀⠻⣿⣄⠀⠀⢻⣿⠀⠀⠀[/] +[#9F1C1C]⠀⠀⠀⣿⡇⠀⠀⠙⠋⠀⠀⚔⠀⠀⠙⠋⠀⠀⢸⣿⠀⠀⠀[/] +[#6B1717]⠀⠀⠀⢿⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⡿⠀⠀⠀[/] +[#6B1717]⠀⠀⠀⠘⢿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⡿⠃⠀⠀⠀[/] +[#C7A96B]⠀⠀⠀⠀⠈⠻⣿⣷⣦⣤⣀⣀⣤⣤⣶⣿⠿⠋⠀⠀⠀⠀[/] +[#C7A96B]⠀⠀⠀⠀⠀⠀⠀⠉⠛⠿⠿⠿⠿⠛⠉⠀⠀⠀⠀⠀⠀⠀[/] +[#DD4A3A]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⚔⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[dim #6B1717]⠀⠀⠀⠀⠀⠀⠀⠀war god online⠀⠀⠀⠀⠀⠀⠀⠀[/]""", + }, + "mono": { + "name": "mono", "description": "Monochrome — clean grayscale", + "colors": { + "banner_border": "#5E5E5E", "banner_title": "#e6edf3", "banner_accent": "#aaaaaa", + "banner_dim": "#606060", "banner_text": "#c9d1d9", "ui_accent": "#aaaaaa", + "ui_label": "#888888", "ui_ok": "#888888", "ui_error": "#cccccc", "ui_warn": "#999999", + "prompt": "#c9d1d9", "input_rule": "#606060", "response_border": "#aaaaaa", + "status_bar_bg": "#1F1F1F", "status_bar_text": "#C9D1D9", + "status_bar_strong": "#E6EDF3", "status_bar_dim": "#777777", + "status_bar_good": "#B5B5B5", "status_bar_warn": "#AAAAAA", "status_bar_bad": "#D0D0D0", + "status_bar_critical": "#F0F0F0", "session_label": "#888888", + "session_border": "#5E5E5E", "completion_menu_bg": "#1F1F1F", + "completion_menu_current_bg": "#464646", "selection_bg": "#505050", + "shell_dollar": "#aaaaaa", "voice_status_bg": "#1F1F1F"}, + "spinner": {}, + "branding": {**_HERMES_BRANDING, "help_header": "[?] Available Commands"}, + "tool_prefix": "┊"}, + "slate": { + "name": "slate", "description": "Cool blue — developer-focused", + "colors": { + "banner_border": "#4169e1", "banner_title": "#7eb8f6", "banner_accent": "#8EA8FF", + "banner_dim": "#545E6B", "banner_text": "#c9d1d9", "ui_accent": "#7eb8f6", + "ui_label": "#8EA8FF", "ui_ok": "#63D0A6", "ui_error": "#F7A072", "ui_warn": "#e6a855", + "prompt": "#c9d1d9", "input_rule": "#4169e1", "response_border": "#7eb8f6", + "status_bar_bg": "#151C2F", "status_bar_text": "#C9D1D9", + "status_bar_strong": "#7EB8F6", "status_bar_dim": "#5D6672", + "status_bar_good": "#63D0A6", "status_bar_warn": "#E6A855", "status_bar_bad": "#F7A072", + "status_bar_critical": "#FF7A7A", "session_label": "#7eb8f6", + "session_border": "#545E6B", "completion_menu_bg": "#151C2F", + "completion_menu_current_bg": "#324867", "selection_bg": "#3A5375", + "shell_dollar": "#7eb8f6", "voice_status_bg": "#151C2F"}, + "spinner": {}, "branding": _HERMES_BRANDING, "tool_prefix": "┊"}, + "daylight": { + "name": "daylight", + "description": "Light theme for bright terminals with dark text and cool blue accents", + "colors": { + "banner_border": "#2563EB", "banner_title": "#0F172A", "banner_accent": "#1D4ED8", + "banner_dim": "#475569", "banner_text": "#111827", "ui_accent": "#2563EB", + "ui_label": "#0F766E", "ui_ok": "#15803D", "ui_error": "#B91C1C", "ui_warn": "#B45309", + "prompt": "#111827", "input_rule": "#6E94BE", "response_border": "#2563EB", + "status_bar_bg": "#E5EDF8", "status_bar_text": "#111827", + "status_bar_strong": "#2563EB", "status_bar_dim": "#838890", + "status_bar_good": "#15803D", "status_bar_warn": "#B45309", "status_bar_bad": "#B45309", + "status_bar_critical": "#B91C1C", "session_label": "#1D4ED8", + "session_border": "#64748B", "completion_menu_bg": "#F8FAFC", + "completion_menu_current_bg": "#DBEAFE", "completion_menu_meta_bg": "#EEF2FF", + "completion_menu_meta_current_bg": "#BFDBFE", "selection_bg": "#D3E0FB", + "shell_dollar": "#2563EB", "voice_status_bg": "#E5EDF8"}, + "spinner": {}, + "branding": {**_HERMES_BRANDING, "help_header": "[?] Available Commands"}, + "tool_prefix": "│"}, + "warm-lightmode": { + "name": "warm-lightmode", + "description": "Warm light mode — dark brown/gold text for light terminal backgrounds", + "colors": { + "banner_border": "#8B6914", "banner_title": "#5C3D11", "banner_accent": "#8B4513", + "banner_dim": "#8B7355", "banner_text": "#2C1810", "ui_accent": "#8B4513", + "ui_label": "#5C3D11", "ui_ok": "#2E7D32", "ui_error": "#C62828", "ui_warn": "#E65100", + "prompt": "#2C1810", "input_rule": "#8B6914", "response_border": "#8B6914", + "status_bar_bg": "#F5F0E8", "status_bar_text": "#2C1810", + "status_bar_strong": "#8B4513", "status_bar_dim": "#8A8F98", + "status_bar_good": "#2E7D32", "status_bar_warn": "#E65100", "status_bar_bad": "#DA4D00", + "status_bar_critical": "#C62828", "session_label": "#5C3D11", + "session_border": "#A0845C", "completion_menu_bg": "#F5EFE0", + "completion_menu_current_bg": "#E8DCC8", "completion_menu_meta_bg": "#F0E8D8", + "completion_menu_meta_current_bg": "#DFCFB0", "selection_bg": "#E8DAD0", + "shell_dollar": "#8B4513", "voice_status_bg": "#F5F0E8"}, + "spinner": {}, "branding": _HERMES_BRANDING, "tool_prefix": "┊"}, + "poseidon": { + "name": "poseidon", "description": "Ocean-god theme — deep blue and seafoam", + "colors": { + "banner_border": "#2A6FB9", "banner_title": "#A9DFFF", "banner_accent": "#5DB8F5", + "banner_dim": "#44638F", "banner_text": "#EAF7FF", "ui_accent": "#5DB8F5", + "ui_label": "#A9DFFF", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726", + "prompt": "#EAF7FF", "input_rule": "#2A6FB9", "response_border": "#5DB8F5", + "status_bar_bg": "#0F2440", "status_bar_text": "#EAF7FF", + "status_bar_strong": "#A9DFFF", "status_bar_dim": "#52708A", + "status_bar_good": "#6ED7B0", "status_bar_warn": "#5DB8F5", "status_bar_bad": "#3576BC", + "status_bar_critical": "#D94F4F", "session_label": "#A9DFFF", + "session_border": "#496884", "completion_menu_bg": "#0F2440", + "completion_menu_current_bg": "#254D73", "selection_bg": "#2A587F", + "shell_dollar": "#5DB8F5", "voice_status_bg": "#0F2440"}, + "spinner": { + "waiting_faces": ["(≈)", "(Ψ)", "(∿)", "(◌)", "(◠)"], + "thinking_faces": ["(Ψ)", "(∿)", "(≈)", "(⌁)", "(◌)"], + "thinking_verbs": [ + "charting currents", "sounding the depth", "reading foam lines", + "steering the trident", "tracking undertow", "plotting sea lanes", + "calling the swell", "measuring pressure"], + "wings": _wings("≈", "Ψ", "∿", "◌")}, + "branding": _branding("Poseidon", "Ψ", "Fair winds! Ψ"), + "tool_prefix": "│", + "banner_logo": """[bold #B8E8FF]██████╗ ██████╗ ███████╗███████╗██╗██████╗ ██████╗ ███╗ ██╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/] +[bold #97D6FF]██╔══██╗██╔═══██╗██╔════╝██╔════╝██║██╔══██╗██╔═══██╗████╗ ██║ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/] +[#75C1F6]██████╔╝██║ ██║███████╗█████╗ ██║██║ ██║██║ ██║██╔██╗ ██║█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/] +[#4FA2E0]██╔═══╝ ██║ ██║╚════██║██╔══╝ ██║██║ ██║██║ ██║██║╚██╗██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/] +[#2E7CC7]██║ ╚██████╔╝███████║███████╗██║██████╔╝╚██████╔╝██║ ╚████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/] +[#1B4F95]╚═╝ ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""", + "banner_hero": """[#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⢠⣿⠏⠀Ψ⠀⠹⣿⡄⠀⠀⠀⠀⠀⠀⠀[/] +[#A9DFFF]⠀⠀⠀⠀⠀⠀⠀⣿⡟⠀⠀⠀⠀⠀⢻⣿⠀⠀⠀⠀⠀⠀⠀[/] +[#A9DFFF]⠀⠀⠀≈≈≈≈≈⣿⡇⠀⠀⠀⠀⠀⢸⣿≈≈≈≈≈⠀⠀⠀[/] +[#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⣿⡇⠀⠀⠀⠀⠀⢸⣿⠀⠀⠀⠀⠀⠀⠀[/] +[#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⢿⣧⠀⠀⠀⠀⠀⣼⡿⠀⠀⠀⠀⠀⠀⠀[/] +[#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⠘⢿⣷⣄⣀⣠⣾⡿⠃⠀⠀⠀⠀⠀⠀⠀[/] +[#153C73]⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣿⣿⡿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#153C73]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#5DB8F5]⠀⠀⠀⠀⠀≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈⠀⠀⠀⠀⠀[/] +[#A9DFFF]⠀⠀⠀⠀⠀⠀≈≈≈≈≈≈≈≈≈≈≈≈≈⠀⠀⠀⠀⠀⠀[/] +[dim #153C73]⠀⠀⠀⠀⠀⠀⠀deep waters hold⠀⠀⠀⠀⠀⠀⠀[/]""", + }, + "sisyphus": { + "name": "sisyphus", "description": "Sisyphean theme — austere grayscale with persistence", + "colors": { + "banner_border": "#B7B7B7", "banner_title": "#F5F5F5", "banner_accent": "#E7E7E7", + "banner_dim": "#5C5C5C", "banner_text": "#D3D3D3", "ui_accent": "#E7E7E7", + "ui_label": "#D3D3D3", "ui_ok": "#919191", "ui_error": "#E7E7E7", "ui_warn": "#B7B7B7", + "prompt": "#F5F5F5", "input_rule": "#656565", "response_border": "#B7B7B7", + "status_bar_bg": "#202020", "status_bar_text": "#D3D3D3", + "status_bar_strong": "#F5F5F5", "status_bar_dim": "#6D6D6D", + "status_bar_good": "#B7B7B7", "status_bar_warn": "#D3D3D3", "status_bar_bad": "#E7E7E7", + "status_bar_critical": "#F5F5F5", "session_label": "#919191", + "session_border": "#656565", "completion_menu_bg": "#202020", + "completion_menu_current_bg": "#585858", "selection_bg": "#666666", + "shell_dollar": "#E7E7E7", "voice_status_bg": "#202020"}, + "spinner": { + "waiting_faces": ["(◉)", "(◌)", "(◬)", "(⬤)", "(::)"], + "thinking_faces": ["(◉)", "(◬)", "(◌)", "(○)", "(●)"], + "thinking_verbs": [ + "finding traction", "measuring the grade", "resetting the boulder", + "counting the ascent", "testing leverage", "setting the shoulder", + "pushing uphill", "enduring the loop"], + "wings": _wings("◉", "◬", "◌", "⬤")}, + "branding": _branding("Sisyphus", "◉", "The boulder waits. ◉"), + "tool_prefix": "│", + "banner_logo": """[bold #F5F5F5]███████╗██╗███████╗██╗ ██╗██████╗ ██╗ ██╗██╗ ██╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/] +[bold #E7E7E7]██╔════╝██║██╔════╝╚██╗ ██╔╝██╔══██╗██║ ██║██║ ██║██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/] +[#D7D7D7]███████╗██║███████╗ ╚████╔╝ ██████╔╝███████║██║ ██║███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/] +[#BFBFBF]╚════██║██║╚════██║ ╚██╔╝ ██╔═══╝ ██╔══██║██║ ██║╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/] +[#8F8F8F]███████║██║███████║ ██║ ██║ ██║ ██║╚██████╔╝███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/] +[#626262]╚══════╝╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""", + "banner_hero": """[#B7B7B7]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#D3D3D3]⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#E7E7E7]⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀[/] +[#F5F5F5]⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀[/] +[#E7E7E7]⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀[/] +[#D3D3D3]⠀⠀⠀⠀⠀⠀⠘⢿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀[/] +[#B7B7B7]⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⣿⠿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#919191]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#656565]⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#656565]⠀⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#4A4A4A]⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#4A4A4A]⠀⠀⠀⠀⠀⣀⣴⣿⣿⣿⣿⣿⣿⣦⣀⠀⠀⠀⠀⠀⠀[/] +[#656565]⠀⠀⠀━━━━━━━━━━━━━━━━━━━━━━━⠀⠀⠀[/] +[dim #4A4A4A]⠀⠀⠀⠀⠀⠀⠀⠀⠀the boulder⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]""", + }, + "charizard": { + "name": "charizard", "description": "Volcanic theme — burnt orange and ember", + "colors": { + "banner_border": "#C75B1D", "banner_title": "#FFD39A", "banner_accent": "#F29C38", + "banner_dim": "#C58A45", "banner_text": "#FFF0D4", "ui_accent": "#F29C38", + "ui_label": "#FFD39A", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726", + "prompt": "#FFF0D4", "input_rule": "#C75B1D", "response_border": "#F29C38", + "status_bar_bg": "#2B160E", "status_bar_text": "#FFF0D4", + "status_bar_strong": "#FFD39A", "status_bar_dim": "#826144", + "status_bar_good": "#6BCB77", "status_bar_warn": "#F29C38", "status_bar_bad": "#E2832B", + "status_bar_critical": "#EF5350", "session_label": "#FFD39A", + "session_border": "#7B593A", "completion_menu_bg": "#0B0503", + "completion_menu_current_bg": "#4A1B07", "completion_menu_meta_bg": "#120806", + "completion_menu_meta_current_bg": "#5A260D", "selection_bg": "#5A260D", + "shell_dollar": "#F29C38", "voice_status_bg": "#2B160E"}, + "spinner": { + "waiting_faces": ["(✦)", "(▲)", "(◇)", "(<>)", "(🔥)"], + "thinking_faces": ["(✦)", "(▲)", "(◇)", "(⌁)", "(🔥)"], + "thinking_verbs": [ + "banking into the draft", "measuring burn", "reading the updraft", + "tracking ember fall", "setting wing angle", "holding the flame core", + "plotting a hot landing", "coiling for lift"], + "wings": _wings("✦", "▲", "◌", "◇")}, + "branding": _branding("Charizard", "✦", "Flame out! ✦"), + "tool_prefix": "│", + "banner_logo": """[bold #FFF0D4] ██████╗██╗ ██╗ █████╗ ██████╗ ██╗███████╗ █████╗ ██████╗ ██████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/] +[bold #FFD39A]██╔════╝██║ ██║██╔══██╗██╔══██╗██║╚══███╔╝██╔══██╗██╔══██╗██╔══██╗ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/] +[#F29C38]██║ ███████║███████║██████╔╝██║ ███╔╝ ███████║██████╔╝██║ ██║█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/] +[#E2832B]██║ ██╔══██║██╔══██║██╔══██╗██║ ███╔╝ ██╔══██║██╔══██╗██║ ██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/] +[#C75B1D]╚██████╗██║ ██║██║ ██║██║ ██║██║███████╗██║ ██║██║ ██║██████╔╝ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/] +[#7A3511] ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""", + "banner_hero": """[#FFD39A]⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⠶⠶⠶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#F29C38]⠀⠀⠀⠀⠀⠀⣴⠟⠁⠀⠀⠀⠀⠈⠻⣦⠀⠀⠀⠀⠀⠀[/] +[#F29C38]⠀⠀⠀⠀⠀⣼⠏⠀⠀⠀✦⠀⠀⠀⠀⠹⣧⠀⠀⠀⠀⠀[/] +[#E2832B]⠀⠀⠀⠀⢰⡟⠀⠀⣀⣤⣤⣤⣀⠀⠀⠀⢻⡆⠀⠀⠀⠀[/] +[#E2832B]⠀⠀⣠⡾⠛⠁⣠⣾⠟⠉⠀⠉⠻⣷⣄⠀⠈⠛⢷⣄⠀⠀[/] +[#C75B1D]⠀⣼⠟⠀⢀⣾⠟⠁⠀⠀⠀⠀⠀⠈⠻⣷⡀⠀⠻⣧⠀[/] +[#C75B1D]⢸⡟⠀⠀⣿⡟⠀⠀⠀🔥⠀⠀⠀⠀⢻⣿⠀⠀⢻⡇[/] +[#7A3511]⠀⠻⣦⡀⠘⢿⣧⡀⠀⠀⠀⠀⠀⢀⣼⡿⠃⢀⣴⠟⠀[/] +[#7A3511]⠀⠀⠈⠻⣦⣀⠙⢿⣷⣤⣤⣤⣾⡿⠋⣀⣴⠟⠁⠀⠀[/] +[#C75B1D]⠀⠀⠀⠀⠈⠙⠛⠶⠤⠭⠭⠤⠶⠛⠋⠁⠀⠀⠀⠀[/] +[#F29C38]⠀⠀⠀⠀⠀⠀⠀⠀⣰⡿⢿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#F29C38]⠀⠀⠀⠀⠀⠀⠀⣼⡟⠀⠀⢻⣧⠀⠀⠀⠀⠀⠀⠀⠀[/] +[dim #7A3511]⠀⠀⠀⠀⠀⠀⠀tail flame lit⠀⠀⠀⠀⠀⠀⠀⠀[/]""", + }} + +_active_skin: Optional[SkinConfig] = None +_active_skin_name: str = "default" +# Routed multiplex profiles: (name, skin) per home key. ``display.skin`` and ``/skins/*.yaml`` +# are per profile, and the relay display name / TUI skin payload are read under each profile's +# override — one module slot would be last-writer-wins across profiles. Unscoped keeps the module slot. +_active_skin_by_home: Dict[str, Tuple[str, SkinConfig]] = {} + + +def _routed_home_key() -> Optional[str]: + from hermes_constants import get_hermes_home_override, hermes_home_key + return None if get_hermes_home_override() is None else hermes_home_key() + + +def _profile_config() -> dict: + try: + from hermes_cli.config import load_config_readonly + return load_config_readonly() or {} + except Exception: + return {} + + +def _skins_dir() -> Path: + return get_hermes_home() / "skins" + + +def _load_skin_from_yaml(path: Path) -> Optional[Dict[str, Any]]: + """Load a skin definition from a YAML file; None on any failure.""" + try: + import yaml + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + if isinstance(data, dict) and "name" in data: + return data + except Exception as e: + logger.debug("Failed to load skin from %s: %s", path, e) + return None + + +def _build_skin_config(data: Dict[str, Any]) -> SkinConfig: + """Build a SkinConfig from a raw dict (built-in or loaded from YAML).""" + default = _BUILTIN_SKINS["default"] + skin_name = str(data.get("name", "unknown")) + + def section(key: str) -> Dict[str, Any]: + value = data.get(key) + if isinstance(value, dict): + return value + if value is not None: + logger.warning("Skin '%s' has invalid '%s' section type (%s); ignoring section", + skin_name, key, type(value).__name__) + return {} + + def merged(key: str) -> Dict[str, Any]: + return {**default.get(key, {}), **section(key)} + # Paired palettes are NOT merged over the default skin's blocks: an empty block means + # "no hand-tuned variant for that polarity" and consumers (the TUI) fall back to `colors` + # + automatic adaptation, which beats the default's gold light palette under a crimson skin. + return SkinConfig( + name=skin_name, description=data.get("description", ""), colors=merged("colors"), + light_colors=section("light_colors"), dark_colors=section("dark_colors"), + spinner=merged("spinner"), branding=merged("branding"), + tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "┊")), + tool_emojis=section("tool_emojis"), banner_logo=data.get("banner_logo", ""), + banner_hero=data.get("banner_hero", "")) + + +def list_skins() -> List[Dict[str, str]]: + """List all available skins (built-in + user-installed); user skins never shadow built-ins.""" + result = [{"name": name, "description": data.get("description", ""), "source": "builtin"} + for name, data in _BUILTIN_SKINS.items()] + skins_path = _skins_dir() + for f in sorted(skins_path.glob("*.yaml")) if skins_path.is_dir() else (): + data = _load_skin_from_yaml(f) + if data and not any(s["name"] == data.get("name", f.stem) for s in result): + result.append({"name": data.get("name", f.stem), "description": data.get("description", ""), + "source": "user"}) + return result + + +def load_skin(name: str) -> SkinConfig: + """Load a skin by name: user skins first, then built-in, then default.""" + user_file = _skins_dir() / f"{name}.yaml" + data = _load_skin_from_yaml(user_file) if user_file.is_file() else None + if not data and name not in _BUILTIN_SKINS: + logger.warning("Skin '%s' not found, using default", name) + return _build_skin_config(data or _BUILTIN_SKINS.get(name) or _BUILTIN_SKINS["default"]) + + +def get_active_skin() -> SkinConfig: + """Currently active skin config (cached).""" + global _active_skin + home_key = _routed_home_key() + if home_key is not None: + entry = _active_skin_by_home.get(home_key) + if entry is None: + # Cold routed profile: its own ``display.skin`` (nobody ran init_skin_from_config for it). + init_skin_from_config(_profile_config()) + entry = _active_skin_by_home[home_key] + return entry[1] + if _active_skin is None: + _active_skin = load_skin(_active_skin_name) + return _active_skin + + +def set_active_skin(name: str) -> SkinConfig: + """Switch the active skin. Returns the new SkinConfig.""" + global _active_skin, _active_skin_name + skin = load_skin(name) + home_key = _routed_home_key() + if home_key is not None: + _active_skin_by_home[home_key] = (name, skin) + return skin + _active_skin_name = name + _active_skin = skin + return _active_skin + + +def get_active_skin_name() -> str: + home_key = _routed_home_key() + if home_key is not None: + entry = _active_skin_by_home.get(home_key) + return entry[0] if entry else "default" + return _active_skin_name + + +def init_skin_from_config(config: dict) -> None: + """Initialize the active skin from CLI config at startup.""" + display = config.get("display") or {} + skin_name = display.get("skin", "default") if isinstance(display, dict) else "default" + set_active_skin(skin_name.strip() if isinstance(skin_name, str) and skin_name.strip() else "default") + + +def _active_branding(key: str, fallback: str) -> str: + try: + return get_active_skin().get_branding(key, fallback) + except Exception: + return fallback + + +def get_active_prompt_symbol(fallback: str = "❯") -> str: + """Interactive prompt symbol (skins store a bare token) plus a single trailing space.""" + cleaned = (_active_branding("prompt_symbol", fallback) or fallback).strip() + return f"{cleaned or fallback.strip()} " + + +def get_active_help_header(fallback: str = "(^_^)? Available Commands") -> str: + return _active_branding("help_header", fallback) + + +def get_active_goodbye(fallback: str = "Goodbye! ☤") -> str: + return _active_branding("goodbye", fallback) + + +# Palette resolution order for prompt_toolkit styles: (name, skin color key, fallback). A +# fallback starting with "@" names an earlier entry (so a missing key inherits its remapped value). +_STYLE_PALETTE = ( + ("prompt", "prompt", ""), ("input_rule", "input_rule", "#CD7F32"), + ("title", "banner_title", "#FFD700"), ("text", "banner_text", "#FFF8DC"), + ("dim", "banner_dim", "#555555"), ("label", "ui_label", "@title"), ("warn", "ui_warn", "#FF8C00"), + ("error", "ui_error", "#FF6B6B"), ("status_bg", "status_bar_bg", "#1a1a2e"), + ("status_text", "status_bar_text", "@text"), ("status_strong", "status_bar_strong", "@title"), + ("status_dim", "status_bar_dim", "@dim"), ("ok", "ui_ok", "#8FBC8F"), + ("status_good", "status_bar_good", "@ok"), ("status_warn", "status_bar_warn", "@warn"), + ("accent", "banner_accent", "@warn"), ("status_bad", "status_bar_bad", "@accent"), + ("status_critical", "status_bar_critical", "@error"), ("voice_bg", "voice_status_bg", "@status_bg"), + ("menu_bg", "completion_menu_bg", "#1a1a2e"), ("menu_current_bg", "completion_menu_current_bg", "#333355"), + ("menu_meta_bg", "completion_menu_meta_bg", "@menu_bg"), + ("menu_meta_current_bg", "completion_menu_meta_current_bg", "@menu_current_bg")) + +# prompt_toolkit style class -> format template over the resolved palette names. +_STYLE_TEMPLATES = { + "input-area": "", # terminal default fg/bg — `prompt` styles the symbol, NOT typed text + "placeholder": "{dim} italic", "prompt": "{prompt}", "prompt-working": "{dim} italic", + "hint": "{dim} italic", + "status-bar": "bg:{status_bg} {status_text}", "status-bar-strong": "bg:{status_bg} {status_strong} bold", + "status-bar-session-title": "bg:{badge_bg} {badge_fg} bold", + "status-bar-dim": "bg:{status_bg} {status_dim}", "status-bar-good": "bg:{status_bg} {status_good} bold", + "status-bar-warn": "bg:{status_bg} {status_warn} bold", "status-bar-bad": "bg:{status_bg} {status_bad} bold", + "status-bar-critical": "bg:{status_bg} {status_critical} bold", + "subagent-dock": "bg:{status_bg} {status_text}", + "subagent-dock.heading": "bg:{status_bg} {status_strong} bold", + "subagent-dock.selected": "bg:{menu_current_bg} {text} bold", + "input-rule": "{input_rule}", "image-badge": "{label} bold", + "completion-menu": "bg:{menu_bg} {text}", "completion-menu.completion": "bg:{menu_bg} {text}", + "completion-menu.completion.current": "bg:{menu_current_bg} {title}", + "completion-menu.meta.completion": "bg:{menu_meta_bg} {dim}", + "completion-menu.meta.completion.current": "bg:{menu_meta_current_bg} {label}", + "clarify-border": "{input_rule}", "clarify-title": "{title} bold", "clarify-question": "{text} bold", + "clarify-choice": "{dim}", "clarify-selected": "{title} bold", "clarify-active-other": "{title} italic", + "clarify-countdown": "{input_rule}", + "sudo-prompt": "{error} bold", "sudo-border": "{input_rule}", "sudo-title": "{error} bold", + "sudo-text": "{text}", + "approval-border": "{input_rule}", "approval-title": "{warn} bold", "approval-desc": "{text} bold", + "approval-cmd": "{dim} italic", "approval-choice": "{dim}", "approval-selected": "{title} bold", + "voice-status": "bg:{voice_bg} {label}", "voice-status-recording": "bg:{voice_bg} {error} bold"} + + +def get_prompt_toolkit_style_overrides() -> Dict[str, str]: + """Return prompt_toolkit style overrides derived from the active skin.""" + try: + skin = get_active_skin() + except Exception: + return {} + # `prompt` is unset by default so typed text inherits the terminal's foreground (readable + # on light and dark schemes); skins opt into a colored prompt symbol via `prompt` in YAML. + # Every read goes through skin.get_color (cli.py wraps it for light-mode remapping). + palette: Dict[str, str] = {} + for name, key, fallback in _STYLE_PALETTE: + palette[name] = skin.get_color(key, palette[fallback[1:]] if fallback.startswith("@") else fallback) + # This badge paints both sides; foreground-only light remapping destroys its contrast. + palette["badge_bg"] = skin.colors.get( + "status_bar_strong", skin.colors.get("banner_title", "#FFD700")) + palette["badge_fg"] = skin.colors.get("status_bar_bg", "#1a1a2e") + return {cls: tpl.format(**palette) for cls, tpl in _STYLE_TEMPLATES.items()} diff --git a/hermes_cli/slack_cli.py b/hermes_cli/slack_cli.py new file mode 100644 index 0000000000000000000000000000000000000000..fe52b4ddb59ffdcb43fdd20f2005b08c49d6924f --- /dev/null +++ b/hermes_cli/slack_cli.py @@ -0,0 +1,155 @@ +"""``hermes slack manifest`` — generate the Slack app manifest JSON that registers every gateway +command as a native Slack slash (``/btw``, ``/stop``, ``/model``, …).""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +SLACK_LONG_DESCRIPTION_MIN_CHARACTERS = 175 +SLACK_LONG_DESCRIPTION_MAX_CHARACTERS = 4000 + + +def _build_full_manifest( + bot_name: str, bot_description: str, include_assistant: bool = True, + messaging_experience: str | None = None, long_description: str | None = None) -> dict: + """Build a full Slack manifest: display info + slash list from ``COMMAND_REGISTRY``. + + Other sections (OAuth scopes, socket mode) are sensible Hermes defaults, tweakable in the Slack + UI after pasting. + """ + from hermes_cli.commands_platforms import slack_app_manifest + if messaging_experience is None: + messaging_experience = "assistant" if include_assistant else "none" + messaging_experience = str(messaging_experience).strip().lower() + if messaging_experience not in {"assistant", "agent", "none"}: + raise ValueError("messaging_experience must be one of: assistant, agent, none") + + features = { + "app_home": { + "home_tab_enabled": False, + "messages_tab_enabled": True, + "messages_tab_read_only_enabled": False}, + "bot_user": {"display_name": bot_name[:80], "always_online": True}, + "slash_commands": slack_app_manifest()["features"]["slash_commands"]} + + bot_scopes = [ + "app_mentions:read", "channels:history", "channels:read", "chat:write", "commands", + "files:read", "files:write", "groups:history", "groups:read", "im:history", "im:read", + "im:write", "mpim:history", "mpim:read", "reactions:read", "users:read"] + bot_events = [ + "app_mention", "message.channels", "message.groups", "message.im", "message.mpim", + "reaction_added", "reaction_removed"] + + if messaging_experience == "assistant": + features["assistant_view"] = { + "assistant_description": "Chat with Hermes in threads and DMs."} + bot_scopes.append("assistant:write") + bot_events.extend(["assistant_thread_context_changed", "assistant_thread_started"]) + elif messaging_experience == "agent": + features["agent_view"] = {"agent_description": "Chat with Hermes in Slack Messages."} + bot_scopes.append("assistant:write") + # Slack includes current viewing context in Agent DM events only after this subscription + # is enabled; the adapter uses it to preserve the referred channel across the agent turn. + bot_events.extend(["app_context_changed", "app_home_opened"]) + + bot_scopes.sort() + bot_events.sort() + + display_information = { + "name": bot_name[:35], + "description": (bot_description or "Your Hermes agent on Slack")[:140], + "background_color": "#1a1a2e"} + if long_description is not None: + display_information["long_description"] = long_description + + return { + "_metadata": {"major_version": 1, "minor_version": 1}, + "display_information": display_information, + "features": features, + "oauth_config": {"scopes": {"bot": bot_scopes}}, + "settings": { + "event_subscriptions": {"bot_events": bot_events}, + "interactivity": {"is_enabled": True}, + "org_deploy_enabled": False, + "socket_mode_enabled": True, + "token_rotation_enabled": False}} + + +def slack_manifest_command(args) -> int: + """Print or write a Slack app manifest JSON (flags documented in ``hermes_cli/main.py``).""" + name = getattr(args, "name", None) or "Hermes" + description = getattr(args, "description", None) or "Your Hermes agent on Slack" + long_description = getattr(args, "long_description", None) + long_description_file = getattr(args, "long_description_file", None) + slashes_only = getattr(args, "slashes_only", False) + + def fail(msg: str) -> int: + print(f"hermes slack manifest: {msg}", file=sys.stderr) + return 2 + + if slashes_only and (long_description is not None or long_description_file is not None): + return fail("long description options cannot be used with --slashes-only") + if long_description_file is not None: + source_arg = str(long_description_file) + try: + with Path(source_arg).expanduser().open("r", encoding="utf-8", newline="") as handle: + long_description = handle.read() + except (OSError, UnicodeError, RuntimeError) as exc: + return fail(f"cannot read long description from {source_arg}: {exc}") + if long_description is not None: + n = len(long_description) + if n < SLACK_LONG_DESCRIPTION_MIN_CHARACTERS: + return fail(f"long description must be at least " + f"{SLACK_LONG_DESCRIPTION_MIN_CHARACTERS} characters (got {n})") + if n > SLACK_LONG_DESCRIPTION_MAX_CHARACTERS: + return fail(f"long description must be at most " + f"{SLACK_LONG_DESCRIPTION_MAX_CHARACTERS} characters (got {n})") + if getattr(args, "agent_view", False): + messaging_experience = "agent" + elif getattr(args, "no_assistant", False): + messaging_experience = "none" + else: + messaging_experience = "assistant" + + if slashes_only: + from hermes_cli.commands_platforms import slack_app_manifest + manifest = slack_app_manifest()["features"]["slash_commands"] + else: + manifest = _build_full_manifest( + name, description, messaging_experience=messaging_experience, + long_description=long_description) + payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n" + + write_target = getattr(args, "write", None) + if write_target is None: + sys.stdout.write(payload) + return 0 + if isinstance(write_target, bool) and write_target: # bare --write → default location + from hermes_constants import get_hermes_home + target = Path(get_hermes_home()) / "slack-manifest.json" + else: + target = Path(write_target).expanduser() + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(payload, encoding="utf-8") + print(f"Slack manifest written to: {target}", file=sys.stderr) + print( + "\nNext steps:\n" + " 1. Open https://api.slack.com/apps and pick your Hermes app\n" + " (or create a new one: Create New App → From an app manifest).\n" + f" 2. Features → App Manifest → paste the contents of\n" + f" {target}\n" + " 3. Save; Slack will prompt to reinstall the app if scopes or\n" + " slash commands changed.\n" + " 4. Make sure Socket Mode is enabled and you have a bot token\n" + " (xoxb-...) and app token (xapp-...) configured via\n" + " `hermes setup`.\n", file=sys.stderr) + return 0 + + +# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- +# Names external plugins imported from this module before the Sep 2026 decomposition. +# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does). +# The whole block is removed by reverting the commit that added it. +import os # noqa: F401,E402 +# ---- END PLUGIN-COMPAT ---- diff --git a/hermes_cli/status.py b/hermes_cli/status.py new file mode 100644 index 0000000000000000000000000000000000000000..c4a4c1038b5e591f096b0f164a7eafa47b8bac49 --- /dev/null +++ b/hermes_cli/status.py @@ -0,0 +1,397 @@ +"""Status command for hermes CLI.""" + +import json +import os +import sys +import time +import importlib.util +from pathlib import Path +from types import SimpleNamespace + +PROJECT_ROOT = Path(__file__).parent.parent.resolve() + +from hermes_cli.auth import AuthError, resolve_provider +from hermes_cli.colors import Colors, color +from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config +from hermes_cli.models import provider_label +from hermes_cli.runtime_provider import resolve_requested_provider +from hermes_cli.vercel_auth import describe_vercel_auth +from hermes_cli.status_auth import ( # renderers wired into _SECTIONS below + _render_api_keys, _render_apikey_providers, _render_auth_providers, _render_nous_gateway) +from hermes_constants import OPENROUTER_MODELS_URL +from hermes_constants import is_termux as _is_termux + + +def check_mark(ok: bool) -> str: + return color("✓", Colors.GREEN) if ok else color("✗", Colors.RED) + + +def _section(title: str) -> None: + """Print a blank line followed by a bold cyan ``◆`` section heading.""" + print() + print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD)) + + +def _row(name: str, ok: bool, text: str, width: int = 12, sep: str = " ") -> None: + """Print one ``name ✓/✗ text`` status row.""" + print(f" {name:<{width}}{sep}{check_mark(ok)} {text}") + + +def _detail(label: str, value) -> None: + """Print an indented ``label: value`` detail line under a status row.""" + _kv(label, value, " ", 12) + + +def _kv(label: str, value, indent: str = " ", width: int = 14) -> None: + """Print a `` Label: value`` line (label padded to the 14-col status layout).""" + print(f"{indent}{label:<{width}}{value}") + + +def _kv_flag(label: str, ok, on: str, off: str) -> None: + """``_kv`` with a ✓/✗ mark followed by ``on`` or ``off`` text.""" + _kv(label, f"{check_mark(bool(ok))} {on if ok else off}") + + +def _configured(ok) -> str: + return "configured" if ok else "not configured" + + +def _first_env_value(names) -> str: + """Return the first non-empty env value among ``names`` (a str or tuple of names).""" + return next((v for v in (get_env_value(n) or "" for n in ((names,) if isinstance(names, str) else names)) if v), "") + + +def _configured_model_label(config: dict) -> str: + """Return the configured default model from config.yaml.""" + model_cfg = config.get("model") + if isinstance(model_cfg, dict): + model_cfg = model_cfg.get("default") or model_cfg.get("name") or "" + return (model_cfg.strip() if isinstance(model_cfg, str) else "") or "(not set)" + + +def _effective_provider_label() -> str: + """Return the provider label matching current CLI runtime resolution.""" + requested = resolve_requested_provider() + try: + effective = resolve_provider(requested) + except AuthError: + effective = requested or "auto" + + if effective == "openrouter": + # A custom endpoint may live in config.yaml (model.base_url, the canonical location) or + # the legacy OPENAI_BASE_URL env var; either way labeling it "OpenRouter" is misleading. + try: + model_cfg = load_config().get("model") + except Exception: + model_cfg = None + config_base_url = (model_cfg.get("base_url") or "").strip() if isinstance(model_cfg, dict) else "" + if config_base_url or get_env_value("OPENAI_BASE_URL"): + effective = "custom" + return provider_label(effective) + + +def _estop_status_line(): + """One-line pause banner for `hermes status`, or None when not paused.""" + try: + from agent.estop import get_state + except ImportError: + return None + state = get_state() + if state is None: + return None + reason = state.get("reason") + return f"⏸️ PAUSED (global emergency stop{f' — reason: {reason}' if reason else ''}; `hermes resume` to lift)" + + +# --- Data tables driving the per-section renderers ------------------------- + +# Simple env-driven terminal backends: (label, env var, default, empty-counts-as-unset). +_TERMINAL_ENV_ROWS = { + "ssh": (("SSH Host:", "TERMINAL_SSH_HOST", "(not set)", True), ("SSH User:", "TERMINAL_SSH_USER", "(not set)", True)), + "docker": (("Docker Image:", "TERMINAL_DOCKER_IMAGE", "python:3.11-slim", False),), + "daytona": (("Daytona Image:", "TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20", False),), +} + +_PLATFORMS = { # name -> (token env var, home-channel env var or None) + "Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"), + "Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"), "WhatsApp": ("WHATSAPP_ENABLED", None), + "Signal": ("SIGNAL_HTTP_URL", "SIGNAL_HOME_CHANNEL"), + "Slack": ("SLACK_BOT_TOKEN", None), "Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"), + "SMS": ("TWILIO_ACCOUNT_SID", "SMS_HOME_CHANNEL"), "DingTalk": ("DINGTALK_CLIENT_ID", None), + "Feishu": ("FEISHU_APP_ID", "FEISHU_HOME_CHANNEL"), "WeCom": ("WECOM_BOT_ID", "WECOM_HOME_CHANNEL"), + "WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None), "Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"), + "BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"), "QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"), + "Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL")} + +# Gateway manager label when the runtime snapshot is unavailable, keyed by platform. +_GATEWAY_FALLBACK = {"termux": ("unknown", "Termux / manual process"), "linux": ("unknown", "systemd/manual"), + "darwin": ("unknown", "launchd")} + + +def _banner(lines, *styles) -> None: + """Blank line, then each line in ``styles``.""" + print() + for line in lines: + print(color(line, *styles)) + + +def _render_header(ctx): + _banner(("┌─────────────────────────────────────────────────────────┐", + "│ ☤ Hermes Agent Status │", + "└─────────────────────────────────────────────────────────┘"), Colors.CYAN) + paused = _estop_status_line() + if paused: + _banner((paused,), Colors.YELLOW, Colors.BOLD) + + +def _render_environment(ctx): + _section("Environment") + _kv("Project:", PROJECT_ROOT) + _kv("Python:", sys.version.split()[0]) + _kv_flag(".env file:", get_env_path().exists(), "exists", "not found") + try: + ctx.config = load_config() + except Exception: + ctx.config = {} + _kv("Model:", _configured_model_label(ctx.config)) + _kv("Provider:", _effective_provider_label()) + + +def _render_terminal(ctx): + _section("Terminal Backend") + terminal_cfg = ctx.config.get("terminal", {}) if isinstance(ctx.config.get("terminal"), dict) else {} + terminal_env = os.getenv("TERMINAL_ENV", "") or terminal_cfg.get("backend", "local") + _kv("Backend:", terminal_env) + if terminal_env in _TERMINAL_ENV_ROWS: + for label, var, default, empty_is_unset in _TERMINAL_ENV_ROWS[terminal_env]: + value = (os.getenv(var, "") or default) if empty_is_unset else os.getenv(var, default) + print(f" {label:<13} {value}") + elif terminal_env == "vercel_sandbox": + persist = os.getenv("TERMINAL_CONTAINER_PERSISTENT") + persist_enabled = (bool(terminal_cfg.get("container_persistent", True)) if persist is None + else persist.lower() in {"1", "true", "yes", "on"}) + auth_status = describe_vercel_auth() + _kv("Runtime:", os.getenv('TERMINAL_VERCEL_RUNTIME') or terminal_cfg.get('vercel_runtime') or 'node24') + _kv_flag("SDK:", importlib.util.find_spec("vercel") is not None, "installed", + "missing (install: pip install 'hermes-agent[vercel]')") + _kv("Auth:", f"{check_mark(auth_status.ok)} {auth_status.label}") + for line in auth_status.detail_lines: + _kv("Auth detail:", line) + _kv("Persistence:", 'snapshot filesystem' if persist_enabled else 'ephemeral filesystem') + _kv("Processes:", "live processes do not survive cleanup, snapshots, or sandbox recreation") + else: + # Plugin-registered terminal backends: show availability via the provider's doctor rows + # (fail-soft — never break `hermes status`). + try: + from hermes_cli.plugins import discover_plugins + discover_plugins() + from agent.terminal_env_registry import get_provider + provider = get_provider(terminal_env) + if provider is not None: + for ok, label, text in provider.doctor_checks(): + print(f" {label}: {check_mark(bool(ok))} {text}") + except Exception: + pass + _kv_flag("Sudo:", os.getenv("SUDO_PASSWORD", ""), "enabled", "disabled") + + +def _render_platforms(ctx): + _section("Messaging Platforms") + for name, (token_var, home_var) in _PLATFORMS.items(): + has_token = bool(os.getenv(token_var, "")) + home_channel = os.getenv(home_var, "") if home_var else "" + _row(name, has_token, _configured(has_token) + (f" (home: {home_channel})" if home_channel else "")) + + try: # Plugin-registered platforms + from gateway.platform_registry import platform_registry + for entry in platform_registry.plugin_entries(): + # Per-entry guard: one raising probe must not abort the listing of every remaining + # plugin platform (matches the other check_fn sites). + try: + configured = bool(entry.check_fn()) + except Exception: + configured = False + _row(entry.label, configured, f"{_configured(configured)} (plugin)") + except Exception: + pass + + +def _render_gateway(ctx): + _section("Gateway Service") + try: + from hermes_cli.gateway import ( + get_gateway_runtime_snapshot, _format_gateway_pids, named_profile_served_by_running_multiplexer) + from hermes_cli.gateway_multiplex_served import multiplexer_served_secondaries + snapshot = get_gateway_runtime_snapshot() + # A satellite profile has no gateway.pid of its own; the default multiplexer is its live process. + if not snapshot.running and named_profile_served_by_running_multiplexer(): + _kv_flag("Status:", True, "running (via the default-profile multiplexer)", "stopped") + _kv("Manage with:", "hermes gateway status # from the default profile") + return + _kv_flag("Status:", snapshot.running, "running", "stopped") + _kv("Manager:", snapshot.manager) + if snapshot.gateway_pids: + _kv("PID(s):", _format_gateway_pids(snapshot.gateway_pids)) + if snapshot.running and (served := multiplexer_served_secondaries()): + _kv("Serves:", ", ".join(served)) + from hermes_cli.gateway_multiplex_served import served_profile_ingress_urls + for name, per_platform in sorted(served_profile_ingress_urls().items()): + for platform, url in sorted(per_platform.items()): + _kv(f" {name}/{platform}:", url) + if snapshot.has_process_service_mismatch: + _kv("Service:", "installed but not managing the current running gateway") + elif _is_termux() and not snapshot.gateway_pids: + _kv("Start with:", "hermes gateway") + _kv("Note:", "Android may stop background jobs when Termux is suspended") + elif snapshot.service_installed and not snapshot.service_running: + _kv("Service:", "installed but stopped") + except Exception: + platform = "termux" if _is_termux() else "linux" if sys.platform.startswith("linux") else sys.platform + status_text, manager = _GATEWAY_FALLBACK.get(platform, ("N/A", "(not supported on this platform)")) + _kv("Status:", color(status_text, Colors.DIM)) + _kv("Manager:", manager) + + +def _load_json(path: Path, encoding: str = "utf-8"): + with open(path, encoding=encoding) as f: + return json.load(f) + + +def _render_cron(ctx): + _section("Scheduled Jobs") + jobs_file = get_hermes_home() / "cron" / "jobs.json" + if not jobs_file.exists(): + _kv("Jobs:", 0) + return + try: + # utf-8-sig: same dialect as cron/jobs.load_jobs — Windows editors may leave a UTF-8 BOM + # that plain utf-8 json.load rejects. + jobs = _load_json(jobs_file, "utf-8-sig").get("jobs", []) + _kv("Jobs:", f"{sum(1 for j in jobs if j.get('enabled', True))} active, {len(jobs)} total") + except Exception: + _kv("Jobs:", "(error reading jobs file)") + + +def _render_sessions(ctx): + _section("Sessions") + # Gateway session count: state.db is the source of truth; fall back to sessions.json for + # pre-migration installs. + try: + from hermes_state import SessionDB + db = SessionDB(read_only=True) # status only reads; never a writer beside a running gateway + try: + gateway_rows = db.list_gateway_sessions(active_only=True) or [] + finally: + db.close() + except Exception: + gateway_rows = [] + + if gateway_rows: + _kv("Active:", f"{len(gateway_rows)} session(s)") + freshest = max((float(r.get("last_active") or 0) for r in gateway_rows), default=0.0) + if freshest > 0: + from hermes_cli.timefmt import relative_time + print(f" Last activity:{relative_time(freshest):>13}") + elif not (sessions_file := get_hermes_home() / "sessions" / "sessions.json").exists(): + _kv("Active:", 0) + else: + try: + data = _load_json(sessions_file) + entries = [k for k in data if not str(k).startswith("_")] if isinstance(data, dict) else [] + _kv("Active:", f"{len(entries)} session(s)") + except Exception: + _kv("Active:", "(error reading sessions file)") + + # Slot usage, only when max_concurrent_sessions is set. The cap is shared across CLI, + # desktop/TUI and the messaging gateway, so the surface that gets rejected is rarely the one + # holding the slots — without this the only way to find out is reading + # runtime/active_sessions.json by hand. + try: + from hermes_cli.active_sessions import ( + active_session_registry_snapshot, format_age, resolve_max_concurrent_sessions) + cap = resolve_max_concurrent_sessions(ctx.config) + except Exception: + cap = None + if cap: + try: + held = active_session_registry_snapshot() + except Exception: + held = [] + _kv("Slots:", color(f"{len(held)}/{cap} in use", Colors.YELLOW if len(held) >= cap else Colors.GREEN)) + now = time.time() + for entry in sorted(held, key=lambda e: e.get("started_at") or 0): + age = format_age(now - float(entry.get("started_at") or now)) + print(f" {entry.get('surface') or 'unknown':<17} {entry.get('session_id') or '?':<24} {age}") + + +def _render_deep(ctx): + if not ctx.deep: + return + _section("Deep Checks") + openrouter_key = os.getenv("OPENROUTER_API_KEY", "") + if openrouter_key: + try: + import httpx + response = httpx.get(OPENROUTER_MODELS_URL, headers={"Authorization": f"Bearer {openrouter_key}"}, timeout=10) + _kv_flag("OpenRouter:", response.status_code == 200, "reachable", f"error ({response.status_code})") + except Exception as e: + _kv("OpenRouter:", f"{check_mark(False)} error: {e}") + try: # gateway port, informational: in use == gateway likely running + import socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1) + port_in_use = sock.connect_ex(('127.0.0.1', 18789)) == 0 + sock.close() + _kv("Port 18789:", 'in use' if port_in_use else 'available') + except OSError: + pass + + +def _render_footer(ctx): + _banner(("─" * 60, " Run 'hermes doctor' for detailed diagnostics", " Run 'hermes setup' to configure"), + Colors.DIM) + print() + + +# Print order of `hermes status`; each renderer takes the shared _StatusContext. +_SECTIONS = ( + _render_header, _render_environment, _render_api_keys, _render_auth_providers, _render_nous_gateway, + _render_apikey_providers, _render_terminal, _render_platforms, _render_gateway, _render_cron, + _render_sessions, _render_deep, _render_footer) + + +def show_status(args): + """Show status of all Hermes Agent components.""" + # Shared by section renderers: config, --deep, and the Nous login facts Auth Providers derives + # for the later Nous Tool Gateway section. + ctx = SimpleNamespace(deep=getattr(args, 'deep', False), config={}, nous_logged_in=False, + nous_inference_present=False, nous_account_info=None) + for render in _SECTIONS: + render(ctx) + + +# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- +# Names external plugins imported from this module before the Sep 2026 decomposition. +# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does). +# The whole block is removed by reverting the commit that added it. +import subprocess # noqa: F401,E402 + + +_PLUGIN_COMPAT_LAZY = { + 'format_nous_portal_entitlement_message': ('hermes_cli.nous_account', 'format_nous_portal_entitlement_message'), + 'get_nous_portal_account_info': ('hermes_cli.nous_account', 'get_nous_portal_account_info'), + 'get_nous_subscription_features': ('hermes_cli.nous_subscription', 'get_nous_subscription_features'), + 'managed_nous_tools_enabled': ('tools.tool_backend_helpers', 'managed_nous_tools_enabled'), + 'redact_key': ('hermes_cli.config', 'redact_key'), +} + + +def __getattr__(name): # PEP 562 — lazy so no import cycles + target = _PLUGIN_COMPAT_LAZY.get(name) + if target is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + import importlib + from hermes_cli.plugin_compat import warn_once + warn_once(__name__, name, *target) + return getattr(importlib.import_module(target[0]), target[1]) +# ---- END PLUGIN-COMPAT ---- diff --git a/hermes_cli/update_cmd_git.py b/hermes_cli/update_cmd_git.py new file mode 100644 index 0000000000000000000000000000000000000000..b479cb8373f083f22955a52408eb5a8d5d1c6665 --- /dev/null +++ b/hermes_cli/update_cmd_git.py @@ -0,0 +1,490 @@ +"""Git plumbing for ``hermes update``: fork/upstream sync, trampoline-git detection, lockfile/EOL churn cleanup, orphan rescue refs, parked-branch assessment, fetch-failure classification. + +Split out of ``update_cmd.py``, which re-imports every name so ``hermes_cli.update_cmd.`` +still resolves/monkeypatches. Origin helpers are imported lazily per function (no cycle; +test patches on ``update_cmd`` stay effective). +""" + +import logging +from contextlib import suppress +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +logger = logging.getLogger("hermes_cli.update_cmd") # log-record parity with the origin module + +_ORPHAN_RESCUE_REFS_TO_KEEP = 10 +_ORPHAN_RESCUE_REF_MAX_AGE_DAYS = 30 + +_GIT_TEXT_KW = dict(capture_output=True, text=True, encoding="utf-8", errors="replace") +_BAR = "=" * 68 +_UPSTREAM_ADD_CMD = "git remote add upstream https://github.com/NousResearch/hermes-agent.git" + + +def _git_ok(git_cmd, args, cwd, **kw) -> bool: + """True when ``_git_run`` exits 0; any exception counts as failure.""" + return _git_stdout(git_cmd, args, cwd, **kw) is not None + + +def _git_stdout(git_cmd, args, cwd, **kw) -> Optional[str]: + """Stripped stdout of a successful ``_git_run``; ``None`` on non-zero exit or any exception.""" + from hermes_cli.update_cmd import _git_run + with suppress(Exception): + result = _git_run(git_cmd, args, cwd, **kw) + if result.returncode == 0: + return result.stdout.strip() + return None + + +def _prune_orphan_rescue_refs( + git_cmd, cwd, branch, keep=_ORPHAN_RESCUE_REFS_TO_KEEP, max_age_days=_ORPHAN_RESCUE_REF_MAX_AGE_DAYS +) -> None: + """Expire old orphan rescue refs (``refs/hermes-update-backups/orphan---``). + + Each ref pins a possibly multi-GB snapshot against ``git gc``, so a repeatedly corrupted install would + grow ``.git`` unbounded. Keep the ``keep`` newest AND drop any older than ``max_age_days`` by the + ``YYYYMMDD-HHMMSS`` stamp (unparseable names left alone); names sort chronologically so + ``for-each-ref`` order is creation order. Best-effort, never blocks. + + A rescue ref pins every object reachable from that commit against ``git gc`` — and in the incident shape + those objects include a full working-tree snapshot (the autostash orphan commit), which can be multi-GB + when the tree holds large stray files. See #87694. + """ + from hermes_cli.update_cmd import _git_run + with suppress(OSError): + prefix = f"refs/hermes-update-backups/orphan-{branch}-" + list_result = _git_run(git_cmd, ["for-each-ref", "--format=%(refname)", "--sort=refname", f"{prefix}*"], cwd) + if list_result.returncode != 0: + return + refs = [line.strip() for line in list_result.stdout.splitlines() if line.strip()] + stale = set(refs[:-keep] if keep > 0 else refs) + if max_age_days > 0: + cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days) + for ref in refs: + with suppress(ValueError): + if datetime.strptime(ref[len(prefix):][:15], "%Y%m%d-%H%M%S").replace(tzinfo=timezone.utc) < cutoff: + stale.add(ref) + for ref in sorted(stale): + _git_run(git_cmd, ["update-ref", "-d", ref], cwd) + + +def _branch_head_label(git_cmd=None, cwd=None) -> str | None: + """``" @ "`` for the checkout (``detached`` when not on a branch), or None. Never raises. + + Appended to summary lines so a checkout parked on a stale branch is visible.""" + from hermes_cli.update_cmd import _m + try: + cmd = list(git_cmd) if git_cmd else ["git"] + root = cwd if cwd is not None else _m().PROJECT_ROOT + + def _rev_parse(*args): + return subprocess.run(cmd + ["rev-parse", *args], cwd=root, **_GIT_TEXT_KW) + + branch, sha = _rev_parse("--abbrev-ref", "HEAD"), _rev_parse("--short", "HEAD") + branch_name, sha_text = branch.stdout.strip(), sha.stdout.strip() + if branch.returncode != 0 or sha.returncode != 0 or not sha_text or not branch_name: + return None + return f"{'detached' if branch_name == 'HEAD' else branch_name} @ {sha_text}" + except Exception: + return None + + +def _branch_head_suffix(git_cmd=None, cwd=None) -> str: + """`` [ @ ]`` suffix for summary lines ("" when unknown).""" + label = _branch_head_label(git_cmd, cwd) + return f" [{label}]" if label else "" + + +def _assess_parked_branch_switch(git_cmd: list[str], cwd: Path, current_branch: str, target_branch: str) -> tuple[bool, str]: + """Decide whether a parked feature branch may be auto-switched back to the update target. + + - (True, "") — tree clean and every parked commit is in ``origin/`` (no ``git cherry +``). + - (True, "unmerged:") — tree clean but commits not in target; switching is safe (checkout keeps + committed work) but caller must print a LOUD notice. Non-interactive callers (desktop, gateway + /update, cron) can't resolve a skip, so a clean checkout must reach target. + - (False, "disabled"|"dirty"|"unverifiable") — caller must NOT touch the branch. Dirty is the + genuinely unsafe case: uncommitted work riding an autostash across branches. + A config read failure must not disable the safety checks: fall through with the default.""" + from hermes_cli.update_cmd import _git_run + try: + from hermes_cli.config import load_config + _update_cfg = (load_config() or {}).get("updates", {}) + if isinstance(_update_cfg, dict) and not bool(_update_cfg.get("auto_switch_parked_branch", True)): + return False, "disabled" + except Exception as exc: + logger.debug("Could not read updates.auto_switch_parked_branch: %s", exc) + status = _git_run(git_cmd, ["status", "--porcelain"], cwd) + if status.returncode != 0: + return False, "unverifiable" + if status.stdout.strip(): + return False, "dirty" + cherry = _git_run(git_cmd, ["cherry", f"origin/{target_branch}"], cwd) + if cherry.returncode != 0: + return False, "unverifiable" + unmerged = [line for line in cherry.stdout.splitlines() if line.startswith("+")] + return True, f"unmerged:{len(unmerged)}" if unmerged else "" + + +_PARKED_SKIP_WHY = { + "dirty": "the working tree has uncommitted changes", + "disabled": "updates.auto_switch_parked_branch is set to false in config.yaml", +} + + +def _print_parked_branch_skip_warning(git_cmd: list[str], cwd: Path, current_branch: str, target_branch: str, reason: str) -> None: + """LOUD block: why the update was skipped on a parked branch, behind-count, fix commands.""" + behind = None + with suppress(Exception): + behind_text = _git_stdout(git_cmd, ["rev-list", f"HEAD..origin/{target_branch}", "--count"], cwd) + if behind_text: + behind = int(behind_text) + why = _PARKED_SKIP_WHY.get(reason, f"the branch state could not be verified against origin/{target_branch}") + print(f"\n{_BAR}\n⚠ CODE UPDATE SKIPPED — checkout is parked on '{current_branch}'") + print(f" Not auto-switching to {target_branch}: {why}.") + if behind is not None and behind > 0: + print(f" This checkout is {behind} commit(s) BEHIND origin/{target_branch} — the code you are running is stale.") + print( + f"\n To resolve, inspect the branch and switch back yourself:\n" + f" git -C {cwd} status\n" + f" git -C {cwd} checkout {target_branch} && hermes update\n" + f" (commit or stash your work on the branch first if you want to keep it)\n{_BAR}" + ) + + +def _print_parked_branch_kept_notice(current_branch: str, target_branch: str, unmerged_count: str) -> None: + """LOUD notice when a clean parked branch with unmerged commits is auto-switched. + + Non-interactive callers can't resolve a skip, so we proceed — but the unmerged work + (still safe on its branch) must be impossible to miss.""" + print( + f"\n{_BAR}\n" + f"⚠ Checkout was parked on '{current_branch}' with " + f"{unmerged_count} commit(s) not merged into origin/{target_branch}.\n" + f" Switching to {target_branch} so the update can proceed — your " + f"commit(s) are safe on '{current_branch}'.\n\n" + f" To pick the work back up later:\n git checkout {current_branch}\n{_BAR}" + ) + + +OFFICIAL_REPO_URLS = { + "https://github.com/NousResearch/hermes-agent.git", + "git@github.com:NousResearch/hermes-agent.git", + "https://github.com/NousResearch/hermes-agent", + "git@github.com:NousResearch/hermes-agent", +} +OFFICIAL_REPO_URL = "https://github.com/NousResearch/hermes-agent.git" +SKIP_UPSTREAM_PROMPT_FILE = ".skip_upstream_prompt" + + +def _get_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]: + """Get the URL of the origin remote, or None if not set.""" + return _git_stdout(git_cmd, ["remote", "get-url", "origin"], cwd) + + +def _is_fork(origin_url: Optional[str]) -> bool: + """Check if the origin remote points to a fork (not the official repo).""" + if not origin_url: + return False + + def _norm(url: str) -> str: + url = url.rstrip("/") + return url[:-4] if url.endswith(".git") else url + + return _norm(origin_url) not in {_norm(official) for official in OFFICIAL_REPO_URLS} + + +def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool: + """Check if an 'upstream' remote already exists.""" + return _git_ok(git_cmd, ["remote", "get-url", "upstream"], cwd) + + +def _add_upstream_remote(git_cmd: list[str], cwd: Path) -> bool: + """Add the official repo as the 'upstream' remote. Returns True on success.""" + return _git_ok(git_cmd, ["remote", "add", "upstream", OFFICIAL_REPO_URL], cwd) + + +def _count_commits_between(git_cmd: list[str], cwd: Path, base: str, head: str) -> int: + """Count commits on `head` that are not on `base`. Returns -1 on error.""" + with suppress(Exception): + count = _git_stdout(git_cmd, ["rev-list", "--count", f"{base}..{head}"], cwd) + if count is not None: + return int(count) + return -1 + + +def _should_skip_upstream_prompt() -> bool: + """Check if user previously declined to add upstream.""" + from hermes_constants import get_hermes_home + return (get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).exists() + + +def _mark_skip_upstream_prompt(): + """Create marker file to skip future upstream prompts.""" + with suppress(Exception): + from hermes_constants import get_hermes_home + (get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).touch() + + +def _sync_fork_with_upstream(git_cmd: list[str], cwd: Path) -> bool: + """Push updated main to origin (sync fork); True on success.""" + return _git_ok(git_cmd, ["push", "origin", "main", "--force-with-lease"], cwd, network=True) + + +def _offer_upstream_remote(git_cmd: list[str], cwd: Path, *, assume_yes: bool, input_fn) -> bool: + """Prompt to add ``upstream`` and add it; False when the user declined, the run is non-interactive, or add failed. + + ``--yes`` means "don't block", not "mutate my remotes", so a non-interactive skip is NOT persisted.""" + from hermes_cli.update_cmd import _add_upstream_remote, _mark_skip_upstream_prompt + print( + "\nℹ Your fork is not tracking the official Hermes repository.\n" + " This means you may miss updates from NousResearch/hermes-agent.\n" + ) + if assume_yes or (input_fn is None and not (sys.stdin.isatty() and sys.stdout.isatty())): + print(f" Skipping upstream setup (non-interactive run).\n Add it later with: {_UPSTREAM_ADD_CMD}") + return False + if input_fn is not None: + response = input_fn("Add official repo as 'upstream' remote? [y/N]", "n").strip().lower() + else: + try: + response = input("Add official repo as 'upstream' remote? [Y/n]: ").strip().lower() + except (EOFError, KeyboardInterrupt, UnicodeDecodeError): + print() + response = "n" + if response not in {"", "y", "yes"}: + print(f" Skipped. Run '{_UPSTREAM_ADD_CMD}' to add later.") + _mark_skip_upstream_prompt() + return False + print("→ Adding upstream remote...") + if not _add_upstream_remote(git_cmd, cwd): + print(" ✗ Failed to add upstream remote. Skipping upstream sync.") + return False + print(" ✓ Added upstream: https://github.com/NousResearch/hermes-agent.git") + return True + + +def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path, *, assume_yes: bool = False, input_fn=None) -> bool: + """Offer to add ``upstream``, compare origin/main vs upstream/main, ff-pull when strictly behind, then push origin. + + Returns True only when origin/main was actually verified against upstream/main; False when the check never + happened, so the caller never reports "up to date" on an origin-only compare. Fetches only upstream/main: + a bare fetch drags in thousands of auto-generated branches. + + See #97052. + """ + from hermes_cli.update_cmd import _count_commits_between, _has_upstream_remote, _no_prompt_git_kwargs, _should_skip_upstream_prompt + if not _has_upstream_remote(git_cmd, cwd) and ( + _should_skip_upstream_prompt() or not _offer_upstream_remote(git_cmd, cwd, assume_yes=assume_yes, input_fn=input_fn) + ): + return False + print("\n→ Fetching upstream...") + try: + subprocess.run(git_cmd + ["fetch", "upstream", "main", "--quiet"], cwd=cwd, capture_output=True, check=True, **_no_prompt_git_kwargs()) + except subprocess.CalledProcessError: + print(" ✗ Failed to fetch upstream. Skipping upstream sync.") + return False + origin_ahead = _count_commits_between(git_cmd, cwd, "upstream/main", "origin/main") + upstream_ahead = _count_commits_between(git_cmd, cwd, "origin/main", "upstream/main") + if origin_ahead < 0 or upstream_ahead < 0: + print(" ✗ Could not compare branches. Skipping upstream sync.") + return False + if origin_ahead > 0: + print( + f"\nℹ Your fork has {origin_ahead} commit(s) not on upstream.\n" + " Skipping upstream sync to preserve your changes.\n" + " If you want to merge upstream changes, run:\n git pull upstream main" + ) + return True + if upstream_ahead == 0: + print(" ✓ Fork is up to date with upstream") + return True + print(f"\n→ Fork is {upstream_ahead} commit(s) behind upstream\n→ Pulling from upstream...") + try: + subprocess.run(git_cmd + ["pull", "--ff-only", "upstream", "main"], cwd=cwd, check=True, **_no_prompt_git_kwargs()) + except subprocess.CalledProcessError: + print(" ✗ Failed to pull from upstream. You may need to resolve conflicts manually.") + return False + print(" ✓ Updated from upstream\n→ Syncing fork...") + if _sync_fork_with_upstream(git_cmd, cwd): + print(" ✓ Fork synced with upstream") + else: + print( + " ℹ Got updates from upstream but couldn't push to fork (no write access?)\n" + " Your local repo is updated, but your fork on GitHub may be behind." + ) + return True + + +def _has_http_code(stderr: str, *codes: str) -> bool: + return any(f"HTTP {code}" in stderr or f"returned error: {code}" in stderr for code in codes) + + +# Ordered (predicate, diagnosis): curl reports HTTP errors as ``unable to access '': ... error: 429``, +# so rate-limit/outage checks must run BEFORE the generic "unable to access" check. An anonymous fetch +# answered with HTTP 401 ("could not read Username") is GitHub during an outage (or a renamed/private +# repo), not a user credentials problem. +_FETCH_FAILURE_RULES = ( + (lambda s: _has_http_code(s, "429") or "rate limit" in s.lower(), + "✗ GitHub is rate limiting requests or having an outage (HTTP 429) — try again in 5 minutes."), + (lambda s: _has_http_code(s, "500", "502", "503", "504"), + "✗ GitHub appears to be having an outage — try again in a few minutes (https://www.githubstatus.com)."), + (lambda s: "Could not resolve host" in s or "unable to access" in s, + "✗ Network error — cannot reach the remote repository."), + (lambda s: "could not read Username" in s or "terminal prompts disabled" in s, + "✗ GitHub rejected the anonymous fetch (asked for a login) — this usually means a GitHub outage;" + " try again in a few minutes (https://www.githubstatus.com). If it persists, check" + " `git remote -v` points at a public repo."), + (lambda s: "Authentication failed" in s, + "✗ Authentication failed — check your git credentials or SSH key."), +) + + +def _classify_fetch_failure(stderr: str) -> str: + """Map git-fetch stderr to a one-line diagnosis (caller also prints the raw first line).""" + return next((message for matches, message in _FETCH_FAILURE_RULES if matches(stderr)), "✗ Failed to fetch updates from origin.") + + +def _print_fetch_failure(stderr: str) -> None: + """Print the classified diagnosis plus the first raw stderr line.""" + stderr = (stderr or "").strip() + print(_classify_fetch_failure(stderr)) + if stderr: + print(f" {stderr.splitlines()[0]}") + + +def _probe_fork_bomb(argv: list) -> Optional[bool]: + """Run `` --version``; True/False = guard message seen/absent, None = probe itself failed.""" + try: + result = subprocess.run(argv + ["--version"], timeout=15, **_GIT_TEXT_KW) + except Exception: + return None + return "fork bomb" in ((result.stdout or "") + (result.stderr or "")).lower() + + +def _git_is_trampoline(git_cmd: list) -> bool: + """Whether *git_cmd* is a broken Git-for-Windows trampoline shim. + + The ~46KB ``bin\\git.exe``/``cmd\\git.exe`` shims re-exec git-core; when they can't find it every call + dies with the launcher's guard message (a PATH problem, not network). Never raises; unknown states + report False so a probe failure can't block an update. + + Git for Windows ships two ~46KB shims (``bin\\git.exe``, ``cmd\\git.exe``) that re-exec the real + ``mingw64\\libexec\\git-core\\git.exe``. See #87876. + """ + return _probe_fork_bomb(git_cmd) is True + + +def _portable_git_candidates() -> list: + """PortableGit candidates: shared root first (where the managed tree actually lives, not the + profile-scoped HERMES_HOME), then profile home as a fallback for custom layouts. + + The Hermes-managed PortableGit tree lives under the SHARED root (``/git/...``), not the + profile-scoped HERMES_HOME (``/profiles/``), so a profile-scoped ``hermes update`` must look + there (monerostar review, #87876). + """ + from hermes_cli.update_cmd import get_default_hermes_root, get_hermes_home + candidates = [] + with suppress(Exception): + candidates += [root / "git" / "mingw64" / "libexec" / "git-core" / "git.exe" for root in (get_default_hermes_root(), Path(get_hermes_home()))] + return candidates + + +def _locate_real_git() -> Optional[Path]: + """Find a real Git-for-Windows ``git-core/git.exe`` (standard locations + managed PortableGit) that runs + without the trampoline guard. None when nothing suits — callers keep the broken command and let the + fetch-failure ZIP fallback handle it. A failed probe (None) disqualifies a candidate like a guard hit. + + The trampoline symptom is PATH-level: ``bin\\git.exe`` / ``cmd\\git.exe`` (both ~46KB shims) fail to + re-exec git-core, while the real binary at ``mingw64\\libexec\\git-core\\git.exe`` (≈4.4MB) works when + invoked directly (#87876). + """ + candidates = [ + Path(r"C:\Program Files\Git\mingw64\libexec\git-core\git.exe"), + Path(r"C:\Program Files (x86)\Git\mingw64\libexec\git-core\git.exe"), + ] + _portable_git_candidates() + return next((c for c in candidates if c.exists() and _probe_fork_bomb([str(c)]) is False), None) + + +def _ensure_non_trampoline_git(git_cmd: list) -> list: + """Swap a broken Git-for-Windows trampoline for a real git binary so fetch/pull/checkout keep working; + if none is found leave the command untouched (fetch-failure handler falls back to ZIP). No-op off + Windows and when git is healthy.""" + from hermes_cli.update_cmd import _locate_real_git + if sys.platform != "win32" or not _git_is_trampoline(git_cmd): + return git_cmd + real_git = _locate_real_git() + if real_git is None: + print("⚠ Detected a broken git trampoline and could not locate a real git binary — the update will fall back to the ZIP path.") + return git_cmd + print(f"⚠ Detected a broken git trampoline; switching to real git at {real_git}") + return [str(real_git)] + list(git_cmd[1:]) + + +def _discard_lockfile_churn(git_cmd, repo_root): + """Restore ``package-lock.json`` files npm rewrote non-deterministically, so the update sees a clean tree + instead of autostashing every run. Only touches lockfiles whose package.json is NOT also dirty. Best-effort.""" + from hermes_cli.update_cmd import _git_run + with suppress(Exception): + diff = _git_run(git_cmd, ["diff", "--name-only"], repo_root) + if diff.returncode != 0: + return + changed = [line.strip() for line in diff.stdout.splitlines()] + dirty_package_dirs = {Path(p).parent for p in changed if p.endswith("package.json")} + dirty = [p for p in changed if p.endswith("package-lock.json") and Path(p).parent not in dirty_package_dirs] + if not dirty: + return + _git_run(git_cmd, ["checkout", "--", *dirty], repo_root) + print(f"→ Discarded npm lockfile churn ({len(dirty)} file(s))") + + +def _normalize_managed_eol(git_cmd, repo_root): + """Take a managed checkout off ``core.autocrlf=true`` without leaving it dirty. + + Git for Windows sets ``autocrlf=true`` system-wide, turning LF files CRLF and breaking ``git checkout`` + on update; install.ps1 pins ``false`` but older checkouts never got it and only ``hermes update`` can + fix them. Pin and cleanup are one operation: under ``autocrlf=true`` a CRLF tree reads clean, so pinning + alone would expose every file as modified (whole-tree autostash). Pin only after the tree verifies clean + under it; a checkout we can't fully normalize is left as-is. Only ``true`` rewrites LF->CRLF + (unset/false/input leave the tree alone). Best-effort. + + Checkouts created before that landed never got the pin and cannot receive it — the bootstrap installer + reuses its build-pinned ``install.ps1`` forever — so ``hermes update``, which ships with the checkout + itself, is the only path left that can fix them. See #67730. + """ + from hermes_cli.update_cmd import _git_run + # -c, not config: evaluate the tree as it WOULD look pinned, persisting nothing. + probe = git_cmd + ["-c", "core.autocrlf=false"] + + def _probe_run(*args, **kw): + return subprocess.run(probe + list(args), cwd=repo_root, **_GIT_TEXT_KW, **kw) + + def _eol_only(): + """Dirty paths whose ONLY change is CRLF; None when either probe fails.""" + all_dirty = _probe_run("diff", "-z", "--name-only") + # Files with a *content* change ignoring CRLF. ``--name-only --ignore-cr-at-eol`` still LISTS + # CR-only files; ``--numstat`` honors the filter (no record for them). Records are + # "\\t\\t"; rename detection is off, so exactly one path. + real_dirty = _probe_run("-c", "core.quotepath=false", "diff", "--numstat", "--ignore-cr-at-eol") + if all_dirty.returncode != 0 or real_dirty.returncode != 0: + return None + return {p for p in all_dirty.stdout.split("\0") if p} - { + parts[2] + for parts in (line.split("\t", 2) for line in real_dirty.stdout.splitlines() if line.strip()) + if len(parts) == 3 and parts[2] + } + + with suppress(Exception): + if _git_run(git_cmd, ["config", "--get", "core.autocrlf"], repo_root).stdout.strip().lower() != "true": + return + eol_only = _eol_only() + if eol_only is None: + return + if eol_only: + # Pathspec via stdin: thousands of paths exceed the Windows argv limit. + _probe_run("checkout", "--pathspec-from-file=-", "--pathspec-file-nul", "--", + input="\0".join(sorted(eol_only)), check=False) + if _eol_only(): # still dirty: pinning would only surface churn we failed to clear + return + print(f"→ Normalized line-ending churn ({len(eol_only)} file(s))") + subprocess.run(git_cmd + ["config", "core.autocrlf", "false"], cwd=repo_root, capture_output=True, check=False) diff --git a/hermes_cli/update_cmd_stash.py b/hermes_cli/update_cmd_stash.py new file mode 100644 index 0000000000000000000000000000000000000000..70be0cb7a00292141314acf2d42663fd857c3c22 --- /dev/null +++ b/hermes_cli/update_cmd_stash.py @@ -0,0 +1,375 @@ +"""Autostash handling for ``hermes update``: stash before the pull, restore/park/discard afterwards, warn about orphans. + +Split out of ``update_cmd.py``; names are re-imported there so ``hermes_cli.update_cmd.`` still resolves/monkeypatches. +Origin helpers are imported lazily per function (no cycle; test patches on the origin stay effective). +""" + +import logging +import subprocess +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +# Log-record parity with the origin module. +logger = logging.getLogger("hermes_cli.update_cmd") + +#: Autostash subject contract: this prefix + UTC YYYYMMDD-HHMMSS stamp +#: (producer _stash_local_changes_if_needed, consumer _warn_orphaned_update_autostashes). +_AUTOSTASH_NAME_PREFIX = "hermes-update-autostash-" + +#: Age past which a leftover autostash is called out. Younger entries are normal +#: (recent --keep-stash park); older ones are almost always forgotten. +# Entries younger than this are normal (a parked stash from : the desktop updater's --keep-stash run minutes +# ago); older ones are almost : always forgotten (#63717 problem 6: an orphan persisted 9+ days unnoticed). +_AUTOSTASH_WARN_AGE_DAYS = 7 + +_STASH_LEFT_IN_PLACE = " The stash was left in place. You can remove it manually after checking the result." + + +def _git_quiet(git_cmd: list[str], args: list[str], cwd: Path, **kwargs): + """``subprocess.run`` of a git command with captured output; None when git cannot run.""" + try: + return subprocess.run(git_cmd + args, cwd=cwd, capture_output=True, **kwargs) + except (OSError, subprocess.SubprocessError): + return None + + +def _git_paths_z(git_cmd: list[str], args: list[str], cwd: Path): + """NUL-separated path listing as a set, or None when git failed (surrogateescape keeps odd filenames).""" + result = _git_quiet(git_cmd, args, cwd, text=True, encoding="utf-8", errors="surrogateescape") + if result is None or result.returncode != 0: + return None + return {path for path in result.stdout.split("\0") if path} + + +def _reset_hard(git_cmd: list[str], cwd: Path) -> None: + subprocess.run(git_cmd + ["reset", "--hard", "HEAD"], cwd=cwd, capture_output=True) + + +def _print_nonempty(text: str, prefix: str = "") -> None: + if text.strip(): + print(f"{prefix}{text.strip()}") + + +def _print_first_line(text: str) -> None: + if text.strip(): + print(f" {text.strip().splitlines()[0]}") + + +def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]: + from hermes_cli.update_cmd import _git_run + status = _git_run(git_cmd, ["status", "--porcelain"], cwd, check=True) + if not status.stdout.strip(): + return None + # Unmerged index entries (interrupted merge/rebase) make `git stash` fail with + # "needs merge"; `git reset` drops only the index conflict state, not the tree. + if _git_run(git_cmd, ["ls-files", "--unmerged"], cwd).stdout.strip(): + print("→ Clearing unmerged index entries from a previous conflict...") + subprocess.run(git_cmd + ["reset"], cwd=cwd, capture_output=True) + + stash_name = datetime.now(timezone.utc).strftime(f"{_AUTOSTASH_NAME_PREFIX}%Y%m%d-%H%M%S") + print("→ Local changes detected — stashing before update...") + prev_stash = _git_run(git_cmd, ["rev-parse", "--verify", "refs/stash"], cwd).stdout.strip() + push = _git_run(git_cmd, ["stash", "push", "--include-untracked", "-m", stash_name], cwd) + _print_nonempty(push.stdout) + stash_probe = _git_run(git_cmd, ["rev-parse", "--verify", "refs/stash"], cwd) + stash_ref = stash_probe.stdout.strip() + stash_created = stash_probe.returncode == 0 and bool(stash_ref) and stash_ref != prev_stash + if push.returncode != 0: + if not stash_created: + # No entry created: changes NOT saved — bail before touching HEAD. + print("✗ Could not stash local changes — update aborted.") + _print_first_line(push.stderr) + print(" Commit, stash, or clean up your local changes manually, then re-run `hermes update`.") + raise subprocess.CalledProcessError(push.returncode, push.args, output=push.stdout, stderr=push.stderr) + # Non-zero but entry created: push saved everything yet couldn't delete some untracked files + # (e.g. root-owned dir). Not a failure — continue. + _print_nonempty(push.stderr) + print(" ⚠ Some untracked files could not be removed from the working tree (permission denied).") + print(" They were still saved to the stash and were left in place — the update will continue.") + # A partially-failed push also skips cleanup of TRACKED modifications; they'd break the following + # pull. Safe to reset: all is in the stash. + _reset_hard(git_cmd, cwd) + return stash_ref + + +def _resolve_stash_selector(git_cmd: list[str], cwd: Path, stash_ref: str) -> Optional[str]: + from hermes_cli.update_cmd import _git_run + stash_list = _git_run(git_cmd, ["stash", "list", "--format=%gd %H"], cwd, check=True) + for line in stash_list.stdout.splitlines(): + selector, _, commit = line.partition(" ") + if commit.strip() == stash_ref: + return selector.strip() + return None + + +def _warn_orphaned_update_autostashes(git_cmd: list[str], cwd: Path) -> int: + """Print a notice for update autostashes older than the warn threshold; return the count (0 on any git failure). + + Autostashes legitimately outlive a run (--keep-stash, failed restore) but nothing re-surfaces them. + Deliberately NOT a GC: a stash may be the only copy of the user's work, so Hermes never drops one. + + Autostash entries legitimately outlive an update run (``--keep-stash`` parks them; a conflicted or + failed restore preserves them for safety), but nothing ever re-surfaces them afterwards — they sit in + ``git stash`` invisibly for weeks (#63717 problem 6). This prints a short notice naming the stale + entries with recovery/cleanup guidance. + """ + from hermes_cli.update_cmd import _git_run + try: + stash_list = _git_run(git_cmd, ["stash", "list", "--format=%gd %s"], cwd) + if stash_list.returncode != 0: + return 0 + cutoff = datetime.now(timezone.utc) - timedelta(days=_AUTOSTASH_WARN_AGE_DAYS) + stale: list[tuple[str, str]] = [] + for line in stash_list.stdout.splitlines(): + selector, _, subject = line.strip().partition(" ") + pos = subject.find(_AUTOSTASH_NAME_PREFIX) + if pos < 0: + continue + stamp = subject[pos + len(_AUTOSTASH_NAME_PREFIX):][:15] # "YYYYMMDD-HHMMSS" + try: + stash_time = datetime.strptime(stamp, "%Y%m%d-%H%M%S").replace(tzinfo=timezone.utc) + except ValueError: + continue # age unknown — leave it alone rather than guess + if stash_time < cutoff: + stale.append((selector, stamp)) + if not stale: + return 0 + print() + print( + f"⚠ {len(stale)} leftover update autostash entr" + f"{'y is' if len(stale) == 1 else 'ies are'} more than " + f"{_AUTOSTASH_WARN_AGE_DAYS} days old:" + ) + for selector, stamp in stale: + print(f" {selector} ({_AUTOSTASH_NAME_PREFIX}{stamp})") + print(" These hold local changes stashed by earlier updates and never") + print(" restored. Review with: git stash show -p ") + print(" Restore with: git stash apply Discard with: git stash drop ") + return len(stale) + except Exception as exc: + logger.debug("Autostash age check failed: %s", exc) + return 0 + + +def _print_stash_cleanup_guidance(stash_ref: str, stash_selector: Optional[str] = None) -> None: + print(" Check `git status` first so you don't accidentally reapply the same change twice.") + print(" Find the saved entry with: git stash list --format='%gd %H %s'") + if stash_selector: + print(f" Remove it with: git stash drop {stash_selector}") + else: + print(f" Look for commit {stash_ref}, then drop its selector with: git stash drop stash@{{N}}") + + +def _stash_apply_failed_only_on_existing_untracked(stderr: str) -> bool: + """True when a ``git stash apply`` failure is ONLY about untracked files that already exist in the tree. + + Tail of the permission-denied class: push swept undeletable files into the stash but couldn't remove them; + apply restores tracked changes, then refuses to overwrite those files and exits non-zero though nothing was lost. + Any other error line (e.g. ``would be overwritten by merge``) means the tracked apply failed -> False. + """ + saw_untracked_error = False + for ln in (ln.strip() for ln in (stderr or "").splitlines() if ln.strip()): + if "already exists, no checkout" in ln or "could not restore untracked files from stash" in ln: + saw_untracked_error = True + elif not ln.startswith(("warning:", "hint:")): + return False + return saw_untracked_error + + +def _park_stashed_changes(stash_ref: str) -> None: + """Leave a pre-update autostash parked (``--keep-stash``, the desktop updater's mode): local source + edits must never be silently re-applied onto updated code; the entry stays in ``git stash``.""" + print() + print("ℹ️ Local changes were stashed before updating and were NOT re-applied (--keep-stash).") + print(f" Stash ref: {stash_ref}") + print(f" Restore manually with: git stash apply {stash_ref}") + + +def _git_untracked_paths(git_cmd: list[str], cwd: Path) -> set[str] | None: + """Return untracked paths, or ``None`` when Git cannot enumerate them.""" + paths = _git_paths_z(git_cmd, ["ls-files", "--others", "--exclude-standard", "-z"], cwd) + if paths is None: + print(" ⚠ Could not enumerate untracked files while validating the restored stash.") + return paths + + +def _restored_python_paths(git_cmd: list[str], cwd: Path) -> tuple[str, ...] | None: + """Restored ``.py`` paths changed from ``HEAD``; deliberately Python-only (entry scripts stay outside the health check).""" + from hermes_cli.update_cmd import _git_untracked_paths + paths = _git_paths_z(git_cmd, ["diff", "--name-only", "-z", "HEAD", "--", "*.py"], cwd) + if paths is None: + print(" ⚠ Could not enumerate tracked Python files restored from the stash.") + return None + untracked = _git_untracked_paths(git_cmd, cwd) + if untracked is None: + return None + paths.update(path for path in untracked if path.endswith(".py")) + return tuple(sorted(paths)) + + +def _reject_unsafe_stash_restore( + git_cmd: list[str], cwd: Path, stash_ref: str, preexisting_untracked: set[str], failing_target: str, + detail: str | None, +) -> None: + """Restore the clean updated tree, preserve the stash, and abort the update.""" + from hermes_cli.update_cmd import _git_untracked_paths + print() + print("✗ Restored local changes made the Hermes agent unexecutable.") + print(f" Health check failed: {failing_target}") + if detail: + for line in str(detail).splitlines()[:6]: + print(f" {line}") + + def _ok(result) -> bool: + return result is not None and result.returncode == 0 + + current_untracked = _git_untracked_paths(git_cmd, cwd) + restored_untracked = current_untracked - preexisting_untracked if current_untracked is not None else set() + reset = _git_quiet(git_cmd, ["reset", "--hard", "HEAD"], cwd) + clean = _git_quiet(git_cmd, ["clean", "-fd", "--", *sorted(restored_untracked)], cwd) if restored_untracked else None + cleanup_ok = current_untracked is not None and _ok(reset) and (not restored_untracked or _ok(clean)) + if cleanup_ok: + cleanup_ok = _ok(_git_quiet(git_cmd, ["diff", "--quiet", "HEAD", "--"], cwd)) + if cleanup_ok: + print(" The clean updated tree has been restored; the gateway was not restarted.") + else: + print(" ⚠ The clean updated tree could not be fully restored automatically.") + print(" Inspect `git status` and run `git reset --hard HEAD` before retrying.") + print(" Platform connectivity alone does not mean the agent can execute turns.") + print(f" Your local changes remain preserved in stash: {stash_ref}") + print(f" Inspect them with: git stash show --stat {stash_ref}") + print(f" Restore manually after fixing them: git stash apply {stash_ref}") + raise SystemExit(1) + + +def _confirm_restore(stash_ref: str, input_fn) -> bool: + """Interactive gate; a remote ``input_fn`` defaults to No (``[y/N]``), the local prompt to Yes.""" + remote_prompt = input_fn is not None + prompt_suffix = "[y/N]" if remote_prompt else "[Y/n]" + print() + print("⚠ Local changes were stashed before updating.") + print(" Restoring them may reapply local customizations onto the updated codebase.") + print(" Review the result afterward if Hermes behaves unexpectedly.") + print(f"Restore local changes now? {prompt_suffix}") + if remote_prompt: + response = input_fn(f"Restore local changes now? {prompt_suffix}", "n") + else: + try: + response = input().strip().lower() + except (EOFError, UnicodeDecodeError): + response = "n" # closed stdin/encoding error must not crash mid-restore + if response in {"y", "yes"} or (not remote_prompt and response == ""): + return True + print("Skipped restoring local changes.") + print("Your changes are still preserved in git stash.") + print(f"Restore manually with: git stash apply {stash_ref}") + return False + + +def _apply_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> bool: + """``git stash apply``; False (tree reset, stash kept) on conflicts or any failure other than the + undeletable-untracked class.""" + from hermes_cli.update_cmd import _git_run + print("→ Restoring local changes...") + restore = _git_run(git_cmd, ["stash", "apply", stash_ref], cwd) + unmerged = _git_run(git_cmd, ["diff", "--name-only", "--diff-filter=U"], cwd) # conflicts can exist even on rc 0 + conflicted_files = unmerged.stdout.strip() + if restore.returncode == 0 and not conflicted_files: + return True + if not conflicted_files and _stash_apply_failed_only_on_existing_untracked(restore.stderr): + # Tracked changes applied; only undeletable-at-stash-time untracked files were refused. Their + # content is untouched — treat as restored. + print(" ⚠ Some stashed untracked files already exist in the working tree and were kept as-is.") + return True + print("✗ Update pulled new code, but restoring local changes hit conflicts.") + _print_nonempty(restore.stdout) + _print_nonempty(restore.stderr) + if conflicted_files: + print("\nConflicted files:") + for f in conflicted_files.splitlines(): + print(f" • {f}") + print("\nYour stashed changes are preserved — nothing is lost.") + print(f" Stash ref: {stash_ref}") + _reset_hard(git_cmd, cwd) # conflict markers make hermes unrunnable; changes stay in the stash + print("Working tree reset to clean state.") + print(f"Restore your changes later with: git stash apply {stash_ref}") + return False # code update succeeded; cmd_update continues (deps, skills, gateway) + + +def _drop_restored_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> None: + from hermes_cli.update_cmd import _git_run + stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref) + if stash_selector is None: + print("⚠ Local changes were restored, but Hermes couldn't find the stash entry to drop.") + print(_STASH_LEFT_IN_PLACE) + _print_stash_cleanup_guidance(stash_ref) + return + drop = _git_run(git_cmd, ["stash", "drop", stash_selector], cwd) + if drop.returncode != 0: + print("⚠ Local changes were restored, but Hermes couldn't drop the saved stash entry.") + _print_nonempty(drop.stdout) + _print_nonempty(drop.stderr) + print(_STASH_LEFT_IN_PLACE) + _print_stash_cleanup_guidance(stash_ref, stash_selector) + + +def _restore_stashed_changes( + git_cmd: list[str], cwd: Path, stash_ref: str, prompt_user: bool = False, input_fn=None, +) -> bool: + from hermes_cli.update_cmd import ( + _critical_module_import_failures, _git_untracked_paths, _restored_python_paths, _validate_python_files_syntax, + ) + if prompt_user and not _confirm_restore(stash_ref, input_fn): + return False + preexisting_untracked = _git_untracked_paths(git_cmd, cwd) + if preexisting_untracked is None: + print(" The stash was not restored because its cleanup baseline is unknown.") + print(f" Restore manually with: git stash apply {stash_ref}") + return False + clean_import_failures = _critical_module_import_failures(cwd, report_runtime_errors=True) + if not _apply_stash(git_cmd, cwd, stash_ref): + return False + + def reject(failing_target: str, detail) -> None: + _reject_unsafe_stash_restore(git_cmd, cwd, stash_ref, preexisting_untracked, failing_target, detail) + + restored_python = _restored_python_paths(git_cmd, cwd) + if restored_python is None: + reject("restored Python source discovery", "could not determine which restored Python files require validation") + syntax_ok, failing_path, syntax_error = _validate_python_files_syntax(cwd, restored_python) + if not syntax_ok: + reject(failing_path or "restored Python source", syntax_error) + for module, error in _critical_module_import_failures(cwd, report_runtime_errors=True).items(): + if clean_import_failures.get(module) != error: + reject(f"agent import {module or 'unknown'}", error[1]) + break + _drop_restored_stash(git_cmd, cwd, stash_ref) + print("⚠ Local changes were restored on top of the updated codebase.") + print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.") + return True + + +def _discard_stashed_changes(git_cmd: list[str], cwd: Path, stash_ref: str) -> bool: + """Drop a pre-update stash without applying (non-interactive ``updates.non_interactive_local_changes: discard``). + + Unlike reset --hard + clean -fd this touches only what was stashed; ignored paths are never affected. + Returns True if dropped, False on git failure (stash left in place). + """ + from hermes_cli.update_cmd import _git_run + stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref) + if stash_selector is None: + print( + "⚠ Configured to discard local changes on non-interactive update, " + "but Hermes couldn't find the stash entry to drop." + ) + _print_stash_cleanup_guidance(stash_ref) + return False + drop = _git_run(git_cmd, ["stash", "drop", stash_selector], cwd) + if drop.returncode != 0: + print("⚠ Configured to discard local changes, but Hermes couldn't drop the saved stash entry.") + _print_first_line(drop.stderr) + _print_stash_cleanup_guidance(stash_ref, stash_selector) + return False + print("→ Discarded local source changes (updates.non_interactive_local_changes=discard).") + return True diff --git a/hermes_cli/web_read_coalescing.py b/hermes_cli/web_read_coalescing.py new file mode 100644 index 0000000000000000000000000000000000000000..3f5b5498a95b21022711da9e1a0152dd183b8e11 --- /dev/null +++ b/hermes_cli/web_read_coalescing.py @@ -0,0 +1,66 @@ +"""Coalesce concurrent synchronous HTTP reads before threadpool admission.""" +import asyncio +import inspect +from functools import partial, wraps +from weakref import WeakKeyDictionary +from typing import get_type_hints + +from anyio.to_thread import run_sync +from hermes_constants import get_hermes_home + + +def _freeze(value): + if isinstance(value, dict): + return (type(value), frozenset((_freeze(k), _freeze(v)) for k, v in value.items())) + if isinstance(value, (list, tuple)): + return (type(value), tuple(map(_freeze, value))) + if isinstance(value, (set, frozenset)): + return (type(value), frozenset(map(_freeze, value))) + return (type(value), value) + + +def coalesced_read(func, *, thread_runner=run_sync): + """Return an async, in-flight-only wrapper around a synchronous read. + + One worker per decorated function/event loop; identical home + bound + arguments share its result. Arguments must be hashable or built-in + containers of hashable values and must not be mutated during the read. + A custom async thread_runner must await the supplied zero-argument callable + through a context-preserving threadpool, not abandon running work. + """ + signature = inspect.signature(func) + hints = get_type_hints(func, include_extras=True) + signature = signature.replace( + parameters=[p.replace(annotation=hints.get(p.name, p.annotation)) + for p in signature.parameters.values()], + return_annotation=hints.get("return", signature.return_annotation), + ) + states = WeakKeyDictionary() + + @wraps(func) + async def wrapped(*args, **kwargs): + loop = asyncio.get_running_loop() + bound = signature.bind(*args, **kwargs) + bound.apply_defaults() + key = (str(get_hermes_home()), _freeze(bound.arguments)) + pending, admission = states.setdefault(loop, ({}, asyncio.Semaphore(1))) + task = pending.get(key) + if task is None: + async def execute(): + try: + async with admission: + return await thread_runner(partial(func, *args, **kwargs)) + finally: + pending.pop(key, None) + if not pending: + states.pop(loop, None) + task = loop.create_task(execute()) + pending[key] = task + # Observe failures even if every HTTP caller has disconnected. + task.add_done_callback(lambda done: None if done.cancelled() else done.exception()) + # A disconnected caller must not release admission for a live worker. + return await asyncio.shield(task) + + wrapped.__signature__ = signature + wrapped.__annotations__ = hints + return wrapped \ No newline at end of file diff --git a/hermes_cli/web_server_dashboard.py b/hermes_cli/web_server_dashboard.py new file mode 100644 index 0000000000000000000000000000000000000000..5fdbb2e0b6abf8f5419df7f2b63d286b224e5e97 --- /dev/null +++ b/hermes_cli/web_server_dashboard.py @@ -0,0 +1,836 @@ +"""Dashboard UI assets: SPA mount, theme normalisation/bootstrap CSS, dashboard-plugin discovery and the plugins-hub merge. +""" + +import logging +import importlib.util +import json +import os +import sys +import threading +import time +import yaml +from fastapi import FastAPI, Request +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response +from fastapi.staticfiles import StaticFiles +from pathlib import Path +from typing import Any, Dict, List, Optional +from hermes_cli.config import cfg_get, get_process_hermes_home +from utils import env_var_enabled + +# Same logger the code used before extraction (record parity). +_log = logging.getLogger("hermes_cli.web_server") + + +def _normalise_prefix(raw: Optional[str]) -> str: + """Normalise an X-Forwarded-Prefix header value (single source of truth lives in + ``hermes_cli.dashboard_auth.prefix`` so gate, OAuth, cookies and SPA mount agree).""" + from hermes_cli.dashboard_auth.prefix import normalise_prefix + return normalise_prefix(raw) + + +def _layer_hex(palette: Dict[str, Any], key: str, default: str) -> str: + layer = palette.get(key) or {} + return layer.get("hex", default) if isinstance(layer, dict) else default + + +def _render_active_theme_bootstrap_css() -> str: + """Critical-CSS ```` escape + return str(s).replace("' + ":root{" + f"--background-base:{_esc(_layer_hex(palette, 'background', '#0a0a0a'))};" + f"--midground-base:{_esc(_layer_hex(palette, 'midground', '#e5e5e5'))};" + f"--theme-font-sans:{_esc(font_sans)};" + f"--theme-base-size:{_esc(base_size)};" + "}" + "html,body{background-color:var(--background-base);" + "color:var(--midground-base);" + "font-family:var(--theme-font-sans);" + "font-size:var(--theme-base-size);}" + "" + ) + return "" + except Exception: + _log.debug("theme bootstrap render failed", exc_info=True) + return "" + + +# Hashed bundle assets are immutable by construction (content hash in the filename; index.html +# is served ``no-store`` and always references the current hashes). +_IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable" +_NO_STORE = {"Cache-Control": "no-store, no-cache, must-revalidate"} +_HEADLESS_MSG = ( + "Headless backend (hermes serve): web UI disabled — use " + "`hermes dashboard` for the browser UI." +) + + +def mount_spa(application: FastAPI): + """Mount the built SPA; unmatched paths fall back to index.html for client-side routing. + + The session token is injected into index.html via a ``{_HEADLESS_MSG}", + headers=_NO_STORE, + ) + return JSONResponse({"error": _HEADLESS_MSG}, status_code=404) + return + + # A missing WEB_DIST is deliberately NOT a mount-time terminal state (#82614): a long-lived `hermes + # dashboard --skip-build` process that survives a `git pull` (or starts before the first build) used to + # install a permanent no_frontend catch-all here and could never recover — every route answered 404 + # "Frontend not built" until the process was restarted, even after `npm run build` completed. The SPA + # routes below all cope with a missing dist per-request (`_serve_index` returns the same 404 JSON when + # index.html is unreadable; the asset mounts use check_dir=False and 404 on missing files), so mounting + # them unconditionally makes the dashboard recover the moment a build appears on disk — no restart + # needed. + def _serve_index(prefix: str = ""): + """index.html with the session token + base-path injected. + + When the OAuth auth gate is active (``app.state.auth_required``), the legacy + ``_SESSION_TOKEN`` is NOT injected — the SPA reads identity from ``/api/auth/me`` over + cookie auth; ``__HERMES_AUTH_REQUIRED__`` tells it which scheme to use for /api/pty + and /api/ws (ticket vs token). + """ + try: + html = (WEB_DIST / "index.html").read_text(encoding="utf-8") + except OSError: + # Partial build / wiped dist / permissions: same JSON 404 as a fully-missing dist. + return JSONResponse({"error": "Frontend not built. Run: cd web && npm run build"}, status_code=404) + chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false" + gated = bool(getattr(app.state, "auth_required", False)) + token_js = "" if gated else f'window.__HERMES_SESSION_TOKEN__="{_server()._SESSION_TOKEN}";' + bootstrap_script = ( + f"" + ) + if prefix: + # Rewrite absolute asset URLs baked into the Vite build to go through the proxy. + for attr in ('href="/assets/', 'src="/assets/', 'href="/favicon.ico"', 'href="/fonts/', + 'href="/ds-assets/', 'src="/ds-assets/'): + html = html.replace(attr, attr.replace('"/', f'"{prefix}/', 1)) + theme_bootstrap = _render_active_theme_bootstrap_css() + if theme_bootstrap: + html = html.replace("", f"{theme_bootstrap}", 1) + html = html.replace("", f"{bootstrap_script}", 1) + return HTMLResponse(html, headers=_NO_STORE) + + # Built CSS contains absolute ``url(/fonts/...)`` / ``url(/ds-assets/...)`` references that + # browsers resolve against the document origin — wrong under a proxy prefix. Intercept CSS + # BEFORE the StaticFiles mount and rewrite when a prefix is in play. + @application.get("/assets/{filename}.css") + async def serve_css(filename: str, request: Request): + css_path = WEB_DIST / "assets" / f"{filename}.css" + if not css_path.is_file() or not css_path.resolve().is_relative_to(WEB_DIST.resolve()): + return JSONResponse({"error": "not found"}, status_code=404) + prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) + css = css_path.read_text(encoding="utf-8") + if prefix: + for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"): + for quote in ("", '"', "'"): + css = css.replace(f"url({quote}{asset_dir}", f"url({quote}{prefix}{asset_dir}") + return Response( + content=css, media_type="text/css", headers={"Cache-Control": _IMMUTABLE_ASSET_CACHE_CONTROL} + ) + + class _ImmutableAssetFiles(StaticFiles): + """StaticFiles that marks hashed bundle assets immutable so reloads skip revalidation.""" + + async def get_response(self, path: str, scope): + response = await super().get_response(path, scope) + if response.status_code == 200: + response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL + return response + + # check_dir=False: the dist may not exist yet; StaticFiles 404s per-request until it does. + application.mount( + "/assets", _ImmutableAssetFiles(directory=WEB_DIST / "assets", check_dir=False), name="assets" + ) + + @application.get("/{full_path:path}") + async def serve_spa(full_path: str, request: Request): + prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) + # An unmatched /api/* path is a missing endpoint, not a client-side route: return a + # real 404 JSON instead of index.html (which breaks JSON clients with a SyntaxError). + if full_path == "api" or full_path.startswith("api/"): + return JSONResponse({"detail": f"No such API endpoint: /{full_path}"}, status_code=404) + file_path = WEB_DIST / full_path + # Prevent path traversal via url-encoded sequences (%2e%2e/) + if ( + full_path + and file_path.resolve().is_relative_to(WEB_DIST.resolve()) + and file_path.exists() + and file_path.is_file() + ): + return FileResponse(file_path) + return _serve_index(prefix) + + +# --------------------------------------------------------------------------- +# Dashboard themes +# --------------------------------------------------------------------------- + +# Built-in themes — label + description only; colors live in web/src/themes/presets.ts. +_BUILTIN_DASHBOARD_THEMES = [ + {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, + {"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"}, + {"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"}, + {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, + {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"}, + {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"}, + {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"}, + {"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"}, +] + + +def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[Dict[str, Any]]: + """Normalise a theme layer spec (bare hex shorthand or ``{hex, alpha}`` dict); ``None`` on + garbage so the caller falls back to a built-in default.""" + if value is None: + return {"hex": default_hex, "alpha": default_alpha} + if isinstance(value, str): + return {"hex": value, "alpha": default_alpha} + if not isinstance(value, dict): + return None + hex_val = value.get("hex", default_hex) + if not isinstance(hex_val, str): + return None + try: + alpha_f = float(value.get("alpha", default_alpha)) + except (TypeError, ValueError): + alpha_f = default_alpha + return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))} + + +_THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = { + "fontSans": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + "fontMono": 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace', + "baseSize": "15px", + "lineHeight": "1.55", + "letterSpacing": "0", +} +_THEME_DEFAULT_LAYOUT: Dict[str, str] = { + "radius": "0.5rem", "density": "comfortable" +} +_THEME_OVERRIDE_KEYS = { + "card", "cardForeground", "popover", "popoverForeground", + "primary", "primaryForeground", "secondary", "secondaryForeground", + "muted", "mutedForeground", "accent", "accentForeground", + "destructive", "destructiveForeground", "success", "warning", + "border", "input", "ring", +} + +# Named asset slots; other keys under ``assets.custom`` become ``--theme-asset-custom-``. +_THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"} + +# Component-style buckets: each camelCase property under a bucket emits +# ``--component--`` on :root, consumed by shell components. +_THEME_COMPONENT_BUCKETS = { + "card", "header", "footer", "sidebar", "tab", "progress", "badge", "backdrop", "page" +} +_THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"} + +# customCSS cap so an oversized theme YAML can't blow up the payload or