SaylorTwift HF Staff commited on
Commit
a200053
·
verified ·
1 Parent(s): 419d78a

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .hadolint.yaml +36 -0
  2. Dockerfile +470 -0
  3. LICENSE +21 -0
  4. README.es.md +220 -0
  5. README.ur-pk.md +261 -0
  6. batch_runner.py +1008 -0
  7. cli-config.yaml.example +0 -0
  8. constraints-termux.txt +15 -0
  9. docker-compose.windows.yml +38 -0
  10. flake.lock +162 -0
  11. hermes_bootstrap.py +119 -0
  12. hermes_cli/_scan_venv_blockers.py +321 -0
  13. hermes_cli/anon_auth.py +869 -0
  14. hermes_cli/auth_xai.py +582 -0
  15. hermes_cli/cli_output.py +70 -0
  16. hermes_cli/colors.py +30 -0
  17. hermes_cli/curator.py +710 -0
  18. hermes_cli/desktop_update_verify.py +95 -0
  19. hermes_cli/focus_view.py +104 -0
  20. hermes_cli/goal_command.py +194 -0
  21. hermes_cli/inventory.py +749 -0
  22. hermes_cli/kanban_db_dispatch.py +0 -0
  23. hermes_cli/kanban_pr_acceptance_store.py +45 -0
  24. hermes_cli/main_install_repair.py +1245 -0
  25. hermes_cli/main_provider_setup.py +946 -0
  26. hermes_cli/main_tui_launch.py +873 -0
  27. hermes_cli/mcp_picker.py +242 -0
  28. hermes_cli/memory_setup.py +466 -0
  29. hermes_cli/middleware.py +239 -0
  30. hermes_cli/models_pricing.py +591 -0
  31. hermes_cli/provider_catalog.py +117 -0
  32. hermes_cli/session_export_md.py +219 -0
  33. hermes_cli/shared_session_attach.py +91 -0
  34. hermes_cli/sizefmt.py +20 -0
  35. hermes_cli/skin_engine.py +556 -0
  36. hermes_cli/slack_cli.py +155 -0
  37. hermes_cli/status.py +397 -0
  38. hermes_cli/update_cmd_git.py +490 -0
  39. hermes_cli/update_cmd_stash.py +375 -0
  40. hermes_cli/web_read_coalescing.py +66 -0
  41. hermes_cli/web_server_dashboard.py +836 -0
  42. hermes_cli/web_server_sessions.py +298 -0
  43. hermes_constants.py +1293 -0
  44. hermes_state_registry.py +462 -0
  45. hermes_state_repair.py +1152 -0
  46. hermes_state_sessions.py +1627 -0
  47. hermes_state_telegram.py +359 -0
  48. hermes_state_titles.py +179 -0
  49. hermes_state_wal.py +622 -0
  50. optional-skills/DESCRIPTION.md +24 -0
.hadolint.yaml ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # hadolint configuration for the Hermes Agent Dockerfile.
2
+ # See https://github.com/hadolint/hadolint#configure for rules.
3
+ #
4
+ # We want hadolint to surface NEW Dockerfile lint regressions, but we
5
+ # don't want to rewrite the existing image to silence rules that are
6
+ # either intentional or pragmatic tradeoffs for this project. Each
7
+ # ignore below has a one-line justification.
8
+ failure-threshold: warning
9
+
10
+ ignored:
11
+ # Pin versions in apt get install. We intentionally don't pin common
12
+ # tools (curl, git, openssh-client, etc.) — security updates flow in
13
+ # via the periodic base-image rebuild, and pinning would lock us to
14
+ # superseded patch releases. Same rationale as nearly every distro-
15
+ # base official image (python, node, debian).
16
+ - DL3008
17
+ # Use WORKDIR to switch to a directory. The image uses `(cd web && …)`
18
+ # / `(cd ../ui-tui && …)` inline subshells for one-off build steps
19
+ # because they don't affect later RUN commands; promoting them to
20
+ # full WORKDIR switches with restores would obscure intent.
21
+ - DL3003
22
+ # Multiple consecutive RUN instructions. The `touch README.md` + `uv
23
+ # sync` split is intentional — `touch` is cheap, `uv sync` is the
24
+ # expensive layer-cached step we want isolated, and merging them
25
+ # would invalidate the cache for trivial changes.
26
+ - DL3059
27
+ # Last USER should not be root. /init (s6-overlay) runs as root so the
28
+ # stage2 hook can usermod/groupmod and chown the data volume per
29
+ # HERMES_UID at runtime; each supervised service then drops to the
30
+ # hermes user via `s6-setuidgid`.
31
+ - DL3002
32
+
33
+ # Require explicit base-image pins (SHA256) — we already do this.
34
+ trustedRegistries:
35
+ - docker.io
36
+ - ghcr.io
Dockerfile ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Debian 13 still ships SQLite 3.46.1, which contains the upstream WAL-reset
2
+ # corruption bug. Build a pinned shared library for the runtime image instead
3
+ # of relying on a distro backport that trixie does not currently provide.
4
+ # See #70480 and https://sqlite.org/wal.html#walresetbug.
5
+ FROM debian:13.4 AS sqlite_build
6
+ ARG SQLITE_AUTOCONF_VERSION=3530400
7
+ ARG SQLITE_SHA256=0e9483900e92cd5de8fd48d16bf9200145a61f7fd5be542a5ac81d8a9516eb9c
8
+ RUN apt-get -o Acquire::Retries=3 update && \
9
+ apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
10
+ build-essential ca-certificates curl && \
11
+ rm -rf /var/lib/apt/lists/* && \
12
+ (curl -fsSL --retry 1 --retry-all-errors --connect-timeout 15 --max-time 60 \
13
+ -o /tmp/sqlite.tar.gz \
14
+ "https://sqlite.org/2026/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz" || \
15
+ curl -fsSL --retry 3 --retry-all-errors --connect-timeout 15 --max-time 120 \
16
+ -o /tmp/sqlite.tar.gz \
17
+ "https://sources.buildroot.net/sqlite/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz") && \
18
+ printf '%s %s\n' "${SQLITE_SHA256}" /tmp/sqlite.tar.gz > /tmp/sqlite.sha256 && \
19
+ sha256sum -c /tmp/sqlite.sha256 && \
20
+ tar -xzf /tmp/sqlite.tar.gz -C /tmp && \
21
+ cd "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}" && \
22
+ CFLAGS="-O2 \
23
+ -DSQLITE_ENABLE_FTS3 \
24
+ -DSQLITE_ENABLE_FTS3_PARENTHESIS \
25
+ -DSQLITE_ENABLE_FTS4 \
26
+ -DSQLITE_ENABLE_FTS5 \
27
+ -DSQLITE_ENABLE_RTREE \
28
+ -DSQLITE_ENABLE_GEOPOLY \
29
+ -DSQLITE_ENABLE_COLUMN_METADATA \
30
+ -DSQLITE_ENABLE_UNLOCK_NOTIFY \
31
+ -DSQLITE_ENABLE_DBSTAT_VTAB \
32
+ -DSQLITE_ENABLE_DBPAGE_VTAB \
33
+ -DSQLITE_ENABLE_MATH_FUNCTIONS \
34
+ -DSQLITE_ENABLE_PREUPDATE_HOOK \
35
+ -DSQLITE_ENABLE_SESSION \
36
+ -DSQLITE_SECURE_DELETE \
37
+ -DSQLITE_THREADSAFE=1 \
38
+ -DSQLITE_MAX_VARIABLE_NUMBER=250000" \
39
+ ./configure --prefix=/opt/sqlite-fixed --disable-static && \
40
+ make -j"$(nproc)" && \
41
+ make install
42
+
43
+ FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
44
+ # Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x
45
+ # which reached EOL in April 2026 — we copy node + npm from the upstream
46
+ # node:26 image instead (Hermes pins its toolchain to Node 26 everywhere).
47
+ # Bookworm-based slim image used so the produced binary links
48
+ # against glibc 2.36, which runs cleanly on our Debian 13 (trixie, glibc
49
+ # 2.41) runtime. Bumping to a new Node major is a one-line ARG change; see
50
+ # #4977.
51
+ FROM node:26-bookworm-slim@sha256:9e6f9357d371591e32ab6f2d8a26d63bdd0d17c29eee3f4f3e7e454d9634bf73 AS node_source
52
+ FROM debian:13.4
53
+
54
+ # Disable Python stdout buffering to ensure logs are printed immediately.
55
+ # Do not write .pyc files at runtime: /opt/hermes is immutable in the
56
+ # published container and writable state belongs under /opt/data.
57
+ ENV PYTHONUNBUFFERED=1
58
+ ENV PYTHONDONTWRITEBYTECODE=1
59
+
60
+ # Store Playwright browsers outside the volume mount so the build-time
61
+ # install survives the /opt/data volume overlay at runtime.
62
+ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
63
+
64
+ # Install system dependencies in one layer, clear APT cache.
65
+ # tini was previously PID 1 to reap orphaned zombie processes (MCP stdio
66
+ # subprocesses, git, bun, etc.) that would otherwise accumulate when hermes
67
+ # ran as PID 1. See #15012. Phase 2 of the s6-overlay supervision plan
68
+ # replaces tini with s6-overlay's /init (PID 1 = s6-svscan), which reaps
69
+ # zombies non-blockingly on SIGCHLD and additionally supervises the main
70
+ # hermes process, the dashboard, and per-profile gateways.
71
+ RUN apt-get -o Acquire::Retries=3 update && \
72
+ apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
73
+ 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 && \
74
+ rm -rf /var/lib/apt/lists/*
75
+
76
+ # Prefer the fixed SQLite over Debian's vulnerable libsqlite3.so.0. Keep the
77
+ # public library name stable so both the system interpreter and the uv-created
78
+ # venv resolve the replacement without changing Python import paths.
79
+ COPY --from=sqlite_build /opt/sqlite-fixed/lib/libsqlite3.so.3.53.4 /usr/local/lib/
80
+ RUN ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so.0 && \
81
+ ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so && \
82
+ printf '/usr/local/lib\n' > /etc/ld.so.conf.d/000-sqlite-fixed.conf && \
83
+ ldconfig && \
84
+ python3 -c "import sqlite3, sys; \
85
+ v = sqlite3.sqlite_version_info; \
86
+ sys.exit(f'linked SQLite {sqlite3.sqlite_version} still has the WAL-reset bug') if v < (3, 51, 3) else None; \
87
+ db = sqlite3.connect(':memory:'); \
88
+ db.execute(\"CREATE VIRTUAL TABLE docs USING fts5(content, tokenize='trigram')\"); \
89
+ db.execute(\"INSERT INTO docs VALUES ('hermes')\"); \
90
+ sys.exit('SQLite FTS5 trigram self-test failed') if db.execute(\"SELECT count(*) FROM docs WHERE docs MATCH 'erm'\").fetchone()[0] != 1 else None; \
91
+ db.close()"
92
+
93
+ # ---------- s6-overlay install ----------
94
+ # s6-overlay provides supervision for the main hermes process, the dashboard,
95
+ # and per-profile gateways. /init becomes PID 1 below — see ENTRYPOINT.
96
+ #
97
+ # Multi-arch: BuildKit auto-populates TARGETARCH (amd64 / arm64). s6-overlay
98
+ # uses tarball names keyed on the kernel arch string (x86_64 / aarch64), so
99
+ # we map between them inline. The noarch + symlinks tarballs are
100
+ # architecture-independent and reused as-is.
101
+ #
102
+ # We use `curl` instead of `ADD` for ALL three tarballs: `ADD` evaluates its
103
+ # URL at parse time (no ARG / TARGETARCH substitution) and — critically for
104
+ # CI reliability — cannot retry, so a single GitHub-release CDN blip fails
105
+ # the whole 15-45 min build. curl -fsSL --retry 3 self-heals those blips,
106
+ # and every tarball is still checksum-verified below before extraction.
107
+ ARG TARGETARCH
108
+ ARG S6_OVERLAY_VERSION=3.2.3.0
109
+ ARG S6_OVERLAY_NOARCH_SHA256=b720f9d9340efc8bb07528b9743813c836e4b02f8693d90241f047998b4c53cf
110
+ ARG S6_OVERLAY_X86_64_SHA256=a93f02882c6ed46b21e7adb5c0add86154f01236c93cd82c7d682722e8840563
111
+ ARG S6_OVERLAY_AARCH64_SHA256=0952056ff913482163cc30e35b2e944b507ba1025d78f5becbb89367bf344581
112
+ ARG S6_OVERLAY_SYMLINKS_SHA256=a60dc5235de3ecbcf874b9c1f18d73263ab99b289b9329aa950e8729c4789f0e
113
+ RUN set -eu; \
114
+ case "${TARGETARCH:-amd64}" in \
115
+ amd64) s6_arch="x86_64"; s6_arch_sha="${S6_OVERLAY_X86_64_SHA256}" ;; \
116
+ arm64) s6_arch="aarch64"; s6_arch_sha="${S6_OVERLAY_AARCH64_SHA256}" ;; \
117
+ *) echo "Unsupported TARGETARCH=${TARGETARCH} for s6-overlay" >&2; exit 1 ;; \
118
+ esac; \
119
+ base="https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}"; \
120
+ curl -fsSL --retry 3 -o /tmp/s6-overlay-noarch.tar.xz \
121
+ "${base}/s6-overlay-noarch.tar.xz"; \
122
+ curl -fsSL --retry 3 -o /tmp/s6-overlay-symlinks-noarch.tar.xz \
123
+ "${base}/s6-overlay-symlinks-noarch.tar.xz"; \
124
+ curl -fsSL --retry 3 -o /tmp/s6-overlay-arch.tar.xz \
125
+ "${base}/s6-overlay-${s6_arch}.tar.xz"; \
126
+ { \
127
+ printf '%s %s\n' "${S6_OVERLAY_NOARCH_SHA256}" /tmp/s6-overlay-noarch.tar.xz; \
128
+ printf '%s %s\n' "${s6_arch_sha}" /tmp/s6-overlay-arch.tar.xz; \
129
+ printf '%s %s\n' "${S6_OVERLAY_SYMLINKS_SHA256}" /tmp/s6-overlay-symlinks-noarch.tar.xz; \
130
+ } > /tmp/s6-overlay.sha256; \
131
+ sha256sum -c /tmp/s6-overlay.sha256; \
132
+ tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz; \
133
+ tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz; \
134
+ tar -C / -Jxpf /tmp/s6-overlay-symlinks-noarch.tar.xz; \
135
+ rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256
136
+
137
+ # #34192 / #66679: backward-compat shim for orchestration templates that
138
+ # still reference the legacy /usr/bin/tini entrypoint (Hostinger's
139
+ # 'Hermes WebUI' catalog, NAS compose projects that preserve an old
140
+ # entrypoint on image update, etc.). A plain symlink to /init made the
141
+ # path exist, but forwarded tini flags like `-g` into s6-overlay's
142
+ # rc.init as the container CMD (`rc.init: 91: -g: not found`) and
143
+ # boot-looped any `restart: unless-stopped` deploy. The shim strips the
144
+ # tini CLI surface, then exec's /init + main-wrapper — see
145
+ # docker/tini-shim.sh. Safe to drop once the affected catalogs are
146
+ # updated.
147
+ COPY --chmod=0755 docker/tini-shim.sh /usr/bin/tini
148
+
149
+ # Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
150
+ RUN useradd -u 10000 -m -d /opt/data hermes
151
+
152
+ COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
153
+
154
+ # Node 26: copy the node binary plus the bundled npm JS install from the
155
+ # upstream image. npm and npx are recreated as symlinks because they're
156
+ # symlinks in the source image (and need to live on PATH).
157
+ #
158
+ # No corepack: Node unbundled it upstream, so node:26 ships only npm in
159
+ # /usr/local/lib/node_modules. Nothing here needs it — no package.json
160
+ # declares a `packageManager`, and no build step shells out to yarn or pnpm.
161
+ #
162
+ # See node_source stage at the top of the file for the version-bump
163
+ # rationale (#4977).
164
+ COPY --chmod=0755 --from=node_source /usr/local/bin/node /usr/local/bin/
165
+ COPY --from=node_source /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
166
+ RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
167
+ ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
168
+
169
+ WORKDIR /opt/hermes
170
+
171
+ # ---------- Layer-cached dependency install ----------
172
+ # Copy only package manifests first so npm install + Playwright are cached
173
+ # unless the lockfiles themselves change.
174
+ #
175
+ # ui-tui/packages/hermes-ink/ is copied IN FULL (not just its manifests)
176
+ # because it is referenced as a `file:` workspace dependency from
177
+ # ui-tui/package.json. Copying the tree up front lets npm resolve the
178
+ # workspace to real content instead of stopping at a bare package.json.
179
+ COPY package.json package-lock.json ./
180
+ COPY web/package.json web/
181
+ COPY ui-tui/package.json ui-tui/
182
+ COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
183
+ # apps/shared/ is copied IN FULL because web/package.json references it as a
184
+ # `file:` workspace dependency (same pattern as hermes-ink above).
185
+ COPY apps/shared/ apps/shared/
186
+
187
+ # `npm_config_install_links=false` forces npm to install `file:` deps as
188
+ # symlinks instead of copies. This is the default since npm 10+, which is
189
+ # what the image ships now (via the node:22 source stage). We set it
190
+ # explicitly anyway as defense-in-depth: the previous Debian-bundled npm
191
+ # 9.x defaulted to install-as-copy, which produced a hidden
192
+ # node_modules/.package-lock.json that permanently disagreed with the root
193
+ # lock on the @hermes/ink entry, tripped the TUI launcher's
194
+ # `_tui_need_npm_install()` check on every startup, and triggered a
195
+ # runtime `npm install` that then failed with EACCES. Keeping the env
196
+ # guards against a future regression if the source npm version changes.
197
+ ENV npm_config_install_links=false
198
+
199
+ RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \
200
+ for i in 1 2 3; do \
201
+ npx playwright install --with-deps chromium --only-shell && break || \
202
+ { [ "$i" = 3 ] && exit 1; echo "playwright install failed (attempt $i); retrying in 10s"; sleep 10; }; \
203
+ done && \
204
+ npm cache clean --force
205
+
206
+ # ---------- Photon iMessage sidecar deps (baked, NS-606) ----------
207
+ # The photon plugin's Node sidecar needs its own node_modules
208
+ # (spectrum-ts). The install tree is immutable at runtime, so a lazy
209
+ # `npm ci` on first connect would hit EROFS — bake the deps here instead
210
+ # (deterministic installs, NS-559). The patch script is copied alongside
211
+ # the manifests because package.json's postinstall runs it, which also
212
+ # means the spectrum-ts patch is applied at build time. Layer-cached:
213
+ # only re-runs when the sidecar manifests/patch change.
214
+ COPY plugins/platforms/photon/sidecar/package.json \
215
+ plugins/platforms/photon/sidecar/package-lock.json \
216
+ plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs \
217
+ plugins/platforms/photon/sidecar/
218
+ RUN cd plugins/platforms/photon/sidecar && \
219
+ npm ci --no-audit --fetch-retries=5 && \
220
+ npm cache clean --force
221
+
222
+ # ---------- Layer-cached Python dependency install ----------
223
+ # Copy only pyproject.toml + uv.lock so the Python dep resolve + wheel
224
+ # download + native-extension compile layer is cached unless those inputs
225
+ # change. Before this split the Python install sat after `COPY . .`, so
226
+ # every source-only commit re-did ~4-5 min of dep work on cold builds.
227
+ #
228
+ # README.md is referenced by pyproject.toml's `readme =` field, but it's
229
+ # excluded from the build context by .dockerignore's `*.md`. uv's build
230
+ # frontend stats the readme path during dep resolution, so we `touch` an
231
+ # empty placeholder — the real README is restored by `COPY . .` below.
232
+ #
233
+ # `uv sync --frozen --no-install-project --extra all --extra messaging --extra otlp`
234
+ # installs the deps reachable through the composite `[all]` extra
235
+ # (handpicked set intended for the production image — excludes `[dev]`),
236
+ # plus gateway messaging adapters that should work in the published image
237
+ # without a first-boot lazy install. We do NOT use `--all-extras`:
238
+ # that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
239
+ # git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
240
+ # redundancy), none of which belong in the published container.
241
+ #
242
+ # Provider packages (anthropic, bedrock, azure-identity) are included
243
+ # so Docker users can use these providers without requiring runtime
244
+ # lazy-install access to PyPI (often blocked in containerized envs).
245
+ #
246
+ # The [otlp] extra contains the SDK/exporter imported by Hermes when Gateway
247
+ # Health export is enabled. Collector and observability-backend dependencies
248
+ # remain external and are not part of the Hermes production image.
249
+ #
250
+ # The hindsight memory provider's client (hindsight-client) is baked in
251
+ # for the same reason: it lazy-installs into /opt/hermes/.venv at first
252
+ # use, which lives inside the (immutable) image layer rather than the
253
+ # mounted /opt/data volume, so it is lost on every container recreate /
254
+ # image update and recall/retain then fails with
255
+ # `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
256
+ #
257
+ # The Matrix gateway's deps ([matrix] extra) are baked in because
258
+ # python-olm (transitive via mautrix[encryption]) builds from source on
259
+ # Python/image combinations without usable wheels. The Docker image is
260
+ # Linux-only, so keeping the native libolm/build-toolchain packages here
261
+ # avoids the cross-platform failures that kept [matrix] out of [all]
262
+ # while still making Matrix work in the published container. Fixes #30399.
263
+ #
264
+ # Google Chat's [google-chat] extra (google-cloud-pubsub + Chat API clients)
265
+ # is baked so hosted/immutable images can enable the adapter without writing
266
+ # the sealed venv. Runtime --install-deps still routes through lazy_deps into
267
+ # HERMES_LAZY_INSTALL_TARGET when the extra is not present.
268
+ #
269
+ # The editable link is created after the source copy below.
270
+ COPY pyproject.toml uv.lock ./
271
+ RUN touch ./README.md
272
+ 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
273
+
274
+ # ---------- Frontend build (cached independently from Python source) ----------
275
+ # Copy only the frontend source trees first so that Python-only changes don't
276
+ # invalidate the (relatively slow) web + ui-tui build layer.
277
+ COPY web/ web/
278
+ COPY ui-tui/ ui-tui/
279
+ COPY apps/shared/ apps/shared/
280
+ RUN cd web && npm run build && \
281
+ cd ../ui-tui && npm run build
282
+
283
+ # ---------- Source code ----------
284
+ # .dockerignore excludes node_modules, so the installs above survive.
285
+ # --link decouples this layer from parents for cache purposes; --chmod bakes
286
+ # the final read-only permissions at copy time so we skip the separate
287
+ # `chmod -R` pass that previously walked ~30k files across the venv +
288
+ # node_modules + source (21s amd64 / 222s arm64 — #49113). `a+rX,go-w`
289
+ # gives the non-root hermes user read + traverse but no write; root retains
290
+ # write so the build steps below don't need chmod u+w dances.
291
+ COPY --link --chmod=a+rX,go-w . .
292
+
293
+ # ---------- Permissions ----------
294
+ # Link hermes-agent itself (editable). Deps are already installed in the
295
+ # cached layer above; `--no-deps` makes this a fast egg-link creation with no
296
+ # resolution or downloads.
297
+ RUN uv pip install --no-cache-dir --no-deps -e "."
298
+
299
+ # Wire the exec shim and install-method stamp. Files under /opt/hermes are
300
+ # already root-owned (COPY, uv sync, npm install all run as root) and
301
+ # read-only for the hermes user (go-w from the --chmod above).
302
+
303
+ USER root
304
+ RUN mkdir -p /opt/hermes/bin && \
305
+ cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
306
+ chmod 0755 /opt/hermes /opt/hermes/bin/hermes && \
307
+ printf 'docker\n' > /opt/hermes/.install_method
308
+ # The ``.install_method`` stamp is baked next to the running code (the install
309
+ # tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
310
+ # volume that is commonly bind-mounted from the host and even shared with a
311
+ # host-side Desktop/CLI install; stamping it at boot used to clobber that
312
+ # host install's marker and wrongly block its ``hermes update``. A code-scoped
313
+ # stamp is read first by detect_install_method() and is immune to the share.
314
+ # Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
315
+ # the data volume. Each supervised service then drops to the hermes user via
316
+ # `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
317
+ # run as the default hermes user (UID 10000).
318
+
319
+ # ---------- Bake image provenance + build-time git revision ----------
320
+ # The versioned, non-secret provenance marker is the authoritative runtime
321
+ # signal that this filesystem came from an immutable image. It deliberately
322
+ # lives outside both /opt/hermes (which operators sometimes bind-mount as a
323
+ # checkout) and /opt/data (the mutable HERMES_HOME volume).
324
+ # .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
325
+ # container always returns nothing — meaning `hermes dump` reports
326
+ # "(unknown)" and the startup banner drops its `· upstream <sha>` suffix.
327
+ # That makes support triage from container bug reports impossible:
328
+ # we can't tell which commit the user is actually running.
329
+ #
330
+ # Fix: write the commit SHA passed via the HERMES_GIT_SHA build-arg to
331
+ # /opt/hermes/.hermes_build_sha at build time, and have
332
+ # hermes_cli/build_info.py read it at runtime. Both `hermes dump` and
333
+ # banner.get_git_banner_state() try the baked SHA first, then fall back
334
+ # to live `git rev-parse` for source installs (unchanged behaviour).
335
+ #
336
+ # The arg is optional — local `docker build` without --build-arg omits the
337
+ # SHA file (and records a null provenance revision), so build-info falls back
338
+ # to live-git lookup. CI
339
+ # (.github/workflows/docker.yml) passes ${{ github.sha }} so
340
+ # every published image has it.
341
+ ARG HERMES_GIT_SHA=
342
+ RUN set -eu; \
343
+ if [ -n "${HERMES_GIT_SHA}" ]; then \
344
+ printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \
345
+ fi; \
346
+ mkdir -p /etc/hermes; \
347
+ 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)'
348
+
349
+ # ---------- s6-overlay service wiring ----------
350
+ # Static services declared at build time: main-hermes + dashboard.
351
+ # Per-profile gateway services are registered dynamically at runtime by
352
+ # the profile create/delete hooks (Phase 4); they live under
353
+ # /run/service/ (tmpfs) and are reconciled on container restart by
354
+ # /etc/cont-init.d/02-reconcile-profiles (Phase 4 Task 4.0).
355
+ COPY docker/s6-rc.d/ /etc/s6-overlay/s6-rc.d/
356
+
357
+ # stage2-hook handles UID/GID remap, volume chown, config seeding,
358
+ # skills sync — all the work the old entrypoint.sh did before
359
+ # `exec hermes`. Wired in as cont-init.d/01- so it
360
+ # runs before user services start.
361
+ #
362
+ # 02-reconcile-profiles re-creates per-profile gateway s6 service
363
+ # slots from $HERMES_HOME/profiles/<name>/ after a container restart
364
+ # (the /run/service/ scandir is tmpfs and wiped on restart). Phase 4.
365
+ RUN mkdir -p /etc/cont-init.d && \
366
+ printf '#!/command/with-contenv sh\nexec /opt/hermes/docker/stage2-hook.sh\n' \
367
+ > /etc/cont-init.d/01-hermes-setup && \
368
+ chmod +x /etc/cont-init.d/01-hermes-setup
369
+ COPY --chmod=0755 docker/cont-init.d/015-supervise-perms /etc/cont-init.d/015-supervise-perms
370
+ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-reconcile-profiles
371
+
372
+ # ---------- Runtime ----------
373
+ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
374
+ # Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8:
375
+ # `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle
376
+ # fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the
377
+ # _tui_need_npm_install / runtime `npm install` branch entirely — exactly the
378
+ # nix/packaged-release path the launcher was designed for.
379
+ #
380
+ # Why this is required (not just an optimization): the root package-lock.json
381
+ # describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*),
382
+ # but the image only installs root/web/ui-tui (apps/* — the desktop app — is
383
+ # never `npm install`ed here). So the actualized node_modules permanently
384
+ # disagrees with the canonical lock, _tui_need_npm_install() returns True on
385
+ # every launch, and the runtime `npm install` it triggers (a) can never
386
+ # converge against the partial monorepo and (b) races itself across concurrent
387
+ # embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a
388
+ # 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
389
+ # check. (A separate launcher hardening is tracked independently.)
390
+ ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
391
+ ENV HERMES_HOME=/opt/data
392
+ ENV HERMES_WRITE_SAFE_ROOT=/opt/data
393
+ ENV HERMES_DISABLE_LAZY_INSTALLS=1
394
+ # The published image seals /opt/hermes (root-owned, read-only) so a runtime
395
+ # lazy install can't mutate the agent's own venv and brick it. But opt-in
396
+ # backends (Firecrawl web search, Exa, Feishu, …) keep their SDKs in
397
+ # tools/lazy_deps.py — deliberately NOT baked into [all] (see pyproject.toml
398
+ # policy 2026-05-12: one quarantined release must not break every install).
399
+ # Redirect those lazy installs to a writable dir on the durable data volume.
400
+ # lazy_deps appends this dir to the END of sys.path, so a package installed
401
+ # here can only ADD modules — it can never shadow or downgrade a core module,
402
+ # so the sealed-venv guarantee holds even with installs re-enabled. The dir
403
+ # is seeded + chowned to the hermes user by docker/stage2-hook.sh and lives
404
+ # on the /opt/data volume, so it persists across container recreates / image
405
+ # updates (an ABI stamp invalidates it if a rebuild bumps the interpreter).
406
+ ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages
407
+
408
+ # `docker exec` privilege-drop shim. When operators run
409
+ # `docker exec <c> hermes ...` they default to root, and any file the
410
+ # command writes under $HERMES_HOME (auth.json, .env, config.yaml) ends
411
+ # up root-owned and unreadable to the supervised gateway (UID 10000).
412
+ # The shim lives at /opt/hermes/bin/hermes, sits earliest on PATH, and
413
+ # transparently re-exec's the real venv binary via `s6-setuidgid hermes`
414
+ # when invoked as root. Non-root callers (supervised processes,
415
+ # `--user hermes`, etc.) hit the short-circuit path with no overhead.
416
+ # Recursion is impossible because the shim exec's the venv binary by
417
+ # absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
418
+ # the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
419
+ COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
420
+ COPY --chmod=0755 docker/entrypoint-dispatch.sh /opt/hermes/docker/entrypoint-dispatch.sh
421
+
422
+ # Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
423
+ # the venv bin onto PATH; Architecture B's main-wrapper.sh does the
424
+ # same for the container's main process, but `docker exec` and our
425
+ # cont-init.d scripts don't pass through the wrapper. Expose the venv
426
+ # bin globally so `docker exec <container> hermes ...` and any
427
+ # subprocess that doesn't activate the venv first still find hermes.
428
+ #
429
+ # /opt/hermes/bin is prepended ahead of the venv so the privilege-drop
430
+ # shim wins PATH resolution. The shim's last act is to exec the venv
431
+ # binary by absolute path, so this PATH ordering is transparent to
432
+ # every other consumer.
433
+ ENV PATH="/opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
434
+ RUN mkdir -p /opt/data
435
+ VOLUME [ "/opt/data" ]
436
+
437
+ # The image ENTRYPOINT is a tiny dispatcher rather than `/init` directly.
438
+ # When the image really owns PID 1 (normal Docker / Podman), the dispatcher
439
+ # execs `/init` and preserves the full s6 supervision tree. When a platform
440
+ # wraps the image entrypoint under its own PID-1 init (Fly Machines,
441
+ # `docker run --init`, some schedulers), `/init` would abort with
442
+ # `can only run as pid 1`; in that case the dispatcher falls back to
443
+ # `stage2-hook.sh` + `main-wrapper.sh` directly so foreground commands still
444
+ # work. See #38349.
445
+ #
446
+ # On the PID-1 path, s6-overlay's /init sets up the supervision tree, runs
447
+ # /etc/cont-init.d/* (our stage2 hook), starts s6-rc services declared in
448
+ # /etc/s6-overlay/s6-rc.d/, then exec's its remaining argv as the container's
449
+ # "main program" with stdin/stdout/stderr inherited (this is what makes
450
+ # interactive --tui work). When the main program exits, /init begins stage 3
451
+ # shutdown and the container exits with the program's exit code. Replaces
452
+ # tini — see Phase 2 of docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md.
453
+ #
454
+ # We use the ENTRYPOINT+CMD split rather than CMD alone so the
455
+ # wrapper is prepended to user-supplied args automatically:
456
+ #
457
+ # docker run <image> → entrypoint-dispatch.sh (CMD default)
458
+ # docker run <image> chat -q "hi" → entrypoint-dispatch.sh chat -q hi
459
+ # docker run <image> sleep infinity → entrypoint-dispatch.sh sleep infinity
460
+ # docker run <image> --tui → entrypoint-dispatch.sh --tui
461
+ #
462
+ # main-wrapper.sh handles arg routing (bare-exec vs. hermes
463
+ # subcommand vs. no-args), drops to the hermes user via s6-setuidgid,
464
+ # and exec's the final program so its exit code becomes the container
465
+ # exit code. The dispatcher preserves that contract across both the
466
+ # supervised PID-1 path and the non-PID-1 fallback path. Without the
467
+ # wrapper-as-ENTRYPOINT, leading-dash args like `--version` would be
468
+ # intercepted by /init's POSIX shell.
469
+ ENTRYPOINT [ "/opt/hermes/docker/entrypoint-dispatch.sh" ]
470
+ CMD [ ]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Nous Research
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.es.md ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <p align="center">
2
+ <img src="assets/banner.png" alt="Hermes Agent" width="100%">
3
+ </p>
4
+
5
+ # Hermes Agent ☤
6
+ <p align="center">
7
+ <a href="https://hermes-agent.nousresearch.com/">Hermes Agent</a> | <a href="https://hermes-agent.nousresearch.com/">Hermes Desktop</a>
8
+ </p>
9
+ <p align="center">
10
+ <a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentación"></a>
11
+ <a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
12
+ <a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/Licencia-MIT-green?style=for-the-badge" alt="Licencia: MIT"></a>
13
+ <a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Creado%20por-Nous%20Research-blueviolet?style=for-the-badge" alt="Creado por Nous Research"></a>
14
+ <a href="README.md"><img src="https://img.shields.io/badge/Lang-English-blue?style=for-the-badge" alt="English"></a>
15
+ <a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
16
+ <a href="README.ur-pk.md"><img src="https://img.shields.io/badge/Lang-اردو-green?style=for-the-badge" alt="اردو"></a>
17
+ </p>
18
+
19
+ **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.
20
+
21
+ 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.
22
+
23
+ <table>
24
+ <tr><td><b>Una interfaz de terminal real</b></td><td>TUI completa con edición multilínea, autocompletado de comandos, historial de conversaciones, interrupción y redirección, y salida de herramientas en streaming.</td></tr>
25
+ <tr><td><b>Vive donde tú vives</b></td><td>Telegram, Discord, Slack, WhatsApp, Signal y CLI — todo desde un único proceso gateway. Transcripción de notas de voz, continuidad de conversación entre plataformas.</td></tr>
26
+ <tr><td><b>Un bucle de aprendizaje cerrado</b></td><td>Memoria 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 <a href="https://github.com/plastic-labs/honcho">Honcho</a>. Compatible con el estándar abierto de <a href="https://agentskills.io">agentskills.io</a>.</td></tr>
27
+ <tr><td><b>Automatizaciones programadas</b></td><td>Planificador 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.</td></tr>
28
+ <tr><td><b>Delega y paraleliza</b></td><td>Lanza 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.</td></tr>
29
+ <tr><td><b>Funciona en cualquier lugar, no solo en tu laptop</b></td><td>Seis 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.</td></tr>
30
+ <tr><td><b>Listo para investigación</b></td><td>Generación de trayectorias en lote, compresión de trayectorias para entrenar la próxima generación de modelos de llamadas a herramientas.</td></tr>
31
+ </table>
32
+
33
+ ---
34
+
35
+ ## Instalación rápida
36
+
37
+ ### Linux, macOS, WSL2, Termux
38
+
39
+ ```bash
40
+ curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
41
+ ```
42
+
43
+ ### Windows (nativo, PowerShell)
44
+
45
+ > **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).
46
+
47
+ Ejecuta esto en PowerShell:
48
+
49
+ ```powershell
50
+ iex (irm https://hermes-agent.nousresearch.com/install.ps1)
51
+ ```
52
+
53
+ 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.
54
+
55
+ 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.
56
+
57
+ > **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.
58
+ >
59
+ > **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.
60
+
61
+ Después de la instalación:
62
+
63
+ ```bash
64
+ source ~/.bashrc # recargar shell (o: source ~/.zshrc)
65
+ hermes # ¡empieza a chatear!
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Primeros pasos
71
+
72
+ ```bash
73
+ hermes # CLI interactiva — inicia una conversación
74
+ hermes model # Elige tu proveedor y modelo LLM
75
+ hermes tools # Configura qué herramientas están habilitadas
76
+ hermes config set # Establece valores de configuración individuales
77
+ hermes gateway # Inicia el gateway de mensajería (Telegram, Discord, etc.)
78
+ hermes setup # Ejecuta el asistente de configuración completo
79
+ hermes claw migrate # Migra desde OpenClaw (si vienes de OpenClaw)
80
+ hermes update # Actualiza a la última versión
81
+ hermes doctor # Diagnostica cualquier problema
82
+ ```
83
+
84
+ 📖 **[Documentación completa →](https://hermes-agent.nousresearch.com/docs/)**
85
+
86
+ ---
87
+
88
+ ## Evita la colección de claves API — Nous Portal
89
+
90
+ 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:
91
+
92
+ - **Más de 300 modelos** — elige cualquiera con `/model <nombre>`
93
+ - **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.
94
+
95
+ Un comando desde una instalación nueva:
96
+
97
+ ```bash
98
+ hermes setup --portal
99
+ ```
100
+
101
+ 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).
102
+
103
+ Puedes seguir usando tus propias claves por herramienta cuando quieras — el gateway es por backend, no todo o nada.
104
+
105
+ ---
106
+
107
+ ## Referencia rápida: CLI vs Mensajería
108
+
109
+ 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.
110
+
111
+ | Acción | CLI | Plataformas de mensajería |
112
+ | ----------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------- |
113
+ | Empezar a chatear | `hermes` | Ejecuta `hermes gateway setup` + `hermes gateway start`, luego envía un mensaje al bot |
114
+ | Nueva conversación | `/new` o `/reset` | `/new` o `/reset` |
115
+ | Cambiar modelo | `/model [proveedor:modelo]` | `/model [proveedor:modelo]` |
116
+ | Establecer personalidad | `/personality [nombre]` | `/personality [nombre]` |
117
+ | Reintentar o deshacer último turno | `/retry`, `/undo` | `/retry`, `/undo` |
118
+ | Comprimir contexto / ver uso | `/compress`, `/usage`, `/insights [--days N]` | `/compress`, `/usage`, `/insights [days]` |
119
+ | Explorar habilidades | `/skills` o `/<nombre-habilidad>` | `/<nombre-habilidad>` |
120
+ | Interrumpir trabajo actual | `Ctrl+C` o enviar un nuevo mensaje | `/stop` o enviar un nuevo mensaje |
121
+ | Estado específico de plataforma | `/platforms` | `/status`, `/sethome` |
122
+
123
+ 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).
124
+
125
+ ---
126
+
127
+ ## Documentación
128
+
129
+ Toda la documentación está en **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)**:
130
+
131
+ | Sección | Contenido |
132
+ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
133
+ | [Inicio rápido](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | Instalar → configurar → primera conversación en 2 minutos |
134
+ | [Uso de CLI](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | Comandos, atajos de teclado, personalidades, sesiones |
135
+ | [Configuración](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | Archivo de configuración, proveedores, modelos, todas las opciones |
136
+ | [Gateway de Mensajería](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram, Discord, Slack, WhatsApp, Signal, Home Assistant |
137
+ | [Seguridad](https://hermes-agent.nousresearch.com/docs/user-guide/security) | Aprobación de comandos, emparejamiento por DM, aislamiento en contenedor |
138
+ | [Herramientas y Toolsets](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | Más de 40 herramientas, sistema de toolsets, backends de terminal |
139
+ | [Sistema de Habilidades](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | Memoria procedimental, Skills Hub, creación de habilidades |
140
+ | [Memoria](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | Memoria persistente, perfiles de usuario, mejores prácticas |
141
+ | [Integración MCP](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | Conecta cualquier servidor MCP para capacidades extendidas |
142
+ | [Programación Cron](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | Tareas programadas con entrega a plataforma |
143
+ | [Archivos de Contexto](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | Contexto de proyecto que da forma a cada conversación |
144
+ | [Arquitectura](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | Estructura del proyecto, bucle del agente, clases principales |
145
+ | [Contribuir](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | Configuración de desarrollo, proceso de PR, estilo de código |
146
+ | [Referencia de CLI](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | Todos los comandos y flags |
147
+ | [Variables de Entorno](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | Referencia completa de variables de entorno |
148
+
149
+ ---
150
+
151
+ ## Migración desde OpenClaw
152
+
153
+ Si vienes de OpenClaw, Hermes puede importar automáticamente tu configuración, memorias, habilidades y claves API.
154
+
155
+ **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.
156
+
157
+ **En cualquier momento después de instalar:**
158
+
159
+ ```bash
160
+ hermes claw migrate # Migración interactiva (preset completo)
161
+ hermes claw migrate --dry-run # Vista previa de qué se migraría
162
+ hermes claw migrate --preset user-data # Migrar sin secretos
163
+ hermes claw migrate --overwrite # Sobreescribir conflictos existentes
164
+ ```
165
+
166
+ Qué se importa:
167
+
168
+ - **SOUL.md** — archivo de personalidad
169
+ - **Memorias** — entradas de MEMORY.md y USER.md
170
+ - **Habilidades** — habilidades creadas por el usuario → `~/.hermes/skills/openclaw-imports/`
171
+ - **Lista de comandos permitidos** — patrones de aprobación
172
+ - **Configuración de mensajería** — configuración de plataformas, usuarios permitidos, directorio de trabajo
173
+ - **Claves API** — secretos en lista de permitidos (Telegram, OpenRouter, OpenAI, Anthropic, ElevenLabs)
174
+ - **Assets de TTS** — archivos de audio del espacio de trabajo
175
+ - **Instrucciones del espacio de trabajo** — AGENTS.md (con `--workspace-target`)
176
+
177
+ 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.
178
+
179
+ ---
180
+
181
+ ## Contribuir
182
+
183
+ ¡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.
184
+
185
+ Inicio rápido para colaboradores — clona y comienza con `setup-hermes.sh`:
186
+
187
+ ```bash
188
+ git clone https://github.com/NousResearch/hermes-agent.git
189
+ cd hermes-agent
190
+ ./setup-hermes.sh # instala uv, crea venv, instala .[all], enlaza ~/.local/bin/hermes
191
+ ./hermes # detecta automáticamente el venv, no necesitas hacer `source` primero
192
+ ```
193
+
194
+ Ruta manual (equivalente a lo anterior):
195
+
196
+ ```bash
197
+ curl -LsSf https://astral.sh/uv/install.sh | sh
198
+ uv venv .venv --python 3.11
199
+ source .venv/bin/activate
200
+ uv pip install -e ".[all,dev]"
201
+ scripts/run_tests.sh
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Comunidad
207
+
208
+ - 💬 [Discord](https://discord.gg/NousResearch)
209
+ - 📚 [Skills Hub](https://agentskills.io)
210
+ - 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
211
+ - 🔌 [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.
212
+ - 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Puente WeChat comunitario: Ejecuta Hermes Agent y OpenClaw en la misma cuenta de WeChat.
213
+
214
+ ---
215
+
216
+ ## Licencia
217
+
218
+ MIT — ver [LICENSE](LICENSE).
219
+
220
+ Creado por [Nous Research](https://nousresearch.com).
README.ur-pk.md ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div dir="rtl">
2
+
3
+ <p align="center">
4
+ <img src="assets/banner.png" alt="Hermes Agent" width="100%">
5
+ </p>
6
+
7
+ # ہرمیس ایجنٹ ☤ (Hermes Agent)
8
+
9
+ <p align="center">
10
+ <a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
11
+ <a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
12
+ <a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
13
+ <a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
14
+ <a href="README.md"><img src="https://img.shields.io/badge/Lang-English-lightgrey?style=for-the-badge" alt="English"></a>
15
+ <a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
16
+ </p>
17
+
18
+ **[نوس ریسرچ (Nous Research)](https://nousresearch.com) کا تیار کردہ خود کو بہتر بنانے والا اے آئی (AI) ایجنٹ۔** یہ واحد ایجنٹ ہے جس میں سیکھنے کا عمل (learning loop) پہلے سے موجود ہے — یہ اپنے تجربات سے نئی مہارتیں (skills) بناتا ہے، استعمال کے دوران ان کو بہتر کرتا ہے، معلومات کو محفوظ رکھنے کے لیے خود کو یاد دہانی کرواتا ہے، اپنی پرانی بات چیت کو تلاش کر سکتا ہے، اور مختلف سیشنز کے دوران آپ کے بارے میں ایک گہری سمجھ پیدا کرتا ہے۔ اسے $5 والے VPS پر چلائیں، GPU کلسٹر پر، یا سرور لیس (serverless) انفراسٹرکچر پر جس کی قیمت استعمال نہ ہونے پر تقریباً صفر ہے۔ یہ آپ کے لیپ ٹاپ تک محدود نہیں ہے — آپ ٹیلی گرام (Telegram) سے اس کے ساتھ بات چیت کر سکتے ہیں جبکہ یہ کلاؤڈ VM پر کام کر رہا ہو۔
19
+
20
+ آپ اپنی مرضی کا کوئی بھی ماڈل استعمال کر سکتے ہیں — [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` استعمال کریں — کسی کوڈ کو تبدیل کرنے کی ضرورت نہیں، کوئی پابندی نہیں۔
21
+
22
+ <table>
23
+ <tr><td><b>حقیقی ٹرمینل انٹرفیس</b></td><td>مکمل TUI جس میں ملٹی لائن ایڈیٹنگ، سلیش-کمانڈ آٹو کمپلیٹ، بات چیت کی ہسٹری، انٹرپٹ اور ری ڈائریکٹ، اور سٹریمنگ ٹول آؤٹ پٹ شامل ہے۔</td></tr>
24
+ <tr><td><b>یہ وہاں موجود ہے جہاں آپ ہیں</b></td><td>ٹیلی گرام، ڈسکارڈ (Discord)، سلیک (Slack)، واٹس ایپ (WhatsApp)، سگنل (Signal)، اور CLI — سب ایک ہی گیٹ وے پروسیس سے کام کرتے ہیں۔ وائس میمو (Voice memo) ٹرانسکرپشن، کراس پلیٹ فارم بات چیت کا تسلسل۔</td></tr>
25
+ <tr><td><b>سیکھنے کا ایک مکمل عمل</b></td><td>ایجنٹ کی اپنی ترتیب دی گئی میموری، جس میں وہ خود کو وقتاً فوقتاً یاد دہانی کرواتا ہے۔ پیچیدہ کاموں کے بعد خود کار طریقے سے مہارت (skill) کی تخلیق۔ استعمال کے دوران مہارتوں میں بہتری۔ LLM سمرائزیشن کے ساتھ FTS5 سیشن سرچ تاکہ پرانے سیشنز کی یاددہانی کی جا سکے۔ <a href="https://github.com/plastic-labs/honcho">Honcho</a> کے ذریعے صارف کی ماڈلنگ۔ <a href="https://agentskills.io">agentskills.io</a> اوپن سٹینڈرڈ کے ساتھ مکمل مطابقت۔</td></tr>
26
+ <tr><td><b>شیڈول کی گئی خودکار کارروائیاں</b></td><td>بلٹ ان (Built-in) کرون (cron) شیڈیولر جو کسی بھی پلیٹ فارم پر ڈیلیوری کے لیے استعمال ہو سکتا ہے۔ روزانہ کی رپورٹس، رات کے بیک اپس، ہفتہ وار آڈٹس — یہ سب کچھ قدرتی زبان (natural language) میں اور بغیر کسی نگرانی کے کام کرتا ہے۔</td></tr>
27
+ <tr><td><b>کام کی تقسیم اور متوازی عمل</b></td><td>متوازی (parallel) کاموں کے لیے الگ سے ذیلی ایجنٹس (subagents) بنائیں۔ پائتھون (Python) سکرپٹس لکھیں جو RPC کے ذریعے ٹولز کو استعمال کریں، تاکہ کئی مراحل پر مشتمل کاموں کو بغیر کسی سیاق و سباق (context) کے خرچ کے، ایک ہی باری میں انجام دیا جا سکے۔</td></tr>
28
+ <tr><td><b>کہیں بھی چلائیں، صرف اپنے لیپ ٹاپ پر نہیں</b></td><td>چھ (Six) ٹرمینل بیک اینڈز — لوکل، Docker، SSH، Singularity، Modal، اور Daytona۔ ڈیٹونا (Daytona) اور موڈل (Modal) سرور لیس (serverless) فعالیت پیش کرتے ہیں — جب آپ کا ایجنٹ فارغ ہوتا ہے تو اس کا ماحول سلیپ (hibernate) ہو جاتا ہے اور ضرورت پڑنے پر خود بخود جاگ جاتا ہے، جس کی وجہ سے سیشنز کے درمیان لاگت تقریباً صفر رہتی ہے۔ اسے $5 والے VPS یا GPU کلسٹر پر چلائیں۔</td></tr>
29
+ <tr><td><b>تحقیق کے لیے تیار</b></td><td>بیچ (Batch) ٹریجیکٹری (trajectory) جنریشن، اگلی نسل کے ٹول کالنگ ماڈلز کی تربیت کے لیے ٹریجیکٹری کمپریشن۔</td></tr>
30
+ </table>
31
+
32
+ ---
33
+
34
+ ## فوری انسٹالیشن (Quick Install)
35
+
36
+ ### لینکس (Linux)، میک او ایس (macOS)، ڈبلیو ایس ایل ٹو (WSL2)، ٹرمکس (Termux)
37
+
38
+ <div dir="ltr">
39
+
40
+ ```bash
41
+ curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
42
+ ```
43
+
44
+ </div>
45
+
46
+ ### ونڈوز (نیٹو، پاور شیل)
47
+
48
+ > **توجہ فرمائیں:** مقامی ونڈوز (Native Windows) پر ہرمیس بغیر WSL کے چلتا ہے — CLI، گیٹ وے، TUI، اور ٹولز سب مقامی طور پر کام کرتے ہیں۔ اگر آپ WSL2 استعمال کرنا پسند کرتے ہیں، تو اوپر دی گئی لینکس/میک او ایس کی کمانڈ وہاں بھی کام کرے گی۔ کوئی مسئلہ نظر آیا؟ براہ کرم [مسائل (issues) درج کریں](https://github.com/NousResearch/hermes-agent/issues)۔
49
+
50
+ اسے پاور شیل (PowerShell) میں چلائیں:
51
+
52
+ <div dir="ltr">
53
+
54
+ ```powershell
55
+ iex (irm https://hermes-agent.nousresearch.com/install.ps1)
56
+ ```
57
+
58
+ </div>
59
+
60
+ انسٹالر سب کچھ خود سنبھالتا ہے: uv، Python 3.11، Node.js، ripgrep، ffmpeg، **اور ایک پورٹ ایبل (portable) گٹ بیش (Git Bash)** (یعنی MinGit، جو `%LOCALAPPDATA%\hermes\git` میں ان پیک ہوتا ہے — اس کے لیے ایڈمن کی اجازت درکار نہیں، اور یہ سسٹم کے کسی بھی گٹ انسٹال سے بالکل الگ ہے)۔ ہرمیس اس بنڈل شدہ گٹ بیش کو شیل کمانڈز چلانے کے لیے استعمال کرتا ہے۔
61
+
62
+ اگر آپ کے پاس پہلے سے گٹ (Git) انسٹال ہے، تو انسٹالر اسے شناخت کر لیتا ہے اور اسے ہی استعمال کرتا ہے۔ بصورت دیگر آپ کو صرف ~45MB کے MinGit ڈاؤنلوڈ کی ضرورت ہوگی — یہ آپ کے سسٹم کے گٹ پر کوئی اثر نہیں ڈالے گا۔
63
+
64
+ > **اینڈرائیڈ (Android) / ٹرمکس (Termux):** ٹیسٹ کیا گیا مینوئل طریقہ [Termux گائیڈ](https://hermes-agent.nousresearch.com/docs/getting-started/termux) میں موجود ہے۔ ٹرمکس پر ہرمیس ایک مخصوص `.[termux]` ایکسٹرا انسٹال کرتا ہے کیونکہ مکمل `.[all]` ایکسٹرا میں ایسی وائس ڈیپینڈینسیز شامل ہیں جو اینڈرائیڈ کے ساتھ مطابقت نہیں رکھتیں۔
65
+ >
66
+ > **ونڈوز (Windows):** مقامی ونڈوز کی مکمل سپورٹ موجود ہے — اوپر دی گئی پاور شیل کی کمانڈ سب کچھ انسٹال کر دیتی ہے۔ اگر آپ WSL2 استعمال کرنا چاہتے ہیں، تو لینکس کی کمانڈ وہاں کام کرتی ہے۔ مقامی ونڈوز میں انسٹالیشن `%LOCALAPPDATA%\hermes` میں ہوتی ہے؛ جبکہ WSL2 میں لینکس کی طرح `~/.hermes` میں ہوتی ہے۔ ہرمیس کا وہ واحد فیچر جسے فی الحال خاص طور پر WSL2 کی ضرورت ہے وہ براؤزر پر مبنی ڈیش بورڈ چیٹ پین ہے (یہ POSIX PTY استعمال کرتا ہے — کلاسک CLI اور گیٹ وے دونوں مقامی طور پر چلتے ہیں)۔
67
+
68
+ انسٹالیشن کے بعد:
69
+
70
+ <div dir="ltr">
71
+
72
+ ```bash
73
+ source ~/.bashrc # شیل کو ری لوڈ کریں (یا: source ~/.zshrc)
74
+ hermes # بات چیت شروع کریں!
75
+ ```
76
+
77
+ </div>
78
+
79
+ ---
80
+
81
+ ## آغاز کریں (Getting Started)
82
+
83
+ <div dir="ltr">
84
+
85
+ ```bash
86
+ hermes # انٹرایکٹو CLI — بات چیت شروع کریں
87
+ hermes model # اپنا LLM پرووا��یڈر اور ماڈل منتخب کریں
88
+ hermes tools # کنفیگر کریں کہ کون سے ٹولز ایکٹو ہیں
89
+ hermes config set # انفرادی کنفگ (config) ویلیوز سیٹ کریں
90
+ hermes gateway # میسجنگ گیٹ وے شروع کریں (ٹیلی گرام، ڈسکارڈ، وغیرہ)
91
+ hermes setup # مکمل سیٹ اپ وزرڈ چلائیں (یہ سب کچھ ایک ساتھ کنفیگر کر دے گا)
92
+ hermes claw migrate # OpenClaw سے مائیگریٹ کریں (اگر آپ OpenClaw سے آ رہے ہیں)
93
+ hermes update # لیٹسٹ ورژن پر اپ ڈیٹ کریں
94
+ hermes doctor # کسی بھی مسئلے کی تشخیص کریں
95
+ ```
96
+
97
+ </div>
98
+
99
+ 📖 **[مکمل دستاویزات →](https://hermes-agent.nousresearch.com/docs/)**
100
+
101
+ ---
102
+
103
+ ## API-کیز اکٹھی کرنے سے بچیں — Nous Portal
104
+
105
+ ہرمیس آپ کے پسندیدہ پرووائیڈر کے ساتھ کام کرتا ہے — یہ چیز تبدیل نہیں ہو رہی۔ لیکن اگر آپ ماڈل، ویب سرچ، امیج جنریشن، TTS، اور کلاؤڈ براؤزر کے لیے پانچ الگ الگ API کیز جمع نہیں کرنا چاہتے، تو **[Nous Portal](https://portal.nousresearch.com)** ان سب کو ایک ہی سبسکرپشن کے تحت کور کرتا ہے:
106
+
107
+ - **300+ ماڈلز** — ان میں سے کوئی بھی ماڈل `/model <name>` کے ذریعے منتخب کریں
108
+ - **ٹول گیٹ وے (Tool Gateway)** — ویب سرچ (Firecrawl)، امیج جنریشن (FAL)، ٹیکسٹ ٹو سپیچ (OpenAI)، کلاؤڈ براؤزر (Browser Use)، یہ سب آپ کی سبسکرپشن کے ذریعے چلتے ہیں۔ کسی اضافی اکاؤنٹ کی ضرورت نہیں۔
109
+
110
+ نئی انسٹالیشن کے بعد بس ایک کمانڈ کی ضرورت ہے:
111
+
112
+ <div dir="ltr">
113
+
114
+ ```bash
115
+ hermes setup --portal
116
+ ```
117
+
118
+ </div>
119
+
120
+ یہ آپ کو OAuth کے ذریعے لاگ ان کرواتا ہے، Nous کو آپ کا پرووائیڈر مقرر کرتا ہے، اور ٹول گیٹ وے کو آن کر دیتا ہے۔ `hermes portal info` کمانڈ استعمال کر کے آپ کسی بھی وقت چیک کر سکتے ہیں کہ کون کون سی سروسز منسلک ہیں۔ مکمل تفصیلات [Tool Gateway دستاویزات کے صفحے](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway) پر موجود ہیں۔
121
+
122
+ آپ اب بھی کسی بھی ٹول کے لیے اپنی مرضی کی API کیز استعمال کر سکتے ہیں — گیٹ وے ہر سروس کے لیے الگ الگ کام کرتا ہے، ایسا نہیں کہ یا تو سب کچھ استعمال کریں یا کچھ بھی نہیں۔
123
+
124
+ ---
125
+
126
+ ## CLI بمقابلہ میسجنگ فوری حوالہ
127
+
128
+ ہرمیس کے دو بنیادی انٹر فیس ہیں: آپ ٹرمینل UI کو `hermes` کے ساتھ شروع کریں، یا گیٹ وے چلا کر اس کے ساتھ ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، یا ای میل کے ذریعے بات کریں۔ جب آپ کسی بات چیت میں ہوتے ہیں، تو بہت سی سلیش (slash) کمانڈز دونوں انٹرفیسز میں ایک جیسی ہوتی ہیں۔
129
+
130
+ <div dir="ltr">
131
+
132
+ | کارروائی (Action) | سی ایل آئی (CLI) | میسجنگ پلیٹ فارمز (Messaging platforms) |
133
+ | --------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------- |
134
+ | بات چیت شروع کریں | `hermes` | `hermes gateway setup` اور `hermes gateway start` چلائیں، پھر بوٹ کو میسج بھیجیں |
135
+ | نئی بات چیت شروع کریں | `/new` یا `/reset` | `/new` یا `/reset` |
136
+ | ماڈل تبدیل کریں | `/model [provider:model]` | `/model [provider:model]` |
137
+ | پرسنلٹی (Personality) سیٹ کریں | `/personality [name]` | `/personality [name]` |
138
+ | پچھلی باری کو دوبارہ یا منسوخ (undo) کریں | `/retry`، `/undo` | `/retry`، `/undo` |
139
+ | کانٹیکسٹ (context) کمپریس کریں / استعمال چیک کریں | `/compress`، `/usage`، `/insights [--days N]` | `/compress`، `/usage`، `/insights [days]` |
140
+ | مہارتیں (Skills) براؤز کریں | `/skills` یا `/<skill-name>` | `/<skill-name>` |
141
+ | موجودہ کام کو روکیں | `Ctrl+C` دبائیں یا نیا میسج بھیجیں | `/stop` یا نیا میسج بھیجیں |
142
+ | پلیٹ فارم کے لحاظ سے سٹیٹس | `/platforms` | `/status`، `/sethome` |
143
+
144
+ </div>
145
+
146
+ مکمل کمانڈ لسٹ کے لیے، [CLI گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/cli) اور [میسجنگ گیٹ وے گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) دیکھیں۔
147
+
148
+ ---
149
+
150
+ ## دستاویزات (Documentation)
151
+
152
+ تمام دستاویزات **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)** پر موجود ہیں:
153
+
154
+ <div dir="ltr">
155
+
156
+ | سیکشن (Section) | تفصیل (What's Covered) |
157
+ | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
158
+ | [فوری آغاز (Quickstart)](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | انسٹالیشن → سیٹ اپ → 2 منٹ میں پہلی بات چیت شروع کریں |
159
+ | [CLI کا استعمال](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | کمانڈز، کی بائنڈنگز (keybindings)، پرسنلٹیز (personalities)، سیشنز |
160
+ | [کنفیگریشن (Configuration)](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | کنفگ فائل، پرووائیڈرز، ماڈلز، اور تمام آپشنز |
161
+ | [میسجنگ گیٹ وے](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، ہوم اسسٹنٹ |
162
+ | [سیکیورٹی (Security)](https://hermes-agent.nousresearch.com/docs/user-guide/security) | کمانڈ کی منظوری، DM پیئرنگ (pairing)، کنٹینر آئسولیشن |
163
+ | [ٹولز اور ٹول سیٹس](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40 سے زائد ٹولز، ٹول سیٹ سسٹم، ٹرمینل بیک اینڈز |
164
+ | [مہارتوں کا سسٹم (Skills System)](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills)| پروسیجرل (Procedural) میموری، سکلز ہب، نئی مہارتیں بنانا |
165
+ | [میموری (Memory)](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | مستقل میموری، یوزر پروفائلز، بہترین طریقہ کار |
166
+ | [MCP انضمام (Integration)](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | صلاحیتوں کو بڑھانے کے لیے کسی بھی MCP سرور کو جوڑیں |
167
+ | [کرون (Cron) شیڈیولنگ](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | پلیٹ فارم ڈیلیوری کے ساتھ شیڈول کیے گئے کام |
168
+ | [کانٹیکسٹ (Context) فائلز](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files)| پروجیکٹ کا سیاق و سباق (context) جو ہر بات چیت پر اثر انداز ہوتا ہے |
169
+ | [آرکیٹیکچر (Architecture)](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | پروجیکٹ کا ڈھانچہ، ایجنٹ لوپ، اہم کلاسز |
170
+ | [تعاون (Contributing)](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | ڈیویلپمنٹ سیٹ اپ، PR کا طریقہ کار، کوڈنگ کا انداز |
171
+ | [CLI حوالہ جات (Reference)](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | تمام کمانڈز اور فلیگز (flags) |
172
+ | [انوائرمنٹ ویری ایبلز](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | مکمل انوائرمنٹ ویری ایبل حوالہ جات |
173
+
174
+ </div>
175
+
176
+ ---
177
+
178
+ ## OpenClaw سے منتقلی
179
+
180
+ اگر آپ OpenClaw سے منتقل ہو رہے ہیں، تو ہرمیس آپ کی سیٹنگز، یادیں (memories)، مہارتیں (skills)، اور API کیز کو خود بخود امپورٹ کر سکتا ہے۔
181
+
182
+ **پہلی بار سیٹ اپ کے دوران:** سیٹ اپ وزرڈ (`hermes setup`) خود بخود `~/.openclaw` کو پہچان لیتا ہے اور کنفیگریشن شروع ہونے سے پہلے مائیگریٹ (migrate) کرنے کا آپشن دیتا ہے۔
183
+
184
+ **انسٹالیشن کے بعد کسی بھی وقت:**
185
+
186
+ <div dir="ltr">
187
+
188
+ ```bash
189
+ hermes claw migrate # انٹرایکٹو مائیگریشن (مکمل پری سیٹ)
190
+ hermes claw migrate --dry-run # جائزہ لیں کہ کیا کیا مائیگریٹ ہوگا
191
+ hermes claw migrate --preset user-data # حساس معلومات (secrets) کے بغیر مائیگریٹ کریں
192
+ hermes claw migrate --overwrite # موجودہ متصادم فائلوں کو اوور رائٹ کریں
193
+ ```
194
+
195
+ </div>
196
+
197
+ جو چیزیں امپورٹ ہوتی ہیں:
198
+
199
+ - **SOUL.md** — پرسونا (persona) فائل
200
+ - **میموریز (Memories)** — MEMORY.md اور USER.md کی اندراجات
201
+ - **مہارتیں (Skills)** — صارف کی بنائی گئی مہارتیں → `~/.hermes/skills/openclaw-imports/`
202
+ - **کمانڈ الاؤ لسٹ (allowlist)** — منظوری کے پیٹرنز (approval patterns)
203
+ - **میسجنگ سیٹنگز** — پلیٹ فارم کنفیگریشنز، اجازت یافتہ صارفین، ورکنگ ڈائریکٹری
204
+ - **API کیز** — الاؤ لسٹ شدہ حساس معلومات (ٹیلی گرام، OpenRouter، OpenAI، Anthropic، ElevenLabs)
205
+ - **TTS اثاثے** — ورک اسپیس کی آڈیو فائلیں
206
+ - **ورک اسپیس کی ہدایات** — AGENTS.md (`--workspace-target` کے ساتھ)
207
+
208
+ تمام آپشنز دیکھنے کے لیے `hermes claw migrate --help` استعمال کریں، یا انٹرایکٹو ایجنٹ کی مدد سے مائیگریٹ کرنے کے لیے `openclaw-migration` سکل کا استعمال کریں (جس میں ڈرائی رن (dry-run) پریویوز شامل ہیں)۔
209
+
210
+ ---
211
+
212
+ ## تعاون کریں (Contributing)
213
+
214
+ ہم آپ کے تعاون کا خیرمقدم کرتے ہیں! ڈیویلپمنٹ سیٹ اپ، کوڈ کے انداز اور PR کے طریقہ کار کے لیے براہ کرم ہماری [Contributing گائیڈ](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) دیکھیں۔
215
+
216
+ معاونین (contributors) کے لیے فوری آغاز — کلون (clone) کریں اور `setup-hermes.sh` چلائیں:
217
+
218
+ <div dir="ltr">
219
+
220
+ ```bash
221
+ git clone https://github.com/NousResearch/hermes-agent.git
222
+ cd hermes-agent
223
+ ./setup-hermes.sh # uv کو انسٹال کرتا ہے، venv بناتا ہے، .[all] کو انسٹال کرتا ہے، اور ~/.local/bin/hermes کا سیم لنک (symlink) بناتا ہے
224
+ ./hermes # خود بخود venv کی شناخت کرتا ہے، پہلے `source` کرنے کی ضرورت نہیں
225
+ ```
226
+
227
+ </div>
228
+
229
+ مینوئل طریقہ (اوپر والے طریقے کے مساوی):
230
+
231
+ <div dir="ltr">
232
+
233
+ ```bash
234
+ curl -LsSf https://astral.sh/uv/install.sh | sh
235
+ uv venv .venv --python 3.11
236
+ source .venv/bin/activate
237
+ uv pip install -e ".[all,dev]"
238
+ scripts/run_tests.sh
239
+ ```
240
+
241
+ </div>
242
+
243
+ ---
244
+
245
+ ## کمیونٹی (Community)
246
+
247
+ - 💬 [ڈسکارڈ (Discord)](https://discord.gg/NousResearch)
248
+ - 📚 [سکلز ہب (Skills Hub)](https://agentskills.io)
249
+ - 🐛 [مسائل (Issues)](https://github.com/NousResearch/hermes-agent/issues)
250
+ - 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — ہرمیس اور دیگر MCP ہوسٹس کے لیے لینکس (Linux) ڈیسک ٹاپ کنٹرول MCP سرور، جس میں AT-SPI ایکسیسیبلٹی ٹریز، Wayland/X11 ان پٹ، سکرین شاٹس، اور کمپوزیٹر ونڈو ٹارگیٹنگ شامل ہے۔
251
+ - 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — کمیونٹی وی چیٹ (WeChat) برج: ہرمیس ایجنٹ اور OpenClaw کو ایک ہی وی چیٹ اکاؤنٹ پر چلائیں۔
252
+
253
+ ---
254
+
255
+ ## لائسنس (License)
256
+
257
+ MIT — تفصیلات کے لیے [LICENSE](LICENSE) دیکھیں۔
258
+
259
+ [نوس ریسرچ (Nous Research)](https://nousresearch.com) کی جانب سے تیار کردہ۔
260
+
261
+ </div>
batch_runner.py ADDED
@@ -0,0 +1,1008 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Batch Agent Runner — run the agent over a JSONL prompt dataset in parallel.
3
+
4
+ Batches are processed by a multiprocessing pool with per-batch ``batch_N.jsonl`` output,
5
+ checkpointing for ``--resume``, trajectories in from/value format, and tool-usage
6
+ statistics aggregated across all batches. See ``main`` (fire CLI) for usage.
7
+ """
8
+
9
+ # hermes_bootstrap must be the very first import — UTF-8 stdio on Windows, no-op on POSIX.
10
+ try:
11
+ import hermes_bootstrap # noqa: F401
12
+ except ModuleNotFoundError:
13
+ # Partial ``hermes update`` (git reset landed, ``uv pip install -e .`` did not):
14
+ # only Windows UTF-8 stdio setup is skipped.
15
+ pass
16
+
17
+ import json
18
+ import logging
19
+ import os
20
+ import time
21
+ import traceback
22
+ from datetime import datetime
23
+ from multiprocessing import Lock, Pool
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+
27
+ import fire
28
+ from rich.console import Console
29
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeRemainingColumn
30
+
31
+ from model_tools import TOOL_TO_TOOLSET_MAP
32
+ from run_agent import AIAgent
33
+ from toolset_distributions import (
34
+ list_distributions,
35
+ sample_toolsets_from_distribution,
36
+ validate_distribution,
37
+ )
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # Auto-derived from model_tools so it stays in sync as tools are added. Gives every
42
+ # trajectory a consistent tool_stats schema (Arrow/Parquet for HF datasets) and filters
43
+ # corrupted entries (hallucinated tool names) when combining trajectories.
44
+ ALL_POSSIBLE_TOOLS = set(TOOL_TO_TOOLSET_MAP.keys())
45
+
46
+ DEFAULT_TOOL_STATS = {'count': 0, 'success': 0, 'failure': 0}
47
+ _REASONING_KEYS = ("total_assistant_turns", "turns_with_reasoning", "turns_without_reasoning")
48
+
49
+ # BatchRunner.__init__ parameters stored as same-named attributes.
50
+ _RUNNER_FIELDS = (
51
+ "batch_size", "run_name", "distribution", "max_iterations", "base_url", "api_key", "model",
52
+ "num_workers", "verbose", "ephemeral_system_prompt", "log_prefix_chars", "providers_allowed",
53
+ "providers_ignored", "providers_order", "provider_sort", "openrouter_min_coding_score",
54
+ "reasoning_config", "prefill_messages", "max_samples",
55
+ )
56
+ # BatchRunner attributes forwarded verbatim to every AIAgent in the worker config.
57
+ _AGENT_PASSTHROUGH = (
58
+ "base_url", "api_key", "ephemeral_system_prompt", "providers_allowed", "providers_ignored",
59
+ "providers_order", "provider_sort", "openrouter_min_coding_score",
60
+ "reasoning_config", "prefill_messages",
61
+ # Without this, every batch task run is attributed to the "unknown" execution
62
+ # surface in shared metrics even though "batch" is a first-class surface.
63
+ "platform",
64
+ )
65
+
66
+
67
+ def _normalize_tool_stats(tool_stats: Dict[str, Dict[str, int]]) -> Dict[str, Dict[str, int]]:
68
+ """All possible tools with zero defaults (consistent HF schema), plus any unexpected tools."""
69
+ normalized = {
70
+ tool: tool_stats[tool].copy() if tool in tool_stats else DEFAULT_TOOL_STATS.copy()
71
+ for tool in ALL_POSSIBLE_TOOLS
72
+ }
73
+ for tool, stats in tool_stats.items():
74
+ if tool not in normalized:
75
+ normalized[tool] = stats.copy()
76
+ return normalized
77
+
78
+
79
+ def _normalize_tool_error_counts(tool_error_counts: Dict[str, int]) -> Dict[str, int]:
80
+ """All possible tools with zero defaults, plus any unexpected tools."""
81
+ normalized = {tool: tool_error_counts.get(tool, 0) for tool in ALL_POSSIBLE_TOOLS}
82
+ for tool, count in tool_error_counts.items():
83
+ if tool not in normalized:
84
+ normalized[tool] = count
85
+ return normalized
86
+
87
+
88
+ def _merge_tool_stats(total: Dict[str, Dict[str, int]], tool_stats: Dict[str, Dict[str, int]]) -> None:
89
+ """Add per-tool count/success/failure from *tool_stats* into *total* in place."""
90
+ for tool_name, stats in tool_stats.items():
91
+ agg = total.setdefault(tool_name, DEFAULT_TOOL_STATS.copy())
92
+ agg["count"] += stats["count"]
93
+ agg["success"] += stats["success"]
94
+ agg["failure"] += stats["failure"]
95
+
96
+
97
+ def _merge_reasoning_stats(total: Dict[str, int], reasoning_stats: Dict[str, Any]) -> None:
98
+ """Add the turn counters from *reasoning_stats* into *total* in place."""
99
+ for key in total:
100
+ total[key] += reasoning_stats.get(key, 0)
101
+
102
+
103
+ def _tool_call_succeeded(content) -> bool:
104
+ """Judge a tool response: JSON with a non-null ``error`` / ``success: false`` fails;
105
+ non-JSON fails only when empty or starting with ``Error:`` (no substring matching, to
106
+ avoid false positives). Non-zero exit codes are NOT failures — the model self-corrects."""
107
+ try:
108
+ content_json = json.loads(content) if isinstance(content, str) else content
109
+ except (json.JSONDecodeError, ValueError, TypeError):
110
+ return bool(content) and not content.strip().lower().startswith("error:")
111
+ if not isinstance(content_json, dict):
112
+ return True
113
+ if content_json.get("error") is not None:
114
+ return False
115
+ # Terminal wraps its response in a "content" field.
116
+ inner_content = content_json.get("content")
117
+ if isinstance(inner_content, dict) and inner_content.get("error") is not None:
118
+ return False
119
+ return content_json.get("success") is not False
120
+
121
+
122
+ def _extract_tool_stats(messages: List[Dict[str, Any]]) -> Dict[str, Dict[str, int]]:
123
+ """Per-tool call counts and success/failure tallies from a message history."""
124
+ tool_stats = {}
125
+ tool_calls_map = {} # tool_call_id -> tool name
126
+
127
+ for msg in messages:
128
+ if msg["role"] == "assistant" and "tool_calls" in msg and msg["tool_calls"]:
129
+ for tool_call in msg["tool_calls"]:
130
+ if not tool_call or not isinstance(tool_call, dict): continue
131
+ tool_name = tool_call["function"]["name"]
132
+ tool_stats.setdefault(tool_name, DEFAULT_TOOL_STATS.copy())["count"] += 1
133
+ tool_calls_map[tool_call["id"]] = tool_name
134
+ elif msg["role"] == "tool":
135
+ tool_call_id = msg.get("tool_call_id", "")
136
+ is_success = _tool_call_succeeded(msg.get("content", ""))
137
+ if tool_call_id in tool_calls_map:
138
+ tool_stats[tool_calls_map[tool_call_id]]["success" if is_success else "failure"] += 1
139
+
140
+ return tool_stats
141
+
142
+
143
+ def _turn_has_reasoning(msg: Dict[str, Any]) -> bool:
144
+ """``<REASONING_SCRATCHPAD>`` in content, or a non-empty native ``reasoning`` field."""
145
+ if "<REASONING_SCRATCHPAD>" in (msg.get("content", "") or ""):
146
+ return True
147
+ return bool(msg.get("reasoning", "").strip()) if msg.get("reasoning") else False
148
+
149
+
150
+ def _extract_reasoning_stats(messages: List[Dict[str, Any]]) -> Dict[str, int]:
151
+ """Count assistant turns with reasoning vs without."""
152
+ assistant_turns = [msg for msg in messages if msg.get("role") == "assistant"]
153
+ total = len(assistant_turns)
154
+ with_reasoning = sum(1 for msg in assistant_turns if _turn_has_reasoning(msg))
155
+ return {
156
+ "total_assistant_turns": total,
157
+ "turns_with_reasoning": with_reasoning,
158
+ "turns_without_reasoning": total - with_reasoning,
159
+ "has_any_reasoning": with_reasoning > 0,
160
+ }
161
+
162
+
163
+ def _failure_result(prompt_index: int, batch_num: int, error: str) -> Dict[str, Any]:
164
+ """Result dict for a prompt that produced no trajectory."""
165
+ return {
166
+ "success": False,
167
+ "prompt_index": prompt_index,
168
+ "error": error,
169
+ "trajectory": None,
170
+ "tool_stats": {},
171
+ "toolsets_used": [],
172
+ "metadata": {"batch_num": batch_num, "timestamp": datetime.now().isoformat()},
173
+ }
174
+
175
+
176
+ def _prepare_container_image(
177
+ prompt_index: int, prompt_data: Dict[str, Any], batch_num: int, task_id: str, config: Dict[str, Any]
178
+ ) -> Optional[Dict[str, Any]]:
179
+ """Register the dataset row's per-prompt container image (``image``/``docker_image``)
180
+ for this task's sandbox (Docker, Modal, Singularity, Daytona).
181
+
182
+ For Docker the image is verified (local cache, then pull) before spending tokens on the
183
+ agent loop; Modal pulls server-side so no local check. Returns a failure result when the
184
+ pull fails, else ``None``.
185
+ """
186
+ container_image = prompt_data.get("image") or prompt_data.get("docker_image")
187
+ if not container_image:
188
+ return None
189
+ env_type = os.getenv("TERMINAL_ENV", "local")
190
+ if env_type == "docker":
191
+ import subprocess as _sp
192
+ try:
193
+ probe = _sp.run(
194
+ ["docker", "image", "inspect", container_image],
195
+ capture_output=True, timeout=10,
196
+ )
197
+ if probe.returncode != 0:
198
+ if config.get("verbose"):
199
+ print(f" Prompt {prompt_index}: Pulling docker image {container_image}...", flush=True)
200
+ pull = _sp.run(
201
+ ["docker", "pull", container_image],
202
+ capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=600,
203
+ )
204
+ if pull.returncode != 0:
205
+ return _failure_result(
206
+ prompt_index, batch_num,
207
+ f"Docker image not available: {container_image}\n{pull.stderr[:500]}",
208
+ )
209
+ except FileNotFoundError:
210
+ pass # Docker CLI not installed — skip check (e.g., Modal backend)
211
+ except Exception as img_err:
212
+ if config.get("verbose"):
213
+ print(f" Prompt {prompt_index}: Docker image check failed: {img_err}", flush=True)
214
+ from tools.terminal_tool import register_task_env_overrides
215
+ overrides = {
216
+ "docker_image": container_image,
217
+ "modal_image": container_image,
218
+ "singularity_image": f"docker://{container_image}",
219
+ "daytona_image": container_image,
220
+ }
221
+ if prompt_data.get("cwd"):
222
+ overrides["cwd"] = prompt_data["cwd"]
223
+ register_task_env_overrides(task_id, overrides)
224
+ if config.get("verbose"):
225
+ print(f" Prompt {prompt_index}: Using container image {container_image}")
226
+ return None
227
+
228
+
229
+ def _process_single_prompt(
230
+ prompt_index: int,
231
+ prompt_data: Dict[str, Any],
232
+ batch_num: int,
233
+ config: Dict[str, Any]
234
+ ) -> Dict[str, Any]:
235
+ """Run the agent on one prompt; returns trajectory, stats and metadata (or a failure result)."""
236
+ prompt = prompt_data["prompt"]
237
+ task_id = f"task_{prompt_index}"
238
+ failure = _prepare_container_image(prompt_index, prompt_data, batch_num, task_id, config)
239
+ if failure is not None:
240
+ return failure
241
+
242
+ try:
243
+ selected_toolsets = sample_toolsets_from_distribution(config["distribution"])
244
+
245
+ if config.get("verbose"):
246
+ print(f" Prompt {prompt_index}: Using toolsets {selected_toolsets}")
247
+ agent = AIAgent(
248
+ model=config["model"],
249
+ max_iterations=config["max_iterations"],
250
+ enabled_toolsets=selected_toolsets,
251
+ save_trajectories=False, # We handle saving ourselves
252
+ verbose_logging=config.get("verbose", False),
253
+ log_prefix_chars=config.get("log_prefix_chars", 100),
254
+ log_prefix=f"[B{batch_num}:P{prompt_index}]",
255
+ skip_context_files=True, # Don't pollute trajectories with SOUL.md/AGENTS.md
256
+ skip_memory=True, # Don't use persistent memory in batch runs
257
+ **{key: config.get(key) for key in _AGENT_PASSTHROUGH if key != "platform"},
258
+ # Batch is a first-class execution surface. Defaulting here (rather than
259
+ # relying on the caller's config dict) keeps task-run telemetry attributable
260
+ # even for callers that build a config without it.
261
+ platform=config.get("platform") or "batch",
262
+ )
263
+
264
+ # task_id ensures each task gets its own isolated VM
265
+ result = agent.run_conversation(prompt, task_id=task_id)
266
+
267
+ # Stats before conversion — keep the original evaluation order.
268
+ tool_stats = _extract_tool_stats(result["messages"])
269
+ reasoning_stats = _extract_reasoning_stats(result["messages"])
270
+ trajectory = agent._convert_to_trajectory_format(result["messages"], prompt, result["completed"])
271
+
272
+ return {
273
+ "success": True,
274
+ "prompt_index": prompt_index,
275
+ "trajectory": trajectory,
276
+ "tool_stats": tool_stats,
277
+ "reasoning_stats": reasoning_stats,
278
+ "completed": result["completed"],
279
+ # Sibling of the non-empty-response return below (#64686): the classifier's failure_reason must
280
+ # survive the empty-response normalization path too, or downstream consumers (TUI billing
281
+ # surface, transient-failure persistence) lose the structured reason exactly when the run
282
+ # produced no text.
283
+ "partial": result.get("partial", False),
284
+ "api_calls": result["api_calls"],
285
+ "toolsets_used": selected_toolsets,
286
+ "metadata": {"batch_num": batch_num, "timestamp": datetime.now().isoformat(), "model": config["model"]},
287
+ }
288
+ except Exception as e:
289
+ print(f"❌ Error processing prompt {prompt_index}: {e}")
290
+ if config.get("verbose"):
291
+ traceback.print_exc()
292
+ return _failure_result(prompt_index, batch_num, str(e))
293
+
294
+
295
+ def _append_jsonl(path: Path, row: Dict[str, Any]) -> None:
296
+ """Append one JSON row and fsync so a crash never loses an acknowledged prompt."""
297
+ with open(path, 'a', encoding='utf-8') as f:
298
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
299
+ f.flush()
300
+ os.fsync(f.fileno())
301
+
302
+
303
+ def _process_batch_worker(args: Tuple) -> Dict[str, Any]:
304
+ """Pool worker: process one batch of ``(index, prompt_data)`` sequentially.
305
+
306
+ ``args`` is ``(batch_num, batch_data, output_dir, completed_prompts, config)``.
307
+ """
308
+ batch_num, batch_data, output_dir, completed_prompts_set, config = args
309
+ output_dir = Path(output_dir)
310
+ print(f"\n🔄 Batch {batch_num}: Starting ({len(batch_data)} prompts)")
311
+ batch_output_file = output_dir / f"batch_{batch_num}.jsonl"
312
+ prompts_to_process = [(idx, data) for idx, data in batch_data if idx not in completed_prompts_set]
313
+
314
+ if not prompts_to_process:
315
+ print(f"✅ Batch {batch_num}: Already completed (skipping)")
316
+ return {"batch_num": batch_num, "processed": 0, "skipped": len(batch_data), "tool_stats": {}, "completed_prompts": []}
317
+ print(f" Processing {len(prompts_to_process)} prompts (skipping {len(batch_data) - len(prompts_to_process)} already completed)")
318
+ batch_tool_stats = {}
319
+ batch_reasoning_stats = dict.fromkeys(_REASONING_KEYS, 0)
320
+ completed_in_batch = []
321
+ discarded_no_reasoning = 0
322
+
323
+ for prompt_index, prompt_data in prompts_to_process:
324
+ result = _process_single_prompt(prompt_index, prompt_data, batch_num, config)
325
+
326
+ if result["success"] and result["trajectory"]:
327
+ reasoning = result.get("reasoning_stats", {})
328
+ if not reasoning.get("has_any_reasoning", True):
329
+ print(f" 🚫 Prompt {prompt_index} discarded (no reasoning in any turn)")
330
+ discarded_no_reasoning += 1
331
+ completed_in_batch.append(prompt_index)
332
+ # Tombstone row (#93527): resume filters by scanning batch_*.jsonl
333
+ # rows for prompt content, so a discarded sample without a row would
334
+ # be re-run at full cost on every restart. The merge step excludes
335
+ # tombstones from trajectories.jsonl.
336
+ _append_jsonl(batch_output_file, {
337
+ "prompt_index": prompt_index,
338
+ "discarded": "no_reasoning",
339
+ "prompt": _entry_prompt_text(prompt_data),
340
+ })
341
+ continue
342
+
343
+ # Normalize for a consistent schema across all entries.
344
+ raw_tool_stats = result.get("tool_stats", {})
345
+ raw_error_counts = {
346
+ tool_name: stats.get("failure", 0)
347
+ for tool_name, stats in raw_tool_stats.items()
348
+ }
349
+ _append_jsonl(batch_output_file, {
350
+ "prompt_index": prompt_index,
351
+ "conversations": result["trajectory"],
352
+ "metadata": result["metadata"],
353
+ "completed": result["completed"],
354
+ "partial": result.get("partial", False), # True if stopped due to invalid tool calls
355
+ "api_calls": result["api_calls"],
356
+ "toolsets_used": result["toolsets_used"],
357
+ "tool_stats": _normalize_tool_stats(raw_tool_stats), # {tool: {count, success, failure}}
358
+ "tool_error_counts": _normalize_tool_error_counts(raw_error_counts) # {tool: failure_count}
359
+ })
360
+ _merge_tool_stats(batch_tool_stats, result.get("tool_stats", {}))
361
+ _merge_reasoning_stats(batch_reasoning_stats, result.get("reasoning_stats", {}))
362
+
363
+ # Only mark as completed if successfully saved (failed prompts can be retried on resume)
364
+ if result["success"] and result["trajectory"]:
365
+ completed_in_batch.append(prompt_index)
366
+ status = "⚠️ partial" if result.get("partial") else "✅"
367
+ print(f" {status} Prompt {prompt_index} completed")
368
+ else:
369
+ print(f" ❌ Prompt {prompt_index} failed (will retry on resume)")
370
+ print(f"✅ Batch {batch_num}: Completed ({len(prompts_to_process)} prompts processed)")
371
+
372
+ return {
373
+ "batch_num": batch_num,
374
+ "processed": len(prompts_to_process),
375
+ "skipped": len(batch_data) - len(prompts_to_process),
376
+ "tool_stats": batch_tool_stats,
377
+ "reasoning_stats": batch_reasoning_stats,
378
+ "discarded_no_reasoning": discarded_no_reasoning,
379
+ "completed_prompts": completed_in_batch
380
+ }
381
+
382
+
383
+ def _entry_prompt_text(entry: Dict) -> str:
384
+ """Human prompt text from a dataset/trajectory entry: flat ``prompt``, ShareGPT
385
+ ``conversations`` (from/value), chat ``conversations``/``messages`` (role/content),
386
+ or a no-reasoning discard tombstone."""
387
+ if not isinstance(entry, dict):
388
+ return ""
389
+ text = str(entry.get("prompt") or "").strip()
390
+ if text:
391
+ return text
392
+ for key in ("conversations", "messages"):
393
+ for msg in entry.get(key, []) or []:
394
+ if not isinstance(msg, dict):
395
+ continue
396
+ role = msg.get("role") or msg.get("from")
397
+ if role in {"user", "human"}:
398
+ text = str(msg.get("content") or msg.get("value") or "").strip()
399
+ if text:
400
+ return text
401
+ return ""
402
+
403
+
404
+ def _banner(title: str) -> None:
405
+ print("\n" + "=" * 70)
406
+ print(title)
407
+ print("=" * 70)
408
+
409
+
410
+ def _chunk(entries: List[Tuple[int, Dict[str, Any]]], size: int) -> List[List[Tuple[int, Dict[str, Any]]]]:
411
+ """Split ``(index, entry)`` tuples into batches of *size*, preserving original indices."""
412
+ return [entries[i:i + size] for i in range(0, len(entries), size)]
413
+
414
+
415
+ class BatchRunner:
416
+ """Manages batch processing of agent prompts with checkpointing and statistics."""
417
+
418
+ def __init__(
419
+ self,
420
+ dataset_file: str,
421
+ batch_size: int,
422
+ run_name: str,
423
+ distribution: str = "default",
424
+ max_iterations: int = 10,
425
+ base_url: str = None,
426
+ api_key: str = None,
427
+ model: str = "claude-opus-4-20250514",
428
+ num_workers: int = 4,
429
+ verbose: bool = False,
430
+ ephemeral_system_prompt: str = None,
431
+ log_prefix_chars: int = 100,
432
+ providers_allowed: List[str] = None,
433
+ providers_ignored: List[str] = None,
434
+ providers_order: List[str] = None,
435
+ provider_sort: str = None,
436
+ openrouter_min_coding_score: Optional[float] = None,
437
+
438
+ reasoning_config: Dict[str, Any] = None,
439
+ prefill_messages: List[Dict[str, Any]] = None,
440
+ max_samples: int = None,
441
+ ):
442
+ """Load the dataset (truncated to *max_samples*), validate *distribution*, create batches.
443
+
444
+ ``ephemeral_system_prompt`` is used during execution but NOT saved to trajectories.
445
+ ``prefill_messages`` are prepended as few-shot context; Anthropic Sonnet/Opus 4.6+
446
+ reject a trailing assistant-role prefill (400) — use user-role priming for those.
447
+ """
448
+ params = dict(locals())
449
+ self.dataset_file = Path(dataset_file)
450
+ for name in _RUNNER_FIELDS:
451
+ setattr(self, name, params[name])
452
+ # Batch runs are their own execution surface; declaring it here keeps every
453
+ # worker's task-run telemetry attributable instead of falling back to "unknown".
454
+ self.platform = "batch"
455
+
456
+ if not validate_distribution(distribution):
457
+ raise ValueError(f"Unknown distribution: {distribution}. Available: {list(list_distributions().keys())}")
458
+ self.output_dir = Path("data") / run_name
459
+ self.output_dir.mkdir(parents=True, exist_ok=True)
460
+ self.checkpoint_file = self.output_dir / "checkpoint.json"
461
+ self.stats_file = self.output_dir / "statistics.json"
462
+ self.dataset = self._load_dataset()
463
+ if self.max_samples and self.max_samples < len(self.dataset):
464
+ full_count = len(self.dataset)
465
+ self.dataset = self.dataset[:self.max_samples]
466
+ print(f"✂️ Truncated dataset from {full_count} to {self.max_samples} samples (--max_samples)")
467
+ self.batches = self._create_batches()
468
+ print("📊 Batch Runner Initialized")
469
+ print(f" Dataset: {self.dataset_file} ({len(self.dataset)} prompts)")
470
+ print(f" Batch size: {self.batch_size}")
471
+ print(f" Total batches: {len(self.batches)}")
472
+ print(f" Run name: {self.run_name}")
473
+ print(f" Distribution: {self.distribution}")
474
+ print(f" Output directory: {self.output_dir}")
475
+ print(f" Workers: {self.num_workers}")
476
+ if self.ephemeral_system_prompt:
477
+ prompt_preview = self.ephemeral_system_prompt[:60] + "..." if len(self.ephemeral_system_prompt) > 60 else self.ephemeral_system_prompt
478
+ print(f" 🔒 Ephemeral system prompt: '{prompt_preview}'")
479
+
480
+ def _load_dataset(self) -> List[Dict[str, Any]]:
481
+ """Load JSONL entries that have a ``prompt`` field; skip blank/invalid lines."""
482
+ if not self.dataset_file.exists():
483
+ raise FileNotFoundError(f"Dataset file not found: {self.dataset_file}")
484
+ dataset = []
485
+ with open(self.dataset_file, 'r', encoding='utf-8') as f:
486
+ for line_num, line in enumerate(f, 1):
487
+ line = line.strip()
488
+ if not line:
489
+ continue
490
+
491
+ try:
492
+ entry = json.loads(line)
493
+ if 'prompt' not in entry:
494
+ print(f"⚠️ Warning: Line {line_num} missing 'prompt' field, skipping")
495
+ continue
496
+ dataset.append(entry)
497
+ except json.JSONDecodeError as e:
498
+ print(f"⚠️ Warning: Invalid JSON on line {line_num}: {e}")
499
+ continue
500
+
501
+ if not dataset:
502
+ raise ValueError(f"No valid entries found in dataset file: {self.dataset_file}")
503
+
504
+ return dataset
505
+
506
+ def _create_batches(self) -> List[List[Tuple[int, Dict[str, Any]]]]:
507
+ """Split the dataset into batches of ``(index, entry)`` tuples."""
508
+ return _chunk(list(enumerate(self.dataset)), self.batch_size)
509
+
510
+ def _empty_checkpoint(self) -> Dict[str, Any]:
511
+ return {"run_name": self.run_name, "completed_prompts": [], "batch_stats": {}, "last_updated": None}
512
+
513
+ def _load_checkpoint(self) -> Dict[str, Any]:
514
+ """Checkpoint data (completed prompt indices), or an empty one if missing/unreadable."""
515
+ if not self.checkpoint_file.exists():
516
+ return self._empty_checkpoint()
517
+
518
+ try:
519
+ with open(self.checkpoint_file, 'r', encoding='utf-8') as f:
520
+ return json.load(f)
521
+ except Exception as e:
522
+ print(f"⚠️ Warning: Failed to load checkpoint: {e}")
523
+ return self._empty_checkpoint()
524
+
525
+ def _save_checkpoint(self, checkpoint_data: Dict[str, Any], lock: Optional[Lock] = None):
526
+ """Atomically write *checkpoint_data* (stamped ``last_updated``), under *lock* if given."""
527
+ checkpoint_data["last_updated"] = datetime.now().isoformat()
528
+ from utils import atomic_json_write
529
+ if lock:
530
+ with lock:
531
+ atomic_json_write(self.checkpoint_file, checkpoint_data)
532
+ else:
533
+ atomic_json_write(self.checkpoint_file, checkpoint_data)
534
+
535
+ def _scan_completed_prompts_by_content(self) -> set:
536
+ """Prompt texts already processed, scanned from every ``batch_*.jsonl``.
537
+
538
+ Matching on content rather than index lets resume recover even when indices
539
+ don't line up. Failed entries are skipped (retried); discard tombstones count
540
+ as completed (#93527) — re-running would just re-discard.
541
+ """
542
+ completed_prompts = set()
543
+ batch_files = sorted(self.output_dir.glob("batch_*.jsonl"))
544
+
545
+ if not batch_files:
546
+ return completed_prompts
547
+ print(f"��� Scanning {len(batch_files)} batch files for completed prompts...")
548
+
549
+ for batch_file in batch_files:
550
+ try:
551
+ with open(batch_file, 'r', encoding='utf-8') as f:
552
+ for line in f:
553
+ try:
554
+ entry = json.loads(line.strip())
555
+ if entry.get("failed", False):
556
+ continue
557
+ prompt_text = _entry_prompt_text(entry)
558
+ if prompt_text:
559
+ completed_prompts.add(prompt_text)
560
+ except json.JSONDecodeError:
561
+ continue
562
+ except Exception as e:
563
+ print(f" ⚠️ Warning: Error reading {batch_file.name}: {e}")
564
+
565
+ return completed_prompts
566
+
567
+ def _filter_dataset_by_completed(self, completed_prompts: set) -> Tuple[List[Dict], List[int]]:
568
+ """Return ``([(index, entry)] not yet completed, [skipped indices])``."""
569
+ filtered_dataset = []
570
+ skipped_indices = []
571
+
572
+ for idx, entry in enumerate(self.dataset):
573
+ prompt_text = entry.get("prompt", "").strip()
574
+
575
+ # Also check conversations format
576
+ if not prompt_text:
577
+ conversations = entry.get("conversations", [])
578
+ for msg in conversations:
579
+ role = msg.get("role") or msg.get("from")
580
+ if role in {"user", "human"}:
581
+ prompt_text = (msg.get("content") or msg.get("value", "")).strip()
582
+ break
583
+
584
+ if prompt_text in completed_prompts:
585
+ skipped_indices.append(idx)
586
+ else:
587
+ filtered_dataset.append((idx, entry))
588
+
589
+ return filtered_dataset, skipped_indices
590
+
591
+ def _apply_resume(self) -> bool:
592
+ """Rebuild ``self.batches`` from unprocessed prompts. False when nothing is left to run."""
593
+ completed_prompt_texts = self._scan_completed_prompts_by_content()
594
+ if not completed_prompt_texts:
595
+ return True
596
+ print(f" Found {len(completed_prompt_texts)} already-completed prompts by content matching")
597
+ filtered_entries, skipped_indices = self._filter_dataset_by_completed(completed_prompt_texts)
598
+
599
+ if not filtered_entries:
600
+ print("\n✅ All prompts have already been processed!")
601
+ return False
602
+ self.batches = _chunk(filtered_entries, self.batch_size)
603
+ _banner("📊 RESUME SUMMARY")
604
+ print(f" Original dataset size: {len(self.dataset):,} prompts")
605
+ print(f" Already completed: {len(skipped_indices):,} prompts")
606
+ print(" ─────────────────────────────────────────")
607
+ print(f" 🎯 RESUMING WITH: {len(filtered_entries):,} prompts")
608
+ print(f" New batches created: {len(self.batches)}")
609
+ print("=" * 70 + "\n")
610
+ return True
611
+
612
+ def _worker_config(self) -> Dict[str, Any]:
613
+ """Picklable agent configuration for worker processes.
614
+
615
+ ``self.api_key`` may be a zero-arg callable (Azure Foundry Entra ID bearer provider
616
+ from ``agent.azure_identity_adapter``), which is not safely picklable across the
617
+ Pool boundary. Drop it and let each worker rebuild its own provider via
618
+ ``resolve_runtime_provider()`` from ``model.auth_mode`` in config.yaml
619
+ (azure-identity caches in-process, so each worker gets its own short-lived cache).
620
+ """
621
+ if callable(self.api_key) and not isinstance(self.api_key, str):
622
+ worker_api_key = None
623
+ print(
624
+ "ℹ️ Detected Entra ID bearer provider — workers will rebuild "
625
+ "credentials from config.yaml in each process.",
626
+ flush=True,
627
+ )
628
+ else:
629
+ worker_api_key = self.api_key
630
+ config = {key: getattr(self, key) for key in _AGENT_PASSTHROUGH}
631
+ config["api_key"] = worker_api_key
632
+ for key in ("distribution", "model", "max_iterations", "verbose", "log_prefix_chars"):
633
+ config[key] = getattr(self, key)
634
+ return config
635
+
636
+ def _run_pool(self, config, checkpoint_data, completed_prompts_set, checkpoint_lock) -> List[Dict[str, Any]]:
637
+ """Process all batches in a worker pool, checkpointing after each result."""
638
+ print(f"\n🔧 Initializing {self.num_workers} worker processes...")
639
+
640
+ with Pool(processes=self.num_workers) as pool:
641
+ # output_dir as str for pickling
642
+ tasks = [
643
+ (batch_num, batch_data, str(self.output_dir), completed_prompts_set, config)
644
+ for batch_num, batch_data in enumerate(self.batches)
645
+ ]
646
+ print(f"✅ Created {len(tasks)} batch tasks")
647
+ print("🚀 Starting parallel batch processing...\n")
648
+
649
+ # rich Progress gives a persistent bottom bar; stdout/stderr are NOT
650
+ # redirected so worker prints stay visible.
651
+ results = []
652
+ console = Console(force_terminal=True)
653
+ with Progress(
654
+ SpinnerColumn(), TextColumn("[bold blue]📦 Batches"), BarColumn(bar_width=40),
655
+ MofNCompleteColumn(), TextColumn("•"), TimeRemainingColumn(),
656
+ console=console, refresh_per_second=2, transient=False,
657
+ redirect_stdout=False, redirect_stderr=False,
658
+ ) as progress:
659
+ task = progress.add_task("Processing", total=len(tasks))
660
+
661
+ # Temporarily suppress DEBUG logging to avoid bar interference
662
+ root_logger = logging.getLogger()
663
+ original_level = root_logger.level
664
+ root_logger.setLevel(logging.WARNING)
665
+
666
+ try:
667
+ for result in pool.imap_unordered(_process_batch_worker, tasks):
668
+ results.append(result)
669
+ progress.update(task, advance=1)
670
+
671
+ # Incremental checkpoint update (so resume works after crash)
672
+ try:
673
+ batch_num = result.get('batch_num')
674
+ completed = result.get('completed_prompts', []) or []
675
+ completed_prompts_set.update(completed)
676
+
677
+ if isinstance(batch_num, int):
678
+ checkpoint_data.setdefault('batch_stats', {})[str(batch_num)] = {
679
+ key: result.get(key, 0) for key in ('processed', 'skipped', 'discarded_no_reasoning')
680
+ }
681
+ checkpoint_data['completed_prompts'] = sorted(completed_prompts_set)
682
+ self._save_checkpoint(checkpoint_data, lock=checkpoint_lock)
683
+ except Exception as ckpt_err:
684
+ # Don't fail the run if checkpoint write fails
685
+ print(f"⚠️ Warning: Failed to save incremental checkpoint: {ckpt_err}")
686
+ except KeyboardInterrupt:
687
+ print("\n⚠️ Interrupted — terminating batch workers...")
688
+ pool.terminate()
689
+ pool.join()
690
+ raise
691
+ except Exception as e:
692
+ logger.error("Batch worker failed: %s", e, exc_info=True)
693
+ pool.terminate()
694
+ pool.join()
695
+ raise
696
+ finally:
697
+ root_logger.setLevel(original_level)
698
+ return results
699
+
700
+ def _combine_batch_files(self) -> Tuple[int, int]:
701
+ """Merge ALL ``batch_*.jsonl`` (old runs + resume) into ``trajectories.jsonl``.
702
+
703
+ Drops corrupted entries (hallucinated tool names, invalid JSON) and discard
704
+ tombstones (#93527, resume bookkeeping only). Returns ``(kept, files_found)``.
705
+ """
706
+ combined_file = self.output_dir / "trajectories.jsonl"
707
+ print(f"\n📦 Combining ALL batch files into {combined_file.name}...")
708
+ total_entries = 0
709
+ filtered_entries = 0
710
+ tombstone_entries = 0
711
+ batch_files_found = 0
712
+ all_batch_files = sorted(self.output_dir.glob("batch_*.jsonl"))
713
+
714
+ with open(combined_file, 'w', encoding='utf-8') as outfile:
715
+ for batch_file in all_batch_files:
716
+ batch_files_found += 1
717
+ batch_num = batch_file.stem.split("_")[1] # Extract batch number for logging
718
+
719
+ with open(batch_file, 'r', encoding='utf-8') as infile:
720
+ for line in infile:
721
+ total_entries += 1
722
+ try:
723
+ data = json.loads(line)
724
+
725
+ if data.get("discarded"):
726
+ tombstone_entries += 1
727
+ continue
728
+ tool_stats = data.get('tool_stats', {})
729
+ invalid_tools = [k for k in tool_stats if k not in ALL_POSSIBLE_TOOLS]
730
+
731
+ if invalid_tools:
732
+ filtered_entries += 1
733
+ invalid_preview = invalid_tools[0][:50] + "..." if len(invalid_tools[0]) > 50 else invalid_tools[0]
734
+ print(f" ⚠️ Filtering corrupted entry (batch {batch_num}): invalid tool '{invalid_preview}'")
735
+ continue
736
+ outfile.write(line)
737
+ except json.JSONDecodeError:
738
+ filtered_entries += 1
739
+ print(f" ⚠️ Filtering invalid JSON entry (batch {batch_num})")
740
+
741
+ if filtered_entries > 0:
742
+ print(f"⚠️ Filtered {filtered_entries} corrupted entries out of {total_entries} total")
743
+ kept = total_entries - filtered_entries - tombstone_entries
744
+ print(f"✅ Combined {batch_files_found} batch files into trajectories.jsonl ({kept} entries)")
745
+ return kept, batch_files_found
746
+
747
+ def _print_summary(self, results, total_tool_stats, total_reasoning_stats, kept, batch_files_found, start_time) -> None:
748
+ _banner("📊 BATCH PROCESSING COMPLETE")
749
+ print(f"✅ Prompts processed this run: {sum(r.get('processed', 0) for r in results)}")
750
+ print(f"✅ Total trajectories in merged file: {kept}")
751
+ print(f"✅ Total batch files merged: {batch_files_found}")
752
+ print(f"⏱️ Total duration: {round(time.time() - start_time, 2)}s")
753
+ print("\n📈 Tool Usage Statistics:")
754
+ print("-" * 70)
755
+
756
+ if total_tool_stats:
757
+ sorted_tools = sorted(total_tool_stats.items(), key=lambda x: x[1]["count"], reverse=True)
758
+ print(f"{'Tool Name':<25} {'Count':<10} {'Success':<10} {'Failure':<10} {'Success Rate':<12}")
759
+ print("-" * 70)
760
+ for tool_name, stats in sorted_tools:
761
+ print(f"{tool_name:<25} {stats['count']:<10} {stats['success']:<10} {stats['failure']:<10} {stats['success_rate']:.1f}%")
762
+ else:
763
+ print("No tool calls were made during this run.")
764
+ total_discarded = sum(r.get("discarded_no_reasoning", 0) for r in results)
765
+
766
+ print("\n🧠 Reasoning Coverage:")
767
+ print("-" * 70)
768
+ total_turns = total_reasoning_stats["total_assistant_turns"]
769
+ with_reasoning = total_reasoning_stats["turns_with_reasoning"]
770
+ without_reasoning = total_reasoning_stats["turns_without_reasoning"]
771
+ if total_turns > 0:
772
+ pct_with = round(with_reasoning / total_turns * 100, 1)
773
+ pct_without = round(without_reasoning / total_turns * 100, 1)
774
+ print(f" Total assistant turns: {total_turns:,}")
775
+ print(f" With reasoning: {with_reasoning:,} ({pct_with}%)")
776
+ print(f" Without reasoning: {without_reasoning:,} ({pct_without}%)")
777
+ else:
778
+ print(" No assistant turns recorded.")
779
+ if total_discarded > 0:
780
+ print(f" 🚫 Samples discarded (zero reasoning): {total_discarded:,}")
781
+ print(f"\n💾 Results saved to: {self.output_dir}")
782
+ print(" - Trajectories: trajectories.jsonl (combined)")
783
+ print(" - Individual batches: batch_*.jsonl (for debugging)")
784
+ print(f" - Statistics: {self.stats_file.name}")
785
+ print(f" - Checkpoint: {self.checkpoint_file.name}")
786
+
787
+ def run(self, resume: bool = False):
788
+ """Run the batch pipeline; with *resume*, skip prompts already present in batch files."""
789
+ _banner("🚀 Starting Batch Processing")
790
+
791
+ if resume and not self._apply_resume():
792
+ return
793
+
794
+ # Load existing checkpoint (so resume doesn't clobber prior progress)
795
+ checkpoint_data = self._load_checkpoint()
796
+ if checkpoint_data.get("run_name") != self.run_name:
797
+ checkpoint_data = self._empty_checkpoint()
798
+ config = self._worker_config()
799
+
800
+ # Index tracking is secondary to content matching (backward compatibility).
801
+ completed_prompts_set = set(checkpoint_data.get("completed_prompts", []))
802
+ start_time = time.time()
803
+
804
+ # Checkpoint writes happen in the parent process; keep a lock for safety.
805
+ checkpoint_lock = Lock()
806
+ results = self._run_pool(config, checkpoint_data, completed_prompts_set, checkpoint_lock)
807
+ total_tool_stats = {}
808
+ total_reasoning_stats = dict.fromkeys(_REASONING_KEYS, 0)
809
+ for batch_result in results:
810
+ _merge_tool_stats(total_tool_stats, batch_result.get("tool_stats", {}))
811
+ _merge_reasoning_stats(total_reasoning_stats, batch_result.get("reasoning_stats", {}))
812
+
813
+ # Final checkpoint is best-effort; incremental writes already happened.
814
+ try:
815
+ checkpoint_data["completed_prompts"] = sorted(completed_prompts_set)
816
+ self._save_checkpoint(checkpoint_data, lock=checkpoint_lock)
817
+ except Exception as ckpt_err:
818
+ print(f"⚠️ Warning: Failed to save final checkpoint: {ckpt_err}")
819
+
820
+ for stats in total_tool_stats.values():
821
+ total_calls = stats["success"] + stats["failure"]
822
+ stats["success_rate"] = round(stats["success"] / total_calls * 100, 2) if total_calls > 0 else 0.0
823
+ stats["failure_rate"] = round(stats["failure"] / total_calls * 100, 2) if total_calls > 0 else 0.0
824
+ kept, batch_files_found = self._combine_batch_files()
825
+ final_stats = {
826
+ "run_name": self.run_name,
827
+ "distribution": self.distribution,
828
+ "total_prompts": len(self.dataset),
829
+ "total_batches": len(self.batches),
830
+ "batch_size": self.batch_size,
831
+ # Snapshot the CLI-level credential/runtime fields BEFORE mutating them so a failed in-place
832
+ # agent swap can roll the whole CLI back to the old working model. Otherwise the broken
833
+ # credentials staged below leak into the next turn's resolution even though the agent itself
834
+ # rolled back (#50163).
835
+ # Snapshot CLI-level fields before mutation so a failed in-place swap rolls the whole CLI back
836
+ # to the old working model (#50163).
837
+ "model": self.model,
838
+ "completed_at": datetime.now().isoformat(),
839
+ "duration_seconds": round(time.time() - start_time, 2),
840
+ "tool_statistics": total_tool_stats,
841
+ "reasoning_statistics": total_reasoning_stats,
842
+ "discarded_no_reasoning": sum(r.get("discarded_no_reasoning", 0) for r in results),
843
+ }
844
+
845
+ with open(self.stats_file, 'w', encoding='utf-8') as f:
846
+ json.dump(final_stats, f, indent=2, ensure_ascii=False)
847
+ self._print_summary(results, total_tool_stats, total_reasoning_stats, kept, batch_files_found, start_time)
848
+
849
+
850
+ def _split_csv(value: Optional[str]) -> Optional[List[str]]:
851
+ """Comma-separated CLI string to a list of stripped items; ``None`` when empty."""
852
+ return [p.strip() for p in value.split(",")] if value else None
853
+
854
+
855
+ def main(
856
+ dataset_file: str = None,
857
+ batch_size: int = None,
858
+ run_name: str = None,
859
+ distribution: str = "default",
860
+ model: str = "anthropic/claude-sonnet-4.6",
861
+ api_key: str = None,
862
+ base_url: str = "https://openrouter.ai/api/v1",
863
+ max_turns: int = 10,
864
+ num_workers: int = 4,
865
+ resume: bool = False,
866
+ verbose: bool = False,
867
+ list_distributions: bool = False,
868
+ ephemeral_system_prompt: str = None,
869
+ log_prefix_chars: int = 100,
870
+ providers_allowed: str = None,
871
+ providers_ignored: str = None,
872
+ providers_order: str = None,
873
+ provider_sort: str = None,
874
+
875
+ reasoning_effort: str = None,
876
+ reasoning_disabled: bool = False,
877
+ prefill_messages_file: str = None,
878
+ max_samples: int = None,
879
+ ):
880
+ """
881
+ Run batch processing of agent prompts from a dataset.
882
+
883
+ Args:
884
+ dataset_file (str): Path to JSONL file with 'prompt' field in each entry
885
+ batch_size (int): Number of prompts per batch
886
+ run_name (str): Name for this run (used for output and checkpointing)
887
+ distribution (str): Toolset distribution to use (default: "default")
888
+ model (str): Model name to use (default: "claude-opus-4-20250514")
889
+ api_key (str): API key for model authentication
890
+ base_url (str): Base URL for model API
891
+ max_turns (int): Maximum number of tool calling iterations per prompt (default: 10)
892
+ num_workers (int): Number of parallel worker processes (default: 4)
893
+ resume (bool): Resume from checkpoint if run was interrupted (default: False)
894
+ verbose (bool): Enable verbose logging (default: False)
895
+ list_distributions (bool): List available toolset distributions and exit
896
+ ephemeral_system_prompt (str): System prompt used during agent execution but NOT saved to trajectories (optional)
897
+ log_prefix_chars (int): Number of characters to show in log previews for tool calls/responses (default: 20)
898
+ providers_allowed (str): Comma-separated list of OpenRouter providers to allow (e.g. "anthropic,openai")
899
+ providers_ignored (str): Comma-separated list of OpenRouter providers to ignore (e.g. "together,deepinfra")
900
+ providers_order (str): Comma-separated list of OpenRouter providers to try in order (e.g. "anthropic,openai,google")
901
+ provider_sort (str): Sort providers by "price", "throughput", or "latency" (OpenRouter only)
902
+
903
+ reasoning_effort (str): Reasoning effort: "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra" (default: "medium")
904
+ reasoning_disabled (bool): Completely disable reasoning/thinking tokens (default: False)
905
+ prefill_messages_file (str): Path to JSON file containing prefill messages (list of {role, content} dicts)
906
+ max_samples (int): Only process the first N samples from the dataset (optional, processes all if not set)
907
+
908
+ Examples:
909
+ # Basic usage
910
+ python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run
911
+
912
+ # Resume interrupted run
913
+ python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run --resume
914
+
915
+ # Use specific distribution
916
+ python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=image_test --distribution=image_gen
917
+
918
+ # With disabled reasoning
919
+ python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run \\
920
+ --reasoning_disabled
921
+
922
+ # With prefill messages from file
923
+ python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run \\
924
+ --prefill_messages_file=configs/prefill_opus.json
925
+
926
+ # List available distributions
927
+ python batch_runner.py --list_distributions
928
+ """
929
+ if list_distributions:
930
+ from toolset_distributions import print_distribution_info
931
+ print("📊 Available Toolset Distributions")
932
+ print("=" * 70)
933
+ all_dists = list_distributions()
934
+ for dist_name in sorted(all_dists.keys()):
935
+ print_distribution_info(dist_name)
936
+
937
+ print("\n💡 Usage:")
938
+ print(" python batch_runner.py --dataset_file=data.jsonl --batch_size=10 \\")
939
+ print(" --run_name=my_run --distribution=<name>")
940
+ return
941
+
942
+ for invalid, message in (
943
+ (not dataset_file, "--dataset_file is required"),
944
+ (not batch_size or batch_size < 1, "--batch_size must be a positive integer"),
945
+ (not run_name, "--run_name is required"),
946
+ ):
947
+ if invalid:
948
+ print(f"❌ Error: {message}")
949
+ raise SystemExit(1)
950
+
951
+ # --reasoning_disabled takes priority, then --reasoning_effort, then default (medium)
952
+ reasoning_config = None
953
+ if reasoning_disabled:
954
+ reasoning_config = {"effort": "none"}
955
+ print("🧠 Reasoning: DISABLED (effort=none)")
956
+ elif reasoning_effort:
957
+ valid_efforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
958
+ if reasoning_effort not in valid_efforts:
959
+ print(f"❌ Error: --reasoning_effort must be one of: {', '.join(valid_efforts)}")
960
+ raise SystemExit(1)
961
+ reasoning_config = {"enabled": True, "effort": reasoning_effort}
962
+ print(f"🧠 Reasoning effort: {reasoning_effort}")
963
+ prefill_messages = None
964
+ if prefill_messages_file:
965
+ try:
966
+ with open(prefill_messages_file, 'r', encoding='utf-8') as f:
967
+ prefill_messages = json.load(f)
968
+ if not isinstance(prefill_messages, list):
969
+ print("❌ Error: prefill_messages_file must contain a JSON array of messages")
970
+ raise SystemExit(1)
971
+ print(f"💬 Loaded {len(prefill_messages)} prefill messages from {prefill_messages_file}")
972
+ except Exception as e:
973
+ print(f"❌ Error loading prefill messages: {e}")
974
+ raise SystemExit(1)
975
+
976
+ try:
977
+ runner = BatchRunner(
978
+ dataset_file=dataset_file,
979
+ batch_size=batch_size,
980
+ run_name=run_name,
981
+ distribution=distribution,
982
+ max_iterations=max_turns,
983
+ base_url=base_url,
984
+ api_key=api_key,
985
+ model=model,
986
+ num_workers=num_workers,
987
+ verbose=verbose,
988
+ ephemeral_system_prompt=ephemeral_system_prompt,
989
+ log_prefix_chars=log_prefix_chars,
990
+ providers_allowed=_split_csv(providers_allowed),
991
+ providers_ignored=_split_csv(providers_ignored),
992
+ providers_order=_split_csv(providers_order),
993
+ provider_sort=provider_sort,
994
+
995
+ reasoning_config=reasoning_config,
996
+ prefill_messages=prefill_messages,
997
+ max_samples=max_samples,
998
+ )
999
+ runner.run(resume=resume)
1000
+ except Exception as e:
1001
+ print(f"\n❌ Fatal error: {e}")
1002
+ if verbose:
1003
+ traceback.print_exc()
1004
+ raise SystemExit(1)
1005
+
1006
+
1007
+ if __name__ == "__main__":
1008
+ fire.Fire(main)
cli-config.yaml.example ADDED
The diff for this file is too large to render. See raw diff
 
constraints-termux.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Termux / Android dependency constraints for Hermes Agent.
2
+ #
3
+ # Usage:
4
+ # python -m pip install -e '.[termux]' -c constraints-termux.txt
5
+ #
6
+ # These pins keep the tested Android install path stable when upstream packages
7
+ # move faster than Termux-compatible wheels / sdists.
8
+
9
+ ipython<10
10
+ jedi>=0.18.1,<0.20
11
+ parso>=0.8.4,<0.9
12
+ stack-data>=0.6,<0.7
13
+ pexpect>4.3,<5
14
+ matplotlib-inline>=0.1.7,<0.2
15
+ asttokens>=2.1,<3
docker-compose.windows.yml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #
2
+ # docker-compose.windows.yml — Windows Docker Desktop compatible
3
+ #
4
+ # Differences from docker-compose.yml:
5
+ # - Removes `network_mode: host` (not supported on Docker Desktop for Windows)
6
+ # - Uses explicit port mappings instead
7
+ # - Uses Windows-style volume path for ~/.hermes
8
+ #
9
+ # Usage:
10
+ # docker compose -f docker-compose.windows.yml up -d
11
+ #
12
+ services:
13
+ gateway:
14
+ image: nousresearch/hermes-agent:latest
15
+ container_name: hermes
16
+ restart: unless-stopped
17
+ volumes:
18
+ - ${USERPROFILE}/.hermes:/opt/data
19
+ environment:
20
+ - HERMES_UID=10000
21
+ - HERMES_GID=10000
22
+ command: ["gateway", "run"]
23
+
24
+ dashboard:
25
+ image: nousresearch/hermes-agent:latest
26
+ container_name: hermes-dashboard
27
+ restart: unless-stopped
28
+ depends_on:
29
+ - gateway
30
+ volumes:
31
+ - ${USERPROFILE}/.hermes:/opt/data
32
+ environment:
33
+ - HERMES_UID=10000
34
+ - HERMES_GID=10000
35
+ - HERMES_DASHBOARD_HOST=0.0.0.0
36
+ ports:
37
+ - "127.0.0.1:9119:9119"
38
+ command: ["dashboard", "--host", "0.0.0.0", "--port", "9119", "--no-open", "--insecure"]
flake.lock ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "nodes": {
3
+ "flake-parts": {
4
+ "inputs": {
5
+ "nixpkgs-lib": [
6
+ "nixpkgs"
7
+ ]
8
+ },
9
+ "locked": {
10
+ "lastModified": 1782949081,
11
+ "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
12
+ "owner": "hercules-ci",
13
+ "repo": "flake-parts",
14
+ "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
15
+ "type": "github"
16
+ },
17
+ "original": {
18
+ "owner": "hercules-ci",
19
+ "repo": "flake-parts",
20
+ "type": "github"
21
+ }
22
+ },
23
+ "home-manager": {
24
+ "inputs": {
25
+ "nixpkgs": [
26
+ "nixpkgs"
27
+ ]
28
+ },
29
+ "locked": {
30
+ "lastModified": 1786487128,
31
+ "narHash": "sha256-ad60hrRVhH/bo3Jl1YLzO+QcS3mUNZr9LNJdzhMO2P4=",
32
+ "owner": "nix-community",
33
+ "repo": "home-manager",
34
+ "rev": "f404edbfa4117810c96b97048299242fc50e5362",
35
+ "type": "github"
36
+ },
37
+ "original": {
38
+ "owner": "nix-community",
39
+ "repo": "home-manager",
40
+ "type": "github"
41
+ }
42
+ },
43
+ "nixpkgs": {
44
+ "locked": {
45
+ "lastModified": 1785318670,
46
+ "narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=",
47
+ "owner": "NixOS",
48
+ "repo": "nixpkgs",
49
+ "rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5",
50
+ "type": "github"
51
+ },
52
+ "original": {
53
+ "owner": "NixOS",
54
+ "ref": "nixos-unstable",
55
+ "repo": "nixpkgs",
56
+ "type": "github"
57
+ }
58
+ },
59
+ "npm-lockfile-fix": {
60
+ "inputs": {
61
+ "nixpkgs": [
62
+ "nixpkgs"
63
+ ]
64
+ },
65
+ "locked": {
66
+ "lastModified": 1775903712,
67
+ "narHash": "sha256-2GV79U6iVH4gKAPWYrxUReB0S41ty/Y3dBLquU8AlaA=",
68
+ "owner": "jeslie0",
69
+ "repo": "npm-lockfile-fix",
70
+ "rev": "c6093acb0c0548e0f9b8b3d82918823721930fe8",
71
+ "type": "github"
72
+ },
73
+ "original": {
74
+ "owner": "jeslie0",
75
+ "repo": "npm-lockfile-fix",
76
+ "type": "github"
77
+ }
78
+ },
79
+ "pyproject-build-systems": {
80
+ "inputs": {
81
+ "nixpkgs": [
82
+ "nixpkgs"
83
+ ],
84
+ "pyproject-nix": [
85
+ "pyproject-nix"
86
+ ],
87
+ "uv2nix": [
88
+ "uv2nix"
89
+ ]
90
+ },
91
+ "locked": {
92
+ "lastModified": 1785115949,
93
+ "narHash": "sha256-8AM37BfyGaL2v/SZyg4PupRxJ01Y4htvM+WrTjWrPpo=",
94
+ "owner": "pyproject-nix",
95
+ "repo": "build-system-pkgs",
96
+ "rev": "62c0d86027edb1c4f39a5facc09876348144f7c9",
97
+ "type": "github"
98
+ },
99
+ "original": {
100
+ "owner": "pyproject-nix",
101
+ "repo": "build-system-pkgs",
102
+ "type": "github"
103
+ }
104
+ },
105
+ "pyproject-nix": {
106
+ "inputs": {
107
+ "nixpkgs": [
108
+ "nixpkgs"
109
+ ]
110
+ },
111
+ "locked": {
112
+ "lastModified": 1784591072,
113
+ "narHash": "sha256-zP/WaDxrRu8GANZM61+V2LT/7ycEEdoyLWn7M6WzU7M=",
114
+ "owner": "pyproject-nix",
115
+ "repo": "pyproject.nix",
116
+ "rev": "e3b599ca2e7fcf93d4edf65d7f19bbf6491724f3",
117
+ "type": "github"
118
+ },
119
+ "original": {
120
+ "owner": "pyproject-nix",
121
+ "repo": "pyproject.nix",
122
+ "type": "github"
123
+ }
124
+ },
125
+ "root": {
126
+ "inputs": {
127
+ "flake-parts": "flake-parts",
128
+ "home-manager": "home-manager",
129
+ "nixpkgs": "nixpkgs",
130
+ "npm-lockfile-fix": "npm-lockfile-fix",
131
+ "pyproject-build-systems": "pyproject-build-systems",
132
+ "pyproject-nix": "pyproject-nix",
133
+ "uv2nix": "uv2nix"
134
+ }
135
+ },
136
+ "uv2nix": {
137
+ "inputs": {
138
+ "nixpkgs": [
139
+ "nixpkgs"
140
+ ],
141
+ "pyproject-nix": [
142
+ "pyproject-nix"
143
+ ]
144
+ },
145
+ "locked": {
146
+ "lastModified": 1785277507,
147
+ "narHash": "sha256-9Tq3UDX2hD/aveW/HvkBlAmEwJTOlY5HQXJM+L5BGmE=",
148
+ "owner": "pyproject-nix",
149
+ "repo": "uv2nix",
150
+ "rev": "5a836d395cbf5fc22670eb98dd4aa4fc4d406977",
151
+ "type": "github"
152
+ },
153
+ "original": {
154
+ "owner": "pyproject-nix",
155
+ "repo": "uv2nix",
156
+ "type": "github"
157
+ }
158
+ }
159
+ },
160
+ "root": "root",
161
+ "version": 7
162
+ }
hermes_bootstrap.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Windows UTF-8 bootstrap for Hermes entry points (no-op on POSIX).
2
+
3
+ Windows binds stdio to the console code page (cp1252), so ``print("café")`` raises
4
+ ``UnicodeEncodeError``, and Python children inherit the same default unless
5
+ ``PYTHONUTF8``/``PYTHONIOENCODING`` are set. Import this module first in every entry
6
+ point (``hermes``, ``hermes-agent``, ``hermes-acp``, ``gateway.run``, ``batch_runner``,
7
+ ``cron/scheduler``). It does NOT re-exec with ``-X utf8``: ``open()`` in the current
8
+ process still needs an explicit ``encoding="utf-8"`` (ruff ``PLW1514``). POSIX is left
9
+ alone deliberately — users' ``LANG``/``LC_*`` choices are respected.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import sys
16
+
17
+ _IS_WINDOWS = sys.platform == "win32"
18
+ _bootstrap_applied = False
19
+
20
+
21
+ def apply_windows_utf8_bootstrap() -> bool:
22
+ """Apply the Windows UTF-8 bootstrap once; True only when it was applied this call."""
23
+ global _bootstrap_applied
24
+
25
+ if not _IS_WINDOWS or _bootstrap_applied:
26
+ return False
27
+
28
+ # setdefault() so a user can opt out with PYTHONUTF8=0 / PYTHONIOENCODING=...
29
+ os.environ.setdefault("PYTHONUTF8", "1")
30
+ os.environ.setdefault("PYTHONIOENCODING", "utf-8")
31
+
32
+ # os.environ changes don't rebind streams bound at interpreter startup, so
33
+ # reconfigure them in-process. errors="replace" keeps a non-UTF-8 legacy
34
+ # pipe on stdin from crashing us (U+FFFD instead of an exception).
35
+ # Non-TextIOWrapper streams (BytesIO in tests, embedded hosts) have no
36
+ # reconfigure(): skip — the env-var fix for children is the bigger win.
37
+ for stream_name in ("stdout", "stderr", "stdin"):
38
+ reconfigure = getattr(getattr(sys, stream_name, None), "reconfigure", None)
39
+ if reconfigure is None:
40
+ continue
41
+ try:
42
+ reconfigure(encoding="utf-8", errors="replace")
43
+ except (OSError, ValueError):
44
+ pass # closed, or replaced with something non-reconfigurable
45
+
46
+ _bootstrap_applied = True
47
+ return True
48
+
49
+
50
+ def suppress_platform_ver_console() -> None:
51
+ """Stub ``platform._syscmd_ver`` on Windows — decode-crash + console-flash guard.
52
+
53
+ ``platform.win32_ver()`` (reached via ``platform.platform()``, which the OpenAI SDK
54
+ calls) shells out ``cmd /c ver`` with ``shell=True`` and no ``CREATE_NO_WINDOW``: a
55
+ windowless parent (pythonw gateway, slash/kanban workers) flashes a console per call,
56
+ and Python 3.11.0/3.11.1 (no ``encoding="locale"`` fix) strict-utf-8-decodes the OEM
57
+ code page output under PEP 540 mode and raises (#69413). Returning the inputs makes
58
+ ``win32_ver()`` fall back to ``sys.getwindowsversion()`` — same data, no subprocess.
59
+ Mirrors ``hermes_cli._subprocess_compat.suppress_platform_ver_console`` for callers
60
+ that never import ``hermes_cli.main``; double application is harmless.
61
+ """
62
+ if not _IS_WINDOWS:
63
+ return
64
+ try:
65
+ import platform
66
+
67
+ if hasattr(platform, "_syscmd_ver"):
68
+ def _quiet_syscmd_ver(system="", release="", version="",
69
+ supported_platforms=("win32", "win16", "dos")):
70
+ return system, release, version
71
+
72
+ platform._syscmd_ver = _quiet_syscmd_ver
73
+ except Exception:
74
+ pass # hardening only — never break an entry point
75
+
76
+
77
+ def harden_import_path(src_root: str | None = None) -> None:
78
+ """Stop a package in the current directory from shadowing Hermes modules.
79
+
80
+ Hermes ships top-level modules with common names (``utils``, ``proxy``, ``ui``); a
81
+ project with its own ``utils/`` launched from its directory would win the import.
82
+ The cwd reaches ``sys.path`` as ``""``/``"."`` (script/``-m`` launches) AND as an
83
+ absolute path (venv activation, PYTHONPATH), so both are handled: relative forms are
84
+ dropped and the Hermes root is *relocated* to the front, not merely inserted when
85
+ absent. ``src_root`` defaults to this module's directory (the repo root for every
86
+ shipped entry point), so no spawner env var is required.
87
+ """
88
+ root = src_root or os.environ.get("HERMES_PYTHON_SRC_ROOT") or os.path.dirname(
89
+ os.path.abspath(__file__)
90
+ )
91
+
92
+ sys.path[:] = [p for p in sys.path if p not in ("", ".")]
93
+
94
+ root_abs = os.path.abspath(root)
95
+ sys.path[:] = [p for p in sys.path if os.path.abspath(p) != root_abs]
96
+ sys.path.insert(0, root)
97
+
98
+
99
+ def activate_durable_lazy_target() -> None:
100
+ """Put the durable lazy-install dir (``HERMES_LAZY_INSTALL_TARGET``) on ``sys.path``.
101
+
102
+ Immutable Docker images seal the venv and redirect lazy installs to the data volume;
103
+ packages installed there on a previous run must be importable before any backend
104
+ imports its SDK. Appends to the END of ``sys.path`` so the core venv always wins name
105
+ collisions (see ``tools.lazy_deps``). Never raises; unset target is a no-op.
106
+ """
107
+ if not os.environ.get("HERMES_LAZY_INSTALL_TARGET", "").strip():
108
+ return
109
+ try:
110
+ from tools import lazy_deps
111
+ lazy_deps.activate_durable_lazy_target()
112
+ except Exception:
113
+ pass # a failed activation just leaves the backend reporting itself unavailable
114
+
115
+
116
+ # Apply on import — entry points only need ``import hermes_bootstrap`` first.
117
+ apply_windows_utf8_bootstrap()
118
+ suppress_platform_ver_console()
119
+ activate_durable_lazy_target()
hermes_cli/_scan_venv_blockers.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone venv-process scan for JSON consumption (``python -m hermes_cli._scan_venv_blockers``).
2
+
3
+ Exits 0 for valid clear or blocked results. Non-zero exit signals probe failure (the detector itself
4
+ crashed, psutil unavailable, etc.). Exactly one JSON document on stdout; diagnostics on stderr only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ import sys
12
+ from pathlib import PureWindowsPath
13
+ from typing import NoReturn
14
+
15
+ # Long CLI flags whose argument value must be redacted from the cmdline. Short flags (-t, -k, -p)
16
+ # are intentionally not redacted — ambiguous and useful diagnostics (toolset, port, profile).
17
+ _SENSITIVE_LONG_FLAGS: list[str] = [
18
+ "--token", "--api-key", "--password", "--secret", "--authorization", "--access-key",
19
+ "--private-key", "--session-key",
20
+ ]
21
+
22
+ _PYTHON_PROCESS_NAMES = {"python.exe", "pythonw.exe", "python", "pythonw"}
23
+ _UPDATER_STOPPABLE_PURPOSES = ("serve", "dashboard")
24
+
25
+
26
+ def _probe_fail_json(diagnostic: str = "probe failed") -> str:
27
+ """The standard probe-failure JSON document.
28
+
29
+ ``ok: false`` plus ``probe_failed: true`` means the detector itself could not run — this is
30
+ *not* a clear scan. Callers must treat ``ok is not True`` / non-zero exit as probe failure,
31
+ never as ``blocked: false`` "clear".
32
+
33
+ See #83149.
34
+ """
35
+ return json.dumps({"ok": False, "probe_failed": True, "blocked": False, "processes": [],
36
+ "error": diagnostic})
37
+
38
+
39
+ def _emit_probe_fail(diagnostic: str) -> NoReturn:
40
+ """Print one JSON to stdout, diagnostic to stderr, exit non-zero."""
41
+ print(_probe_fail_json(diagnostic))
42
+ print(diagnostic, file=sys.stderr)
43
+ sys.exit(1)
44
+
45
+
46
+ def _find_flag(text: str, flag: str) -> int:
47
+ """Index of *flag* (case-insensitive) at string start or after a space; -1 if absent."""
48
+ low, fl, pos = text.lower(), flag.lower(), 0
49
+ while True:
50
+ idx = low.find(fl, pos)
51
+ if idx == -1 or idx == 0 or text[idx - 1] == " ":
52
+ return idx
53
+ pos = idx + 1
54
+
55
+
56
+ def _redact_sensitive_cmdline(cmdline: str) -> str:
57
+ """Apply the shared secret redactor, then replace everything after a sensitive long flag."""
58
+ try:
59
+ from agent.redact import redact_sensitive_text # noqa: PLC0415
60
+
61
+ cmdline = redact_sensitive_text(cmdline, force=True)
62
+ except Exception:
63
+ return "<redacted>"
64
+ # --flag=value → preserve "--flag="; --flag value → preserve "--flag ".
65
+ earliest = len(cmdline)
66
+ for flag in _SENSITIVE_LONG_FLAGS:
67
+ for suffix in ("=", " "):
68
+ idx = _find_flag(cmdline, flag + suffix)
69
+ if idx != -1 and idx + len(flag) + 1 < earliest:
70
+ earliest = idx + len(flag) + 1
71
+ if earliest < len(cmdline):
72
+ return cmdline[:earliest] + "<redacted>"
73
+ return cmdline
74
+
75
+
76
+ def _classify_local_preview_args(args: object) -> dict[str, object]:
77
+ """Safe UI metadata for an exact ``python -m http.server`` argv; ``{}`` otherwise.
78
+
79
+ The general holder detector truncates its diagnostic command line; reading argv separately
80
+ preserves a useful directory label without exposing an unbounded command line to the renderer.
81
+ """
82
+ if not isinstance(args, (list, tuple)) or not all(isinstance(arg, str) for arg in args):
83
+ return {}
84
+ # ``-m`` must be the first argument after the executable: a later ``-m http.server`` can be
85
+ # data passed to an unrelated script and must never authorize termination.
86
+ if len(args) < 3 or args[1] != "-m" or args[2].lower() != "http.server":
87
+ return {}
88
+ port = 8000
89
+ if len(args) > 3 and args[3].isdigit() and 0 < int(args[3]) <= 65535:
90
+ port = int(args[3])
91
+ label = ""
92
+ try:
93
+ directory_index = args.index("--directory")
94
+ if directory_index + 1 < len(args):
95
+ label = PureWindowsPath(args[directory_index + 1]).name
96
+ except ValueError:
97
+ pass
98
+ metadata: dict[str, object] = {"kind": "local-preview", "safeToStop": True, "port": port}
99
+ if label:
100
+ metadata["label"] = label
101
+ return metadata
102
+
103
+
104
+ def _local_preview_metadata(pid: int, name: str) -> dict[str, object]:
105
+ if name.lower() not in _PYTHON_PROCESS_NAMES:
106
+ return {}
107
+ try:
108
+ import psutil # noqa: PLC0415
109
+
110
+ process = psutil.Process(pid)
111
+ metadata = _classify_local_preview_args(process.cmdline())
112
+ if metadata:
113
+ metadata["createTime"] = process.create_time()
114
+ return metadata
115
+ except Exception:
116
+ return {}
117
+
118
+
119
+ def _terminate_safe_preview(
120
+ pid: int, expected_create_time: float, *, psutil_module: object | None = None,
121
+ ) -> tuple[bool, str | None]:
122
+ """Terminate one verified local preview process tree.
123
+
124
+ A fresh ``psutil.Process`` identity check and exact argv classification occur immediately before
125
+ termination; psutil guards mutating Process methods against PID reuse (no taskkill stale-PID
126
+ race).
127
+ """
128
+ try:
129
+ if psutil_module is None:
130
+ import psutil as psutil_module # type: ignore[no-redef] # noqa: PLC0415
131
+
132
+ process = psutil_module.Process(pid) # type: ignore[attr-defined]
133
+ if abs(process.create_time() - expected_create_time) > 0.001:
134
+ return False, "process identity changed"
135
+ if not _classify_local_preview_args(process.cmdline()):
136
+ return False, "process is no longer a local preview"
137
+ targets = [*reversed(process.children(recursive=True)), process]
138
+ for target in targets:
139
+ target.terminate()
140
+ _gone, alive = psutil_module.wait_procs(targets, timeout=3) # type: ignore[attr-defined]
141
+ for target in alive:
142
+ target.kill()
143
+ if alive:
144
+ psutil_module.wait_procs(alive, timeout=2) # type: ignore[attr-defined]
145
+ return True, None
146
+ except Exception as exc:
147
+ return False, f"termination failed: {type(exc).__name__}"
148
+
149
+
150
+ def _is_pausable_gateway(cmdline: str) -> bool:
151
+ """True when *cmdline* is a gateway process the updater can pause.
152
+
153
+ Only gateway invocations are exempted; anything else running from the venv has no pause
154
+ machinery downstream and must keep blocking the handoff. Delegates to the canonical
155
+ ``gateway run`` matcher so this exemption, pause discovery and the updater's guard share one
156
+ parser.
157
+ """
158
+ try:
159
+ from gateway.status import looks_like_gateway_command_line # noqa: PLC0415
160
+ except Exception:
161
+ return False
162
+ return looks_like_gateway_command_line(cmdline)
163
+
164
+
165
+ def _is_updater_owned_backend(pid: int, cmdline: str) -> bool:
166
+ """True when *pid* is a Hermes backend the CLI updater can stop (positive ledger identity).
167
+
168
+ The gateway exemption above keeps ``gateway run`` holders out of the blocker list because the updater's
169
+ own pause machinery stops and resumes them. ``hermes serve`` / ``hermes dashboard`` backends had no such
170
+ deferral, so a leaked serve child (or a Desktop-owned backend the teardown lost track of) dead-ended the
171
+ hand-off with ``venv-blocked`` — or, worse, survived the hand-off and made the shim quarantine fail with
172
+ ``os error 32`` (#98336) — even though the updater downstream owns exactly this case with its ledger
173
+ rungs (`_ledger_reapable_backend_pids` reaps dead-spawner orphans; `_ledger_manual_serve_holders` stops
174
+ manual serves and relaunches them on their recorded host/port).
175
+ Positive identity only — never name/substring matching (#90778, and the 99558 identity-guard contract):
176
+ """
177
+ return _updater_owned_backend_entry(pid, cmdline) is not None
178
+
179
+
180
+ def _updater_owned_backend_entry(pid: int, cmdline: str) -> dict | None:
181
+ """Ledger entry for a deferred serve/dashboard backend, or ``None`` when it must block.
182
+
183
+ Returning the entry lets ``main()`` emit sanitized decision evidence — structured identity
184
+ fields only, never argv, which can carry tokens or private endpoints.
185
+
186
+ See #98350.
187
+ """
188
+ try:
189
+ from hermes_cli.update_cmd import _hermes_holder_subcommand # noqa: PLC0415
190
+
191
+ purpose = _hermes_holder_subcommand(cmdline)
192
+ except Exception:
193
+ return None
194
+ if purpose not in _UPDATER_STOPPABLE_PURPOSES:
195
+ return None
196
+ try:
197
+ from hermes_cli.process_identity import ledger_entries, spawner_is_dead # noqa: PLC0415
198
+
199
+ entries = ledger_entries()
200
+ except Exception:
201
+ return None
202
+ for entry in entries:
203
+ if entry.get("pid") != pid:
204
+ continue
205
+ if entry.get("purpose") not in _UPDATER_STOPPABLE_PURPOSES:
206
+ return None
207
+ # Spawner dead, unrecorded, or unprovable-but-registered: the updater's ledger rungs own
208
+ # this holder (reap or stop+relaunch).
209
+ if spawner_is_dead(entry) is not False or _spawner_is_this_handoff_desktop(entry):
210
+ return entry
211
+ return None
212
+ return None
213
+
214
+
215
+ def _deferred_backend_evidence(entries: list[dict]) -> list[dict]:
216
+ """Sanitized evidence (pid, purpose, recorded port — never argv) for deferred backends.
217
+
218
+ Structured ledger fields only — pid, purpose, recorded port — never the command line, which can carry
219
+ tokens or private endpoints. Lets the scan result explain *why* a holder disappeared from ``processes``
220
+ without echoing argv (#98350).
221
+ """
222
+ return [{"pid": entry.get("pid"), "purpose": entry.get("purpose"), "port": entry.get("port")}
223
+ for entry in entries if isinstance(entry.get("pid"), int)]
224
+
225
+
226
+ def _spawner_is_this_handoff_desktop(entry: dict) -> bool:
227
+ """True when the entry's live spawner is an ancestor of this scan.
228
+
229
+ The scan is spawned by the Desktop app's update preflight, so the Desktop performing the
230
+ hand-off is in our ancestor chain. Identity is ``(pid, create_time)`` — a recycled PID cannot
231
+ forge the pair.
232
+ """
233
+ spawner_pid = entry.get("spawner_pid")
234
+ if not isinstance(spawner_pid, int) or spawner_pid <= 0:
235
+ return False
236
+ try:
237
+ import psutil # noqa: PLC0415
238
+
239
+ for ancestor in psutil.Process().parents():
240
+ if ancestor.pid != spawner_pid:
241
+ continue
242
+ expected = entry.get("spawner_create")
243
+ if expected is None:
244
+ return True
245
+ return abs(float(ancestor.create_time()) - float(expected)) < 2.0
246
+ except Exception:
247
+ return False
248
+ return False
249
+
250
+
251
+ def main() -> None:
252
+ """Entry point. Prints one JSON doc to stdout. Exits 0 for valid scan."""
253
+ try:
254
+ import psutil # noqa: PLC0415, F401
255
+ except Exception as exc:
256
+ _emit_probe_fail(f"psutil is not available: {exc}")
257
+ try:
258
+ from hermes_cli.update_cmd import _detect_venv_python_processes
259
+
260
+ matches = _detect_venv_python_processes()
261
+ except Exception as exc:
262
+ _emit_probe_fail(f"scan aborted: {exc}")
263
+
264
+ processes = []
265
+ exempted_gateways = 0
266
+ deferred_entries: list[dict] = []
267
+ for pid, name, cmdline in matches:
268
+ if _is_pausable_gateway(cmdline):
269
+ exempted_gateways += 1
270
+ continue
271
+ deferred_entry = _updater_owned_backend_entry(pid, cmdline)
272
+ if deferred_entry is not None:
273
+ # Ledger-verified backend the updater's own rungs stop (and relaunch) downstream —
274
+ # reporting it here would dead-end the hand-off before that machinery can run.
275
+ # See #98336.
276
+ deferred_entries.append(deferred_entry)
277
+ continue
278
+ # Truncate for display AFTER the gateway exemption has seen the full cmdline (long
279
+ # managed-runtime interpreter paths would otherwise swallow the `gateway run` argv).
280
+ process = {"pid": pid, "name": name, "cmdline": _redact_sensitive_cmdline(cmdline)[:120]}
281
+ process.update(_local_preview_metadata(pid, name))
282
+ processes.append(process)
283
+
284
+ # pausable_gateways / deferred_backends / deferred_backend_evidence are diagnostic only.
285
+ data = {
286
+ "ok": True,
287
+ "blocked": bool(processes),
288
+ "processes": processes,
289
+ "pausable_gateways": exempted_gateways,
290
+ # Diagnostic only: ledger-verified serve/dashboard backends deferred to the updater's stop/relaunch
291
+ # rungs (#98336).
292
+ "deferred_backends": len(deferred_entries),
293
+ # Diagnostic only: sanitized evidence (structured ledger identity, never argv) explaining which
294
+ # holders the deferral consumed (#98350).
295
+ "deferred_backend_evidence": _deferred_backend_evidence(deferred_entries),
296
+ }
297
+ print(json.dumps(data))
298
+ sys.exit(0)
299
+
300
+
301
+ def _terminate_safe_main(argv: list[str]) -> NoReturn:
302
+ if len(argv) != 2:
303
+ print(json.dumps({"ok": False, "error": "expected pid and create time"}))
304
+ raise SystemExit(2)
305
+ try:
306
+ pid = int(argv[0])
307
+ create_time = float(argv[1])
308
+ if pid <= 0 or not math.isfinite(create_time) or create_time <= 0:
309
+ raise ValueError
310
+ except ValueError:
311
+ print(json.dumps({"ok": False, "error": "invalid process identity"}))
312
+ raise SystemExit(2)
313
+ stopped, error = _terminate_safe_preview(pid, create_time)
314
+ print(json.dumps({"ok": stopped, "pid": pid, "error": error}))
315
+ raise SystemExit(0 if stopped else 1)
316
+
317
+
318
+ if __name__ == "__main__":
319
+ if len(sys.argv) > 1 and sys.argv[1] == "--terminate-safe":
320
+ _terminate_safe_main(sys.argv[2:])
321
+ main()
hermes_cli/anon_auth.py ADDED
@@ -0,0 +1,869 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Nous free-tier identity: the ``anonymous`` auth method of the ``nous`` provider.
2
+
3
+ The identity is created in exactly one place, at boot (``hermes_cli.free_tier_bootstrap``), and only
4
+ while ``HERMES_GUEST_ONBOARDING=1`` (see ``guest_enabled``). The bootstrap mints an anonymous Nous
5
+ account (``POST /api/anonymous/create``); its ``anon_`` credential is later exchanged for short-lived
6
+ JWTs (``POST /api/anonymous/token``). The result is persisted as the singleton ``providers.nous``; it
7
+ becomes ``active_provider`` only when the bootstrap's inventory found nothing else usable, so an
8
+ install with its own key keeps that key for inference and uses the identity for connectors only. In
9
+ the resolver ladder (``resolve_provider``) an existing free-tier identity sits directly above the
10
+ implicit AWS Bedrock chain (NS-829): any explicit provider (env key, ``model.provider``, OpenRouter
11
+ pool, a logged-in ``active_provider``) beats it, and the ladder never creates one.
12
+
13
+ Only two mechanics differ from an OAuth login and both are isolated behind ``is_guest_state``:
14
+ token acquisition (re-exchange the ``anon_`` credential; there is no refresh token) and routing
15
+ (the welcome inference host, single model ``nous/welcome``).
16
+
17
+ Users are never shown the words guest / anonymous / account for this state: surfaces say
18
+ "Nous · free tier". Two user-facing verbs reach the same flow, both keeping the identity's
19
+ connectors: ``hermes auth upgrade`` in a terminal and ``/login`` inside a chat.
20
+
21
+ Lifecycle lives in ONE primitive, :func:`ensure_portal_identity`: adopt what the shared store already
22
+ holds, else mint under the shared-store lock. It is the only minter; nothing else calls
23
+ :func:`mint_guest`.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import os
30
+ import time
31
+ from datetime import datetime, timedelta, timezone
32
+ from typing import Any, Callable, Dict, Optional
33
+
34
+ from agent.retry_utils import parse_retry_after_seconds
35
+ from hermes_cli.auth_constants import (
36
+ AuthError, DEFAULT_NOUS_PORTAL_URL, DEFAULT_NOUS_WELCOME_URL, _decode_jwt_claims, httpx)
37
+
38
+ logger = logging.getLogger("hermes_cli.auth")
39
+
40
+ ANON_AUTH_METHOD = "anonymous"
41
+ ANON_CLIENT_ID = "nas-anonymous"
42
+ ANON_ACCOUNT_TIER = "anonymous"
43
+ GUEST_MODEL = "nous/welcome"
44
+ ANON_SECRET_HEADER = "x-anonymous-api-secret"
45
+ # The shared secret gates the anonymous surface during its integration phase. It is a deployment
46
+ # secret (Sid's), read from the environment only.
47
+ ANON_SECRET_ENV = "HERMES_ANON_API_SECRET"
48
+ # Launch gate for the whole free tier while it is pre-GA: exactly "1" turns it on for this process
49
+ # (CLI, gateway, serve backend alike); anything else leaves every surface behaving as if the free
50
+ # tier did not exist. ``guest_enabled`` is the only reader. Not a user preference: never written to
51
+ # config.yaml or .env, never shown in setup. Deleted at GA together with this comment.
52
+ GUEST_ONBOARDING_ENV = "HERMES_GUEST_ONBOARDING"
53
+ GUEST_MINT_TIMEOUT_SECONDS = 5.0
54
+ # Copy shared by every surface that names the free tier (R-USR-1): never guest / anonymous / account.
55
+ FREE_TIER_LABEL = "Nous · free tier"
56
+ UPGRADE_HINT = "Run `hermes auth upgrade` to sign in with a Nous account, or /login inside a chat."
57
+ FREE_TIER_NOT_SIGNED_IN = (
58
+ "You're not signed in. Free inference and connectors are always on. "
59
+ "Run `hermes auth` to sign in with a Nous account.")
60
+
61
+
62
+ class AnonCredentialDead(AuthError):
63
+ """NAS no longer knows this ``anon_`` credential (reaped, or claimed into a real account).
64
+
65
+ The one client rule for reap AND claim: mark dead, re-mint on the next need.
66
+ """
67
+
68
+
69
+ def _anon_err(message: str, code: str) -> AuthError:
70
+ return AuthError(message, code=code)
71
+
72
+
73
+ def guest_enabled() -> bool:
74
+ """The free tier is on for this process: the launch gate is set AND ``nous.guest`` (default
75
+ True) has not switched it off. The only place either is read."""
76
+ if (os.environ.get(GUEST_ONBOARDING_ENV) or "").strip() != "1":
77
+ return False
78
+ try:
79
+ from hermes_cli.config import load_config_readonly
80
+ nous_cfg = load_config_readonly().get("nous")
81
+ except Exception as exc: # config unreadable: keep today's behaviour (no guest) rather than mint
82
+ logger.debug("guest: config unreadable, treating nous.guest as false: %s", exc)
83
+ return False
84
+ if not isinstance(nous_cfg, dict):
85
+ return True
86
+ return bool(nous_cfg.get("guest", True))
87
+
88
+
89
+ def is_guest_state(state: Any) -> bool:
90
+ return isinstance(state, dict) and state.get("auth_method") == ANON_AUTH_METHOD
91
+
92
+
93
+ def current_nous_state() -> Optional[Dict[str, Any]]:
94
+ """The profile's ``providers.nous`` state without locking or network (status/picker reads)."""
95
+ from hermes_cli.auth import _load_auth_store, _load_provider_state
96
+ try:
97
+ return _load_provider_state(_load_auth_store(), "nous")
98
+ except Exception as exc:
99
+ logger.debug("guest: auth store unreadable: %s", exc)
100
+ return None
101
+
102
+
103
+ def has_guest() -> bool:
104
+ return is_guest_state(current_nous_state())
105
+
106
+
107
+ def guest_carries_inference() -> bool:
108
+ """True when the profile's Nous identity is the free tier and the free tier is on.
109
+
110
+ Profile-level: use for status, picker and notice surfaces. Routing decisions (which model a
111
+ request may carry) must use :func:`route_is_welcome_host` on the SELECTED runtime instead: a
112
+ credential-pool entry can pick a paid Nous key while the profile singleton is still a guest.
113
+ """
114
+ return guest_enabled() and has_guest()
115
+
116
+
117
+ WELCOME_HOSTS = frozenset({"welcome-api.nousresearch.com"})
118
+
119
+
120
+ def pin_model_for_route(provider: Any, base_url: Any, model: Any) -> Any:
121
+ """Model policy at agent START: on the Nous welcome host the model is ``nous/welcome``; anywhere
122
+ else the caller's model stands. Used once, when the route is first finalized. Mid-conversation
123
+ route changes go through :func:`route_can_serve_model` instead: a conversation's model is never
124
+ silently rewritten by a credential rotation.
125
+ """
126
+ if provider == "nous" and route_is_welcome_host(base_url):
127
+ if model and model != GUEST_MODEL:
128
+ logger.info("Nous free tier: using %s instead of configured model %s", GUEST_MODEL, model)
129
+ return GUEST_MODEL
130
+ return model
131
+
132
+
133
+ def route_can_serve_model(provider: Any, base_url: Any, model: Any) -> bool:
134
+ """Eligibility for a credential ROTATION: the welcome host serves only ``nous/welcome``, so a
135
+ conversation on any other model must not be rotated onto it (and a ``nous/welcome`` conversation
136
+ may move to the portal host, which serves it too). Non-Nous routes are always eligible."""
137
+ if provider != "nous" or not route_is_welcome_host(base_url):
138
+ return True
139
+ return not model or model == GUEST_MODEL
140
+
141
+
142
+ def route_is_welcome_host(base_url: Any) -> bool:
143
+ """The routing predicate for the free tier: the welcome host serves exactly ``nous/welcome``.
144
+
145
+ Keyed on the resolved endpoint, never on profile state, so a paid pool credential routed to the
146
+ portal host keeps its model even when a guest singleton exists beside it.
147
+ """
148
+ from urllib.parse import urlparse
149
+ try:
150
+ host = (urlparse(str(base_url or "")).hostname or "").lower()
151
+ except ValueError:
152
+ return False
153
+ return host in WELCOME_HOSTS
154
+
155
+
156
+ def anon_secret() -> str:
157
+ return (os.environ.get(ANON_SECRET_ENV) or "").strip()
158
+
159
+
160
+ def _anon_headers() -> Dict[str, str]:
161
+ headers = {"content-type": "application/json"}
162
+ if secret := anon_secret():
163
+ headers[ANON_SECRET_HEADER] = secret
164
+ return headers
165
+
166
+
167
+ def _raise_for_anon_status(response: httpx.Response, *, action: str) -> Dict[str, Any]:
168
+ try:
169
+ payload = response.json()
170
+ except ValueError:
171
+ payload = {}
172
+ if not isinstance(payload, dict):
173
+ payload = {}
174
+ error = str(payload.get("error") or "")
175
+ if response.status_code in (200, 201):
176
+ return payload
177
+ if response.status_code == 404 and error == "unknown_token":
178
+ raise AnonCredentialDead("Nous free-tier credential is no longer valid.", code="anon_credential_dead")
179
+ if response.status_code == 401 and error == "invalid_shared_secret":
180
+ raise _anon_err("Nous free tier is not open on this portal.", "anon_gate_closed")
181
+ if response.status_code == 401:
182
+ raise AnonCredentialDead("Nous free-tier credential was revoked.", code="anon_credential_dead")
183
+ if response.status_code == 429:
184
+ raise _anon_err("Nous free tier is rate limited; try again shortly.", "anon_rate_limited")
185
+ if response.status_code == 403 and error in {"anonymous_accounts_disabled", "circuit_open"}:
186
+ raise _anon_err("Nous free tier is currently disabled.", "anon_gate_closed")
187
+ raise _anon_err(
188
+ f"Nous free tier {action} failed ({response.status_code}{': ' + error if error else ''}).",
189
+ "anon_server_error")
190
+
191
+
192
+ def mint_guest(client: httpx.Client, portal_base_url: str) -> Dict[str, Any]:
193
+ """``POST /api/anonymous/create`` -> ``{user_id, org_id, token, idle_ttl_days}``. Token shown once."""
194
+ response = client.post(f"{portal_base_url.rstrip('/')}/api/anonymous/create", headers=_anon_headers(), json={})
195
+ payload = _raise_for_anon_status(response, action="sign-up")
196
+ token = payload.get("token")
197
+ if not isinstance(token, str) or not token.startswith("anon_"):
198
+ raise _anon_err("Nous free tier sign-up returned no credential.", "anon_server_error")
199
+ return payload
200
+
201
+
202
+ def exchange_anon_jwt(client: httpx.Client, portal_base_url: str, anon_token: str) -> Dict[str, Any]:
203
+ """``POST /api/anonymous/token {token}`` -> ``{access_token, expires_in, inference_base_url, ...}``.
204
+
205
+ Raises :class:`AnonCredentialDead` on 404 ``unknown_token`` / 401 (reaped or claimed).
206
+ """
207
+ response = client.post(
208
+ f"{portal_base_url.rstrip('/')}/api/anonymous/token", headers=_anon_headers(), json={"token": anon_token})
209
+ payload = _raise_for_anon_status(response, action="token exchange")
210
+ if not isinstance(payload.get("access_token"), str) or not payload["access_token"]:
211
+ raise _anon_err("Nous free tier token exchange returned no token.", "anon_server_error")
212
+ return payload
213
+
214
+
215
+ def apply_exchange_to_state(state: Dict[str, Any], exchanged: Dict[str, Any]) -> None:
216
+ """Write a fresh exchange result into a guest state in place (token, expiry, routing)."""
217
+ from hermes_cli.auth_nous import _validate_nous_inference_url_from_network
218
+ access_token = exchanged["access_token"]
219
+ claims = _decode_jwt_claims(access_token)
220
+ now = datetime.now(timezone.utc)
221
+ exp = claims.get("exp")
222
+ if isinstance(exp, (int, float)):
223
+ expires_at = datetime.fromtimestamp(float(exp), tz=timezone.utc)
224
+ else:
225
+ expires_at = now + timedelta(seconds=int(exchanged.get("expires_in") or 900))
226
+ # NAS names the welcome host on every exchange; absent (older NAS) or outside the allowlist
227
+ # (a staging host without NOUS_INFERENCE_BASE_URL set), the literal stands in. Never the paid
228
+ # host: the gateway cross-refuses an anonymous JWT there.
229
+ inference_url = (_validate_nous_inference_url_from_network(exchanged.get("inference_base_url"))
230
+ or DEFAULT_NOUS_WELCOME_URL)
231
+ scope = claims.get("scope") or claims.get("scp") or state.get("scope")
232
+ if isinstance(scope, (list, tuple)):
233
+ scope = " ".join(str(s) for s in scope)
234
+ state.update(
235
+ access_token=access_token, token_type="Bearer", scope=scope,
236
+ obtained_at=now.isoformat(), expires_at=expires_at.isoformat(),
237
+ expires_in=max(0, int((expires_at - now).total_seconds())),
238
+ account_tier=str(claims.get("account_tier") or ANON_ACCOUNT_TIER))
239
+ state["inference_base_url"] = inference_url
240
+ for key in ("user_id", "org_id"):
241
+ if exchanged.get(key):
242
+ state[key] = exchanged[key]
243
+ state.pop("refresh_token", None)
244
+
245
+
246
+ def _portal_base_url() -> str:
247
+ from hermes_cli.auth_nous import _nous_portal_env_override
248
+ return (_nous_portal_env_override() or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
249
+
250
+
251
+ def _shared_identity_key(state: Any) -> Optional[str]:
252
+ """Stable identity of a Nous credential: the anon_ token for a guest, the refresh token for an
253
+ account. Used to decide whether two stores hold the SAME identity."""
254
+ if not isinstance(state, dict):
255
+ return None
256
+ return state.get("anon_token") if is_guest_state(state) else state.get("refresh_token")
257
+
258
+
259
+ def _mint_locked(
260
+ client: httpx.Client, portal: str, auth_store: Dict[str, Any], *, carries_inference: bool = True,
261
+ ) -> Dict[str, Any]:
262
+ """Mint under the caller's locks. The identity is persisted as soon as ``create`` succeeds, BEFORE
263
+ the exchange: a 429 or timeout on the exchange must not lose a credential NAS still honours (the
264
+ next attempt exchanges the stored one instead of minting again).
265
+
266
+ ``carries_inference`` decides whether the new identity also becomes ``active_provider``. The
267
+ bootstrap passes False when its inventory found another usable provider: the identity exists for
268
+ connectors, the user's own provider keeps carrying inference (NS-845 Q1.3)."""
269
+ from hermes_cli.auth import _store_provider_state, _save_auth_store
270
+ from hermes_cli.auth_nous import _write_shared_nous_state
271
+ minted = mint_guest(client, portal)
272
+ state: Dict[str, Any] = {
273
+ "auth_method": ANON_AUTH_METHOD, "account_tier": ANON_ACCOUNT_TIER,
274
+ "anon_token": minted["token"], "client_id": ANON_CLIENT_ID,
275
+ "portal_base_url": portal.rstrip("/"),
276
+ "user_id": minted.get("user_id"), "org_id": minted.get("org_id"),
277
+ "idle_ttl_days": minted.get("idle_ttl_days"),
278
+ }
279
+ _store_provider_state(auth_store, "nous", state, set_active=carries_inference)
280
+ _save_auth_store(auth_store)
281
+ _write_shared_nous_state(state)
282
+ logger.info("Nous free tier ready (identity minted)")
283
+ return state
284
+
285
+
286
+ # Per-process memo: one failed mint is enough for a process (a 429 or a closed gate must not be hit
287
+ # twice); ``clear_dead_guest`` resets it because a retired credential is a reason to mint again.
288
+ # The bool is the unscoped (launch profile) slot; routed multiplex profiles each get their own entry
289
+ # in the set — profile A's 429 must not stop profile B from ever getting an identity.
290
+ _mint_failed = False
291
+ _mint_failed_homes: set[str] = set()
292
+
293
+
294
+ def _mint_failed_for_profile() -> bool:
295
+ from hermes_constants import get_hermes_home_override, hermes_home_key
296
+ if get_hermes_home_override() is None:
297
+ return _mint_failed
298
+ return hermes_home_key() in _mint_failed_homes
299
+
300
+
301
+ def _set_mint_failed(failed: bool) -> None:
302
+ global _mint_failed
303
+ from hermes_constants import get_hermes_home_override, hermes_home_key
304
+ if get_hermes_home_override() is None:
305
+ _mint_failed = failed
306
+ elif failed:
307
+ _mint_failed_homes.add(hermes_home_key())
308
+ else:
309
+ _mint_failed_homes.discard(hermes_home_key())
310
+
311
+
312
+ def _reconcile_and_provision(*, timeout_seconds: float, carries_inference: bool = True) -> Optional[Dict[str, Any]]:
313
+ """The lifecycle body, run under profile lock THEN shared lock (the documented order).
314
+
315
+ 1. The shared store is the identity of record for this Hermes root. If it holds an identity
316
+ that differs from the profile's, the profile adopts it (a stale guest never outlives a
317
+ sibling profile's sign-in, and never overwrites it). An adopted free-tier identity claims
318
+ ``active_provider`` under the same rule as a mint; an adopted ACCOUNT always does (the user
319
+ signed in somewhere on this machine).
320
+ 2. Otherwise the profile's own identity stands.
321
+ 3. Nothing anywhere: mint, persisting the credential before exchanging it.
322
+ """
323
+ from hermes_cli.auth import (
324
+ _auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store,
325
+ _store_provider_state, _resolve_verify)
326
+ from hermes_cli.auth_nous import (
327
+ _nous_http_client, _nous_shared_store_lock, _read_shared_nous_state, _write_shared_nous_state)
328
+ portal = _portal_base_url()
329
+ with _auth_store_lock():
330
+ auth_store = _load_auth_store()
331
+ profile_state = _load_provider_state(auth_store, "nous")
332
+ with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds, 5.0)):
333
+ shared = _read_shared_nous_state()
334
+ if shared and _shared_identity_key(shared) != _shared_identity_key(profile_state):
335
+ state = dict(shared)
336
+ _store_provider_state(
337
+ auth_store, "nous", state,
338
+ set_active=carries_inference or not is_guest_state(state))
339
+ _save_auth_store(auth_store)
340
+ logger.debug("Nous identity adopted from the shared store")
341
+ return state
342
+ if profile_state:
343
+ if not shared:
344
+ _write_shared_nous_state(profile_state)
345
+ return profile_state
346
+ verify = _resolve_verify(insecure=None, ca_bundle=None, auth_state=None)
347
+ with _nous_http_client(timeout_seconds, verify) as client:
348
+ return _mint_locked(client, portal, auth_store, carries_inference=carries_inference)
349
+
350
+
351
+ def ensure_portal_identity(
352
+ *, explicit: bool, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS,
353
+ carries_inference: bool = True,
354
+ ) -> Optional[Dict[str, Any]]:
355
+ """Make sure this profile has a Nous identity (guest or account); mint a guest only if the shared
356
+ store has none. Returns the ``providers.nous`` state, or None (disabled / failed once already).
357
+
358
+ ``explicit`` is required and must be True: the only callers are the boot bootstrap
359
+ (``free_tier_bootstrap.run_bootstrap``), the desktop's ``free_tier.provision`` retry, and the
360
+ dead-credential replacements (``auth_nous.resolve_nous_runtime_credentials``,
361
+ ``managed_tool_gateway._replace_dead_guest_token``). Nothing creates an identity as a side effect
362
+ of reading status, resolving a provider or fetching a connector bearer (NS-845 Q1.2).
363
+
364
+ Order: ``guest_enabled`` gate -> reconcile with the shared store -> mint. Locks are taken profile
365
+ first, then shared, matching every other Nous path. ``carries_inference=False`` leaves
366
+ ``active_provider`` alone (the identity is for connectors; another provider does inference).
367
+ Blocking, bounded by ``timeout_seconds``; the bootstrap puts it on its own thread.
368
+ """
369
+ if not explicit:
370
+ raise ValueError("ensure_portal_identity: only explicit creators may call this (explicit=True)")
371
+ if not guest_enabled():
372
+ return None
373
+ if _mint_failed_for_profile() and not current_nous_state():
374
+ return None # this profile already tried and failed in this process; do not hammer the portal
375
+ try:
376
+ return _reconcile_and_provision(
377
+ timeout_seconds=timeout_seconds, carries_inference=carries_inference)
378
+ except Exception:
379
+ _set_mint_failed(True)
380
+ raise
381
+
382
+
383
+ def refresh_guest_state(state: Dict[str, Any], client: httpx.Client) -> None:
384
+ """Token-acquisition seam for a guest: re-exchange the ``anon_`` credential in place.
385
+
386
+ The portal URL is the resolver's canonical one (env override, else the validated stored URL,
387
+ else the default), never a raw stored value on its own.
388
+ Raises :class:`AnonCredentialDead` when NAS no longer knows the credential; the caller owns
389
+ re-minting (:func:`ensure_portal_identity` after :func:`clear_dead_guest`).
390
+ """
391
+ anon_token = state.get("anon_token")
392
+ if not isinstance(anon_token, str) or not anon_token:
393
+ raise AnonCredentialDead("Nous free-tier credential is missing.", code="anon_credential_dead")
394
+ from hermes_cli.auth import _nous_portal_base_url
395
+ apply_exchange_to_state(state, exchange_anon_jwt(client, _nous_portal_base_url(state), anon_token))
396
+
397
+
398
+ def clear_dead_guest(reason: str, *, dead_token: Optional[str] = None) -> None:
399
+ """Drop a dead guest so the next need re-mints.
400
+
401
+ Only the identity that actually failed is removed: a stale profile whose credential NAS rejected
402
+ must not erase a sibling profile's newer sign-in or replacement guest from the shared store. When
403
+ *dead_token* is None the profile's current guest is treated as the failed one.
404
+ """
405
+ from hermes_cli.auth import (
406
+ _auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store, _store_section)
407
+ from hermes_cli.auth_nous import _clear_shared_nous_state, _nous_shared_store_lock, _read_shared_nous_state
408
+ with _auth_store_lock():
409
+ auth_store = _load_auth_store()
410
+ state = _load_provider_state(auth_store, "nous")
411
+ if is_guest_state(state):
412
+ token = dead_token or state.get("anon_token")
413
+ if state.get("anon_token") == token:
414
+ _store_section(auth_store, "providers").pop("nous", None)
415
+ _store_section(auth_store, "credential_pool").pop("nous", None)
416
+ if auth_store.get("active_provider") == "nous":
417
+ auth_store["active_provider"] = None
418
+ _save_auth_store(auth_store)
419
+ else:
420
+ token = dead_token
421
+ with _nous_shared_store_lock():
422
+ shared = _read_shared_nous_state()
423
+ if token and is_guest_state(shared) and shared.get("anon_token") == token:
424
+ _clear_shared_nous_state(reason)
425
+ _set_mint_failed(False)
426
+ logger.info("Nous free-tier identity retired (%s); a new one is set up on next use", reason)
427
+
428
+
429
+ # --- Gateway welcome-tier contract: structured refusals and the model-switch header ------------------
430
+ #
431
+ # The inference gateway answers a welcome-tier request it will not serve with a structured 429
432
+ # (``{status, message, reason, retry_after, alternates?, upgrade_url?}``), and a request on the wrong
433
+ # host with a 400 (or a 403 while the tier is dark) whose message names the right host. A NAMED
434
+ # account that still asks for ``nous/welcome`` is served the id's backing model and told what to
435
+ # switch to in the ``x-nous-model-switch`` response header. Every rule for reading those lives here;
436
+ # the error classifier and the turn loop only call in.
437
+
438
+ MODEL_SWITCH_HEADER = "x-nous-model-switch"
439
+ # Fairshare refusal reasons the welcome tier can answer with (api ``FairshareRefusalReason``).
440
+ WELCOME_REFUSAL_REASONS = frozenset(
441
+ {"model_not_free", "feature_not_free", "at_capacity", "admission_closed", "rate_limited"})
442
+ # Reasons that mean "not on this tier, ever": no retry helps, only a sign-in or another provider.
443
+ WELCOME_TIER_GATE_REASONS = frozenset({"model_not_free", "feature_not_free"})
444
+ # Gateway messages (lowercased substrings) for a request on the wrong host or a dark tier.
445
+ _WELCOME_ROUTE_REFUSALS = (
446
+ ("anonymous accounts must use", "anon_on_paid_host"),
447
+ ("serves anonymous hermes agent accounts only", "named_on_welcome_host"),
448
+ ("anonymous accounts are not accepted", "tier_disabled"),
449
+ )
450
+ _WELCOME_ROUTE_COPY = {
451
+ "anon_on_paid_host": "The Nous free tier must use its own inference host ({host}); "
452
+ "Hermes is pointed at the paid one. Restart Hermes to re-read the route, "
453
+ "or unset NOUS_INFERENCE_BASE_URL if you set it.",
454
+ "named_on_welcome_host": "This Nous account must use the Nous Portal inference host, "
455
+ "not the free tier's. Run /model and pick the Nous row again.",
456
+ "tier_disabled": "The Nous free tier is switched off right now. {signin}",
457
+ }
458
+ _SIGNIN_CHAT = "Sign in with a Nous account for the full catalog: /login."
459
+ _SIGNIN_TERMINAL = "Sign in with a Nous account for the full catalog: `hermes auth upgrade`."
460
+
461
+
462
+ def parse_welcome_refusal(body: Any) -> Optional[Dict[str, Any]]:
463
+ """The structured welcome-tier refusal in a gateway 429 body, or None for any other shape.
464
+
465
+ Returns ``{"reason", "retry_after", "alternates", "upgrade_url"}`` with ``retry_after`` an int
466
+ of whole seconds (0 when the gateway sent none) and ``alternates`` a list of model ids.
467
+ """
468
+ if not isinstance(body, dict):
469
+ return None
470
+ reason = body.get("reason")
471
+ if not isinstance(reason, str) or reason not in WELCOME_REFUSAL_REASONS:
472
+ return None
473
+ raw_retry = body.get("retry_after")
474
+ try:
475
+ retry_after = max(0, int(float(raw_retry))) if raw_retry not in (None, "") else 0
476
+ except (TypeError, ValueError):
477
+ retry_after = 0
478
+ raw_alternates = body.get("alternates")
479
+ alternates = [str(a) for a in raw_alternates if isinstance(a, str) and a] if isinstance(raw_alternates, list) else []
480
+ upgrade_url = body.get("upgrade_url")
481
+ return {"reason": reason, "retry_after": retry_after, "alternates": alternates,
482
+ "upgrade_url": upgrade_url if isinstance(upgrade_url, str) else ""}
483
+
484
+
485
+ def welcome_refusal_copy(refusal: Dict[str, Any], *, model: str = "", in_chat: bool = True) -> str:
486
+ """User copy for a structured welcome-tier refusal: what happened and the one way forward.
487
+
488
+ Never guest / anonymous / claim; ``in_chat`` picks ``/login`` over the terminal verb."""
489
+ signin = _SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL
490
+ reason = str(refusal.get("reason") or "")
491
+ alternates = refusal.get("alternates") or []
492
+ serves = alternates[0] if alternates else GUEST_MODEL
493
+ retry = int(refusal.get("retry_after") or 0)
494
+ wait = f"Retrying in {retry}s." if retry > 0 else "Try again shortly."
495
+ if reason == "model_not_free":
496
+ what = f"{model} isn't on the Nous free tier" if model else "That model isn't on the Nous free tier"
497
+ return f"{what}; it serves {serves} only. {signin}"
498
+ if reason == "feature_not_free":
499
+ return f"This feature isn't on the Nous free tier. {signin}"
500
+ if reason == "at_capacity":
501
+ return f"The Nous free tier is at capacity and briefly paused. {wait} {signin}"
502
+ if reason == "admission_closed":
503
+ return f"The Nous free tier isn't admitting new sessions right now. {wait} {signin}"
504
+ if reason == "rate_limited":
505
+ return f"Nous free tier rate limit active \u2014 resets in {retry}s. {signin}"
506
+ return f"The Nous free tier refused this request ({reason}). {signin}"
507
+
508
+
509
+ def welcome_route_refusal(status: Any, message: Any) -> Optional[str]:
510
+ """Which host cross-refusal a gateway 400/403 is, by its message; None for any other error.
511
+
512
+ ``"anon_on_paid_host"``: a free-tier JWT reached the paid host. ``"named_on_welcome_host"``: an
513
+ account or API key reached the free tier's host. ``"tier_disabled"``: the tier is dark
514
+ (``WELCOME_MODE=off``). Each is deterministic for the request: retrying cannot help."""
515
+ if status not in (400, 403):
516
+ return None
517
+ text = str(message or "").lower()
518
+ return next((kind for needle, kind in _WELCOME_ROUTE_REFUSALS if needle in text), None)
519
+
520
+
521
+ def welcome_route_refusal_copy(kind: str, *, in_chat: bool = True) -> str:
522
+ template = _WELCOME_ROUTE_COPY.get(kind) or "The Nous inference gateway refused this route."
523
+ return template.format(
524
+ host=DEFAULT_NOUS_WELCOME_URL, signin=_SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL)
525
+
526
+
527
+ def note_model_switch(agent: Any, headers: Any) -> Optional[str]:
528
+ """Record the gateway's ``x-nous-model-switch`` header on *agent* for the next call, if present.
529
+
530
+ The header arrives on a NAMED account's response that asked for ``nous/welcome`` (the gateway
531
+ served the backing model and billed it normally): the free tier's model no longer belongs in
532
+ this install's configuration. Recorded here, applied by :func:`apply_model_switch` between
533
+ calls so a response still streaming is never re-labelled under itself. Returns the backing id.
534
+ """
535
+ if headers is None:
536
+ return None
537
+ value = None
538
+ try:
539
+ value = headers.get(MODEL_SWITCH_HEADER)
540
+ if value is None and hasattr(headers, "items"):
541
+ value = next((v for k, v in headers.items() if str(k).lower() == MODEL_SWITCH_HEADER), None)
542
+ except Exception:
543
+ return None
544
+ backing = str(value or "").strip()
545
+ if not backing:
546
+ return None
547
+ requested = str(getattr(agent, "model", "") or "")
548
+ if backing == requested:
549
+ return None
550
+ try:
551
+ agent._nous_pending_model_switch = (requested, backing)
552
+ except Exception:
553
+ return None
554
+ return backing
555
+
556
+
557
+ def apply_model_switch(agent: Any) -> Optional[str]:
558
+ """Move *agent* (and the config default, when it still names the switched id) to the backing
559
+ model the gateway named. Returns the new model, or None when nothing was pending.
560
+
561
+ Runs once per recorded header, between calls. The conversation keeps its history; only the id
562
+ the next request carries changes, so a promoted account stops relying on the gateway's reverse
563
+ map. The config write is the same one a sign-in completion uses, so ``hermes model`` and the
564
+ gateway's config re-read agree with the live session.
565
+ """
566
+ pending = getattr(agent, "_nous_pending_model_switch", None)
567
+ if not pending:
568
+ return None
569
+ agent._nous_pending_model_switch = None
570
+ requested, backing = pending
571
+ if str(getattr(agent, "model", "") or "") != requested:
572
+ return None # the session already moved (a /model, a sign-in sweep)
573
+ agent.model = backing
574
+ # The gateway's cache check compares agent.model with the config default and evicts on a
575
+ # mismatch it did not cause; this pair names the move so the check can recognise exactly this
576
+ # server-driven switch even when the config write below did not land.
577
+ agent._nous_model_switch = (requested, backing)
578
+ logger.info("Nous gateway asked to switch %s -> %s; applied for this session", requested, backing)
579
+ try:
580
+ from hermes_cli.config import load_config_readonly
581
+ raw = load_config_readonly().get("model")
582
+ model_cfg = raw if isinstance(raw, dict) else ({"default": raw} if isinstance(raw, str) else {})
583
+ if str(model_cfg.get("default") or "").strip() == requested:
584
+ from hermes_cli.auth import _update_config_for_provider
585
+ _update_config_for_provider(
586
+ "nous", str(getattr(agent, "base_url", "") or ""), default_model=backing)
587
+ logger.info("Config default model moved %s -> %s", requested, backing)
588
+ except Exception as exc:
589
+ logger.debug("model switch: config default left as is: %s", exc)
590
+ status = getattr(agent, "_buffer_status", None)
591
+ if callable(status):
592
+ try:
593
+ status(f"Model is now {backing} (your account's model; {requested} is the free tier's).")
594
+ except Exception:
595
+ pass
596
+ return backing
597
+
598
+
599
+ # One-time CLI notice: an install whose inference is carried by an explicit provider learns once that
600
+ # the free tier (inference + connectors) now exists. The flag lives on the guest state itself so it
601
+ # dies with the identity; a fresh guest (re-mint, new profile) may announce itself once more.
602
+ GUEST_NOTICE_FLAG = "guest_notice_shown"
603
+ FREE_TIER_AVAILABLE_NOTICE = (
604
+ "Free Nous inference and connectors are now available. "
605
+ "/model to try them, /login to sign in.")
606
+
607
+
608
+ def guest_notice_pending() -> bool:
609
+ """True when a guest identity exists and the one-time availability notice has not been shown."""
610
+ state = current_nous_state()
611
+ return is_guest_state(state) and not bool(state.get(GUEST_NOTICE_FLAG))
612
+
613
+
614
+ def mark_guest_notice_shown() -> bool:
615
+ """Persist ``guest_notice_shown`` on the guest's ``providers.nous`` state (whichever store holds it).
616
+
617
+ Returns True when a flag was written; False when there is no guest to mark."""
618
+ from hermes_cli.auth import (
619
+ _auth_file_path, _load_auth_store, _provider_state_transaction, _same_path, _save_auth_store,
620
+ _store_section)
621
+ with _provider_state_transaction("nous") as (auth_store, state, source_path):
622
+ if not is_guest_state(state) or source_path is None:
623
+ return False
624
+ if state.get(GUEST_NOTICE_FLAG):
625
+ return True
626
+ state = dict(state)
627
+ state[GUEST_NOTICE_FLAG] = True
628
+ if _same_path(source_path, _auth_file_path()):
629
+ _store_section(auth_store, "providers")["nous"] = state
630
+ _save_auth_store(auth_store)
631
+ else:
632
+ source_store = _load_auth_store(source_path)
633
+ _store_section(source_store, "providers")["nous"] = state
634
+ _save_auth_store(source_store, target_path=source_path)
635
+ return True
636
+
637
+
638
+ # --- ``hermes auth upgrade``: sign the guest into a real Nous account, keeping its connectors ---------
639
+ #
640
+ # Wire: the normal device-code flow, with a promotion intent registered on NAS BETWEEN the code
641
+ # request and the token poll (``POST /api/anonymous/promotion-intent {token, user_code, device_code}``).
642
+ # NAS then transfers the guest's connectors into whichever account approves that device code. We
643
+ # watch ``POST /api/anonymous/promotion-status {claim_code}`` until it leaves ``pending``; only a
644
+ # ``completed`` promotion is followed by the token grant, which ``persist_nous_credentials`` writes
645
+ # over the guest singleton and the shared store. The server never reports expiry: our own
646
+ # ``expires_in`` clock ends the wait. User-facing copy never says guest / anonymous / claim.
647
+
648
+ UPGRADED_AUTH_METHOD = "oauth_device_code"
649
+
650
+
651
+ def register_promotion_intent(
652
+ client: httpx.Client, portal_base_url: str, anon_token: str, *, user_code: str, device_code: str,
653
+ ) -> Dict[str, Any]:
654
+ """``POST /api/anonymous/promotion-intent`` -> ``{claim_code, claim_url, expires_in, interval}``."""
655
+ response = client.post(
656
+ f"{portal_base_url.rstrip('/')}/api/anonymous/promotion-intent", headers=_anon_headers(),
657
+ json={"token": anon_token, "user_code": user_code, "device_code": device_code})
658
+ payload = _raise_for_anon_status(response, action="sign-in")
659
+ if not isinstance(payload.get("claim_code"), str) or not payload["claim_code"]:
660
+ raise _anon_err("Nous free tier sign-in returned no transfer code.", "anon_server_error")
661
+ return payload
662
+
663
+
664
+ def _retry_after_seconds(response: httpx.Response, default: float) -> float:
665
+ seconds = parse_retry_after_seconds(response.headers)
666
+ return default if seconds is None else seconds
667
+
668
+
669
+ def _sleep_until(wake: float, cancelled: Optional[Callable[[], bool]]) -> bool:
670
+ """Sleep until the monotonic time *wake*. Returns True when *cancelled* fired first.
671
+
672
+ Without a hook this is one plain :func:`time.sleep`. With one the sleep is cut into <= 1 s
673
+ ticks so an attempt stopped from outside ends in about a second instead of blocking to the
674
+ sign-in code's own expiry.
675
+ """
676
+ if cancelled is None:
677
+ remaining = wake - time.monotonic()
678
+ if remaining > 0:
679
+ time.sleep(remaining)
680
+ return False
681
+ while True:
682
+ if cancelled():
683
+ return True
684
+ remaining = wake - time.monotonic()
685
+ if remaining <= 0:
686
+ return False
687
+ time.sleep(min(1.0, remaining))
688
+
689
+
690
+ def wait_for_promotion(
691
+ client: httpx.Client, portal_base_url: str, claim_code: str, *, expires_in: int, interval: int,
692
+ cancelled: Optional[Callable[[], bool]] = None,
693
+ ) -> Dict[str, Any]:
694
+ """Poll ``POST /api/anonymous/promotion-status`` until it leaves ``pending`` or our clock runs out.
695
+
696
+ Returns the final status payload; ``{"status": "timeout"}`` when ``expires_in`` elapsed. 429 honours
697
+ ``Retry-After``; other non-2xx statuses raise through :func:`_raise_for_anon_status`.
698
+
699
+ *cancelled* is an optional hook a surface passes to stop an attempt it no longer wants (a newer
700
+ sign-in replaced it, the user cancelled, the process is shutting down). It is polled at the top
701
+ of every iteration and on a <= 1 s tick while sleeping; once it has fired this call returns
702
+ ``{"status": "cancelled"}`` for every outcome except a ``completed`` transfer already in hand,
703
+ which is reported so the caller's ``cancel_wins_after_promotion`` ruling can decide it.
704
+ Passing nothing is today's behaviour.
705
+ """
706
+ deadline = time.monotonic() + max(1, int(expires_in))
707
+ wait = max(0, int(interval))
708
+ while time.monotonic() < deadline:
709
+ if cancelled is not None and cancelled():
710
+ return {"status": "cancelled"}
711
+ response = client.post(
712
+ f"{portal_base_url.rstrip('/')}/api/anonymous/promotion-status", headers=_anon_headers(),
713
+ json={"claim_code": claim_code})
714
+ if response.status_code == 429:
715
+ retry = min(_retry_after_seconds(response, default=max(1, wait)),
716
+ max(0.0, deadline - time.monotonic()))
717
+ if _sleep_until(time.monotonic() + retry, cancelled):
718
+ return {"status": "cancelled"}
719
+ continue
720
+ payload = _raise_for_anon_status(response, action="sign-in")
721
+ status = str(payload.get("status") or "unknown")
722
+ if status != "pending":
723
+ # A completed transfer is already committed on the account service; report it even when
724
+ # the hook fired during this request. run_sign_in's cancel_wins_after_promotion
725
+ # rules what each surface does with it. Every other terminal outcome loses to a cancel.
726
+ if status != "completed" and cancelled is not None and cancelled():
727
+ return {"status": "cancelled"}
728
+ return payload
729
+ if _sleep_until(time.monotonic() + wait, cancelled):
730
+ return {"status": "cancelled"}
731
+ return {"status": "timeout"}
732
+
733
+
734
+ def _account_state_from_token(
735
+ token_data: Dict[str, Any], *, portal_base_url: str, client_id: str, scope: Optional[str], verify: Any,
736
+ timeout_seconds: float,
737
+ ) -> Dict[str, Any]:
738
+ """The ``providers.nous`` shape for the signed-in account (same fields the device-code login writes)."""
739
+ from hermes_cli.auth import PROVIDER_REGISTRY, _coerce_ttl_seconds, _optional_base_url, _tls_state_from_verify
740
+ from hermes_cli.auth_nous import _NOUS_EMPTY_AGENT_KEY_FIELDS, _iso_after, refresh_nous_oauth_from_state
741
+ now = datetime.now(timezone.utc)
742
+ ttl = _coerce_ttl_seconds(token_data.get("expires_in", 0))
743
+ inference_url = (
744
+ _optional_base_url(token_data.get("inference_base_url"))
745
+ or PROVIDER_REGISTRY["nous"].inference_base_url.rstrip("/"))
746
+ state = {
747
+ "portal_base_url": portal_base_url, "inference_base_url": inference_url,
748
+ "client_id": client_id, "scope": token_data.get("scope") or scope,
749
+ "token_type": token_data.get("token_type", "Bearer"),
750
+ "access_token": token_data["access_token"], "refresh_token": token_data.get("refresh_token"),
751
+ "obtained_at": now.isoformat(), "expires_at": _iso_after(now, ttl), "expires_in": ttl,
752
+ "tls": _tls_state_from_verify(verify), **_NOUS_EMPTY_AGENT_KEY_FIELDS}
753
+ state = refresh_nous_oauth_from_state(state, timeout_seconds=timeout_seconds, force_refresh=False)
754
+ state["auth_method"] = UPGRADED_AUTH_METHOD
755
+ return state
756
+
757
+
758
+ def settle_after_upgrade(account_state: Dict[str, Any]) -> Dict[str, Any]:
759
+ """After a sign-in from the free tier persisted the account: move the config off the free tier's route.
760
+
761
+ Picking the free-tier row may have written ``model.default: nous/welcome`` and ``model.base_url``
762
+ = welcome host. An account cannot keep either: the welcome host refuses account tokens, and the
763
+ portal host serves ``nous/welcome`` as a paid model. When the config is on the free tier's route,
764
+ ``model.base_url`` becomes the account's inference host and ``model.default`` the recommended
765
+ default for the account's tier (:func:`hermes_cli.models.recommended_nous_default_model`, the
766
+ same pick as ``GET /api/model/recommended-default``), through the same config write a plain Nous
767
+ login uses. A config on the user's own model and host is left alone.
768
+
769
+ Every sign-in completion (CLI ``hermes auth upgrade``, the desktop poller) calls this once, after
770
+ ``persist_nous_credentials``. Returns ``{"model": str, "changed": bool}``: ``model`` is the default
771
+ the config now carries (``""`` when it carries none); ``changed`` says whether this call wrote it.
772
+ Never raises: a failed pick or write is logged and reported as ``changed: False`` so the sign-in
773
+ itself still counts.
774
+ """
775
+ from hermes_cli.config import load_config_readonly
776
+ try:
777
+ raw = load_config_readonly().get("model")
778
+ except Exception as exc:
779
+ logger.warning("sign-in completion: config unreadable, default model left as is: %s", exc)
780
+ return {"model": "", "changed": False}
781
+ model_cfg = raw if isinstance(raw, dict) else ({"default": raw} if isinstance(raw, str) else {})
782
+ current = str(model_cfg.get("default") or "").strip()
783
+ on_welcome_model = current == GUEST_MODEL
784
+ on_welcome_host = route_is_welcome_host(model_cfg.get("base_url"))
785
+ if not (on_welcome_model or on_welcome_host):
786
+ return {"model": current, "changed": False}
787
+ model = current
788
+ if on_welcome_model:
789
+ from hermes_cli.models import recommended_nous_default_model
790
+ try:
791
+ model = str(recommended_nous_default_model().get("model") or "")
792
+ except Exception as exc:
793
+ logger.debug("sign-in completion: recommended default unavailable: %s", exc)
794
+ model = ""
795
+ try:
796
+ from hermes_cli.auth import _update_config_for_provider
797
+ # One write: host and default move together, so a failure leaves the config as it was
798
+ # rather than the account host paired with the welcome model. No eligible recommendation
799
+ # (Portal unreachable, or the plan and org policy admit nothing) clears the default in that
800
+ # same write; the runtime's silent default applies until the user picks one with `hermes model`.
801
+ _update_config_for_provider(
802
+ "nous", str(account_state.get("inference_base_url") or ""),
803
+ default_model=model if on_welcome_model else None,
804
+ clear_default=on_welcome_model and not model)
805
+ except Exception as exc:
806
+ logger.warning("sign-in completion: could not update the default model: %s", exc)
807
+ return {"model": current, "changed": False}
808
+ return {"model": model, "changed": True}
809
+
810
+
811
+ def _poll_for_token(*args, **kwargs) -> Dict[str, Any]:
812
+ """Keep both the sign-in module seam and the device-flow seam live at call time."""
813
+ from hermes_cli.auth_device_flow import _poll_for_token as poll
814
+ return poll(*args, **kwargs)
815
+
816
+
817
+ def persist_nous_credentials(*args, **kwargs):
818
+ """Keep the existing auth_nous persistence seam behind the sign-in entry point."""
819
+ from hermes_cli.auth_nous import persist_nous_credentials as persist
820
+ return persist(*args, **kwargs)
821
+
822
+
823
+ # Public sign-in imports remain here for existing callers and module-attribute patches.
824
+ # The flow imports this module only inside calls, so either module can be imported first.
825
+ from hermes_cli.anon_sign_in import ( # noqa: E402
826
+ AlreadySignedIn as AlreadySignedIn,
827
+ Code as Code,
828
+ Completed as Completed,
829
+ Declined as Declined,
830
+ FREE_TIER_RATE_LIMIT_CHAT as FREE_TIER_RATE_LIMIT_CHAT,
831
+ Failed as Failed,
832
+ LOGIN_BUSY_ELSEWHERE as LOGIN_BUSY_ELSEWHERE,
833
+ LOGIN_COMMAND as LOGIN_COMMAND,
834
+ LOGIN_DM_ONLY as LOGIN_DM_ONLY,
835
+ LOGIN_NOT_ALLOWED as LOGIN_NOT_ALLOWED,
836
+ LOGIN_STARTING as LOGIN_STARTING,
837
+ Retired as Retired,
838
+ SignInState as SignInState,
839
+ Superseded as Superseded,
840
+ TimedOut as TimedOut,
841
+ UPGRADE_ALREADY_SIGNED_IN as UPGRADE_ALREADY_SIGNED_IN,
842
+ UPGRADE_CANCELLED as UPGRADE_CANCELLED,
843
+ UPGRADE_DO_NOT_SHARE as UPGRADE_DO_NOT_SHARE,
844
+ UPGRADE_NOT_COMPLETED as UPGRADE_NOT_COMPLETED,
845
+ UPGRADE_NO_DEFAULT_CHAT as UPGRADE_NO_DEFAULT_CHAT,
846
+ UPGRADE_NO_DEFAULT_TERMINAL as UPGRADE_NO_DEFAULT_TERMINAL,
847
+ UPGRADE_REASON_COPY as UPGRADE_REASON_COPY,
848
+ UPGRADE_START as UPGRADE_START,
849
+ UPGRADE_TIMED_OUT as UPGRADE_TIMED_OUT,
850
+ UPGRADE_UNAVAILABLE as UPGRADE_UNAVAILABLE,
851
+ UPGRADE_UNAVAILABLE_CHAT as UPGRADE_UNAVAILABLE_CHAT,
852
+ UPGRADE_WAITING as UPGRADE_WAITING,
853
+ UPGRADE_WAITING_UP_TO as UPGRADE_WAITING_UP_TO,
854
+ Unavailable as Unavailable,
855
+ Waiting as Waiting,
856
+ _RETIRED_REASONS as _RETIRED_REASONS,
857
+ _default_persist_guard as _default_persist_guard,
858
+ _outcome_state as _outcome_state,
859
+ format_wait_line as format_wait_line,
860
+ run_sign_in as run_sign_in,
861
+ )
862
+ from hermes_cli.anon_sign_in_cli import ( # noqa: E402
863
+ drain_sign_in_copy as drain_sign_in_copy,
864
+ render_sign_in_cli as render_sign_in_cli,
865
+ render_sign_in_cli_code as render_sign_in_cli_code,
866
+ upgrade_guest as upgrade_guest,
867
+ )
868
+
869
+ FREE_TIER_STATUS_LINE = f"{FREE_TIER_LABEL} \u00b7 {GUEST_MODEL} \u00b7 {LOGIN_COMMAND} to sign in"
hermes_cli/auth_xai.py ADDED
@@ -0,0 +1,582 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """xAI Grok OAuth: token store, discovery, refresh, device-code login.
2
+
3
+ Split out of ``hermes_cli/auth.py``; origin helpers are imported lazily per function so
4
+ ``hermes_cli.auth.<helper>`` patches still intercept and no cycle forms.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import base64
11
+ import json
12
+ import os
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Optional, TYPE_CHECKING
16
+ from urllib.parse import urlparse
17
+ from hermes_cli.auth_codex import _load_auth_store_maybe_locked, _refresh_payload_access_token
18
+ from hermes_cli.auth_constants import (
19
+ AUTH_LOCK_TIMEOUT_SECONDS, AuthError, DEFAULT_XAI_OAUTH_BASE_URL, DEVICE_CODE_GRANT_TYPE,
20
+ XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, XAI_OAUTH_CLIENT_ID, XAI_OAUTH_DEVICE_CODE_URL,
21
+ XAI_OAUTH_DISCOVERY_URL, XAI_OAUTH_SCOPE, _FORM_JSON_HEADERS, _xai_err, httpx,
22
+ )
23
+ from utils import env_float
24
+
25
+ if TYPE_CHECKING: # annotation-only; the runtime import would be a cycle
26
+ from hermes_cli.auth import ProviderConfig
27
+ logger = logging.getLogger("hermes_cli.auth")
28
+
29
+ _RELOGIN = "Re-authenticate with `hermes model`."
30
+
31
+
32
+ def _clean(value: Any) -> str:
33
+ return str(value or "").strip()
34
+
35
+
36
+ def _token_pair(tokens: Any) -> tuple[str, str]:
37
+ """``(access_token, refresh_token)`` stripped; empty strings when *tokens* is not a dict."""
38
+ if not isinstance(tokens, dict):
39
+ return "", ""
40
+ return _clean(tokens.get("access_token")), _clean(tokens.get("refresh_token"))
41
+
42
+
43
+ def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]:
44
+ """Return usable xAI OAuth state from provider state or credential pool."""
45
+ from hermes_cli.auth import _load_provider_state
46
+ state = _load_provider_state(auth_store, "xai-oauth")
47
+ if isinstance(state, dict) and all(_token_pair(state.get("tokens"))):
48
+ return state
49
+
50
+ credential_pool = auth_store.get("credential_pool")
51
+ entries = credential_pool.get("xai-oauth") if isinstance(credential_pool, dict) else None
52
+ for entry in entries if isinstance(entries, list) else ():
53
+ access_token, refresh_token = _token_pair(entry)
54
+ if not access_token or not refresh_token:
55
+ continue
56
+ merged = dict(state or {})
57
+ merged["tokens"] = {
58
+ "access_token": access_token, "refresh_token": refresh_token,
59
+ "token_type": str(entry.get("token_type") or "Bearer"),
60
+ }
61
+ if entry.get("last_refresh"):
62
+ merged["last_refresh"] = entry.get("last_refresh")
63
+ merged.setdefault("auth_mode", "oauth_pkce")
64
+ return merged
65
+
66
+ return state if isinstance(state, dict) else None
67
+
68
+
69
+ def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool:
70
+ return isinstance(state, dict) and all(_token_pair(state.get("tokens")))
71
+
72
+
73
+ def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
74
+ from hermes_cli.auth import _load_global_auth_store
75
+ state = _xai_oauth_state_from_store(_load_auth_store_maybe_locked(_lock))
76
+ if not _xai_oauth_state_has_usable_tokens(state):
77
+ global_state = _xai_oauth_state_from_store(_load_global_auth_store())
78
+ if _xai_oauth_state_has_usable_tokens(global_state):
79
+ state = global_state
80
+ if not state:
81
+ raise _xai_err(
82
+ "No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.",
83
+ "xai_auth_missing", relogin=True,
84
+ )
85
+ tokens = state.get("tokens")
86
+ if not isinstance(tokens, dict):
87
+ raise _xai_err(f"xAI OAuth state is missing tokens. {_RELOGIN}", "xai_auth_invalid_shape", relogin=True)
88
+ access_token, refresh_token = _token_pair(tokens)
89
+ for value, field in ((access_token, "access_token"), (refresh_token, "refresh_token")):
90
+ if not value:
91
+ raise _xai_err(
92
+ f"xAI OAuth state is missing {field}. {_RELOGIN}", f"xai_auth_missing_{field}", relogin=True,
93
+ )
94
+ return {
95
+ "tokens": tokens, "last_refresh": state.get("last_refresh"),
96
+ "discovery": state.get("discovery") or {}, "redirect_uri": state.get("redirect_uri"),
97
+ }
98
+
99
+
100
+ def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None:
101
+ """Best-effort persist of a rotated xAI grant into the global-root auth.json.
102
+
103
+ xAI rotates refresh_token on every refresh, so a profile that refreshed a root-resolved grant
104
+ must write the chain back to root. Touches only root ``providers.xai-oauth``; swallows all
105
+ errors (root-stale is better than breaking the profile's own save).
106
+ """
107
+ from hermes_cli.auth import _global_auth_file_path, _persist_provider_state_to_store
108
+ global_path = _global_auth_file_path()
109
+ if global_path is None: # classic mode (profile == root); the profile save already hit root
110
+ return
111
+ # Seat belt: under pytest never write the real ~/.hermes/auth.json (mirrors the read-side guard
112
+ # in _load_global_auth_store). Uses raw HOME, not Path.home(), which fixtures may monkeypatch.
113
+ real_home_env = os.environ.get("HOME", "") if os.environ.get("PYTEST_CURRENT_TEST") else ""
114
+ if real_home_env:
115
+ real_root = Path(real_home_env) / ".hermes" / "auth.json"
116
+ try:
117
+ if global_path.resolve(strict=False) == real_root.resolve(strict=False):
118
+ return
119
+ except Exception:
120
+ return
121
+ try:
122
+ _persist_provider_state_to_store("xai-oauth", state, global_path, set_active=False)
123
+ except Exception as exc: # pragma: no cover - best effort
124
+ logger.debug("xAI OAuth: write-through to global root failed: %s", exc)
125
+
126
+
127
+ def _save_xai_oauth_tokens(
128
+ tokens: Dict[str, Any], *, discovery: Optional[Dict[str, Any]] = None, redirect_uri: str = "",
129
+ last_refresh: Optional[str] = None, auth_mode: str = "oauth_device_code",
130
+ set_active: bool = True,
131
+ ) -> None:
132
+ """Persist xAI OAuth tokens; *set_active* also promotes ``xai-oauth`` to ``active_provider``.
133
+
134
+ Pass ``set_active=False`` for side-tool bootstrap (TTS/setup, tools config, dashboard, refresh)
135
+ so inference routing is unchanged.
136
+ """
137
+ 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
138
+ if last_refresh is None:
139
+ last_refresh = _utc_now_z()
140
+ with _auth_store_lock():
141
+ auth_store = _load_auth_store()
142
+ # A profile lacking its own xai-oauth block reads root's grant via fallback; refreshing it
143
+ # must write the rotated chain back to root or root keeps a revoked refresh token. Decide by
144
+ # where the grant was resolved FROM (key presence lies: _store_provider_state creates it).
145
+ state, source_path = _load_provider_state_with_source(auth_store, "xai-oauth")
146
+ state = state if state is not None else {}
147
+ state.update(tokens=tokens, last_refresh=last_refresh, auth_mode=auth_mode)
148
+ if discovery:
149
+ state["discovery"] = discovery
150
+ if redirect_uri:
151
+ state["redirect_uri"] = redirect_uri
152
+ global_root = _global_auth_file_path()
153
+ if source_path is not None and global_root is not None and _same_path(source_path, global_root):
154
+ # Root-only write-back: a profile copy would shadow root and disable write-through.
155
+ _write_through_xai_oauth_to_global_root(state)
156
+ else:
157
+ _store_provider_state(auth_store, "xai-oauth", state, set_active=set_active)
158
+ _save_auth_store(auth_store)
159
+
160
+
161
+ def _xai_jwt_exp(access_token: Any) -> Optional[float]:
162
+ """``exp`` claim of a JWT-shaped access token, or None when absent/undecodable."""
163
+ if not isinstance(access_token, str) or "." not in access_token:
164
+ return None
165
+ try:
166
+ parts = access_token.split(".")
167
+ if len(parts) < 2:
168
+ return None
169
+ payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
170
+ exp = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8")).get("exp")
171
+ return float(exp) if isinstance(exp, (int, float)) else None
172
+ except Exception:
173
+ return None
174
+
175
+
176
+ def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool:
177
+ exp = _xai_jwt_exp(access_token)
178
+ return exp is not None and exp <= time.time() + max(0, int(skew_seconds))
179
+
180
+
181
+ def _xai_proactive_refresh_skew_seconds(access_token: str) -> int:
182
+ """Proactive-refresh lead time before JWT ``exp``.
183
+
184
+ Device-code logins often return ~15-minute JWTs; the full hour-long skew would refresh on every
185
+ resolution, burning single-use refresh tokens and racing callers into ``invalid_grant``.
186
+ """
187
+ max_skew = XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS
188
+ exp = _xai_jwt_exp(access_token)
189
+ if exp is None:
190
+ return max_skew
191
+ remaining = exp - time.time()
192
+ return min(120, max_skew) if 0 < remaining <= 45 * 60 else max_skew
193
+
194
+
195
+ def _is_xai_origin_host(host: str) -> bool:
196
+ """``x.ai`` is the bare apex, so an exact match or any ``.x.ai`` suffix is accepted."""
197
+ return host == "x.ai" or host.endswith(".x.ai")
198
+
199
+
200
+ def _xai_url_problem(url: str) -> tuple[Optional[str], str]:
201
+ """``(problem, host)`` — problem is ``"scheme"``, ``"host"``, ``"origin"`` or None when *url* is HTTPS on x.ai."""
202
+ parsed = urlparse(url)
203
+ host = (parsed.hostname or "").lower()
204
+ if parsed.scheme != "https":
205
+ return "scheme", host
206
+ if not host:
207
+ return "host", host
208
+ if not _is_xai_origin_host(host):
209
+ return "origin", host
210
+ return None, host
211
+
212
+
213
+ def _xai_validate_oauth_endpoint(url: str, *, field: str) -> str:
214
+ """Refuse a discovery endpoint that isn't HTTPS on the xAI origin.
215
+
216
+ Discovery is cached in auth.json, so one MITM at login could plant a ``token_endpoint`` that
217
+ receives the refresh_token forever; pinning scheme + host (RFC 8414 §2) removes that.
218
+ """
219
+ problem, host = _xai_url_problem(url)
220
+ if problem is None:
221
+ return url
222
+ message = {
223
+ "scheme": f"xAI OIDC discovery returned a non-HTTPS {field}: {url!r}.",
224
+ "host": f"xAI OIDC discovery {field} is missing a hostname: {url!r}.",
225
+ "origin": (
226
+ f"xAI OIDC discovery {field} host {host!r} is not on the xAI origin "
227
+ f"(expected x.ai or a *.x.ai subdomain). Refusing to use a cached "
228
+ f"endpoint that may have been substituted by a MITM during initial "
229
+ f"discovery; re-authenticate with `hermes model` to re-fetch."
230
+ ),
231
+ }[problem]
232
+ raise _xai_err(message, "xai_discovery_invalid")
233
+
234
+
235
+ def _xai_validate_inference_base_url(value: str, *, fallback: str) -> str:
236
+ """Pin the OAuth inference base_url to ``*.x.ai``; warn and use *fallback* on rejection.
237
+
238
+ Warn-not-raise: a bad env var must not deadlock auth, but the bearer must never leak elsewhere.
239
+ """
240
+ candidate = (value or "").strip().rstrip("/")
241
+ if not candidate:
242
+ return fallback
243
+ try:
244
+ problem, host = _xai_url_problem(candidate)
245
+ except Exception:
246
+ logger.warning("Ignoring malformed xAI base_url override %r; using %s instead.", candidate, fallback)
247
+ return fallback
248
+ if problem is None:
249
+ return candidate
250
+ if problem == "scheme":
251
+ logger.warning(
252
+ "Refusing non-HTTPS xAI base_url override %r (xai-oauth bearer would "
253
+ "be sent in cleartext); falling back to %s.",
254
+ candidate, fallback,
255
+ )
256
+ elif problem == "host":
257
+ logger.warning("Ignoring xAI base_url override %r with no hostname; using %s instead.", candidate, fallback)
258
+ else:
259
+ logger.warning(
260
+ "Refusing xAI base_url override %r — host %r is not on the xAI origin "
261
+ "(expected x.ai or a *.x.ai subdomain). The xai-oauth bearer is only "
262
+ "valid against xAI's inference API; sending it elsewhere would leak "
263
+ "the credential. Falling back to %s.",
264
+ candidate, host, fallback,
265
+ )
266
+ return fallback
267
+
268
+
269
+ def _xai_oauth_discovery(timeout_seconds: float = 15.0) -> Dict[str, str]:
270
+ try:
271
+ response = httpx.get(XAI_OAUTH_DISCOVERY_URL, headers={"Accept": "application/json"}, timeout=timeout_seconds)
272
+ except Exception as exc:
273
+ raise _xai_err(f"xAI OIDC discovery failed: {exc}", "xai_discovery_failed") from exc
274
+ if response.status_code != 200:
275
+ raise _xai_err(f"xAI OIDC discovery returned status {response.status_code}.", "xai_discovery_failed")
276
+ try:
277
+ payload = response.json()
278
+ except Exception as exc:
279
+ raise _xai_err(f"xAI OIDC discovery returned invalid JSON: {exc}", "xai_discovery_invalid_json") from exc
280
+ if not isinstance(payload, dict):
281
+ raise _xai_err("xAI OIDC discovery response was not a JSON object.", "xai_discovery_incomplete")
282
+ endpoints = {k: _clean(payload.get(k)) for k in ("authorization_endpoint", "token_endpoint")}
283
+ if not all(endpoints.values()):
284
+ raise _xai_err("xAI OIDC discovery response was missing required endpoints.", "xai_discovery_incomplete")
285
+ for field, url in endpoints.items():
286
+ _xai_validate_oauth_endpoint(url, field=field)
287
+ return endpoints
288
+
289
+
290
+ def _xai_tokens_from_payload(payload: Dict[str, Any], access_token: str, fallback_refresh: str) -> Dict[str, Any]:
291
+ """Token block persisted for xAI OAuth; falls back to *fallback_refresh* when none is rotated in."""
292
+ return {
293
+ "access_token": access_token,
294
+ "refresh_token": str(payload.get("refresh_token") or fallback_refresh).strip(),
295
+ "id_token": _clean(payload.get("id_token")), "expires_in": payload.get("expires_in"),
296
+ "token_type": _clean(payload.get("token_type") or "Bearer") or "Bearer",
297
+ }
298
+
299
+
300
+ def refresh_xai_oauth_pure(
301
+ access_token: str, refresh_token: str, *, token_endpoint: str = "",
302
+ timeout_seconds: float = 20.0,
303
+ ) -> Dict[str, Any]:
304
+ from hermes_cli.auth import _nonempty_str, _utc_now_z, _xai_oauth_discovery
305
+ del access_token
306
+ if not _nonempty_str(refresh_token):
307
+ raise _xai_err(
308
+ f"xAI OAuth is missing refresh_token. {_RELOGIN}", "xai_auth_missing_refresh_token", relogin=True,
309
+ )
310
+ endpoint = token_endpoint.strip() or _xai_oauth_discovery(timeout_seconds)["token_endpoint"]
311
+ # Re-validate cached endpoints: an old/hand-edited auth.json may carry a non-xAI token_endpoint
312
+ # that would otherwise receive every future refresh_token.
313
+ _xai_validate_oauth_endpoint(endpoint, field="token_endpoint")
314
+ timeout = httpx.Timeout(max(5.0, float(timeout_seconds)))
315
+ with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client:
316
+ response = client.post(
317
+ endpoint, headers={"Content-Type": "application/x-www-form-urlencoded"},
318
+ data={"grant_type": "refresh_token", "client_id": XAI_OAUTH_CLIENT_ID, "refresh_token": refresh_token},
319
+ )
320
+ if response.status_code != 200:
321
+ detail = response.text.strip()
322
+ suffix = f" Response: {detail}" if detail else ""
323
+ # 403 is almost always a tier/entitlement gate; re-login won't fix it, so use a separate
324
+ # code and format_auth_error skips the re-authenticate hint.
325
+ # ``403`` from xAI's token endpoint is almost always a tier / entitlement gate (the OAuth grant
326
+ # exists but the account isn't on the allowlist for API access). Re-running ``hermes model`` won't
327
+ # fix that — surface a separate error code so ``format_auth_error`` doesn't append a misleading
328
+ # re-authenticate hint, and point users at the ``XAI_API_KEY`` fallback. See #26847.
329
+ if response.status_code == 403:
330
+ raise _xai_err(
331
+ "xAI token refresh failed with HTTP 403." + suffix
332
+ + " This OAuth account is not authorized for xAI API"
333
+ " access — xAI may be restricting API/OAuth use to"
334
+ " specific SuperGrok tiers despite the in-app"
335
+ " subscription being active. Re-logging in won't"
336
+ " change that; set ``XAI_API_KEY`` and switch to"
337
+ " ``provider: xai`` (API-key path) if available, or"
338
+ " upgrade your subscription at https://x.ai/grok.",
339
+ "xai_oauth_tier_denied", relogin=False,
340
+ )
341
+ raise _xai_err(
342
+ "xAI token refresh failed." + suffix, "xai_refresh_failed", relogin=response.status_code in {400, 401},
343
+ )
344
+ payload, refreshed_access = _refresh_payload_access_token(
345
+ response, provider="xai-oauth",
346
+ invalid_json=("xAI token refresh returned invalid JSON: {exc}", "xai_refresh_invalid_json"),
347
+ invalid_json_relogin=False, strict_str=False,
348
+ invalid_response=("xAI token refresh response was not a JSON object.", "xai_refresh_invalid_response"),
349
+ missing_access=("xAI token refresh response was missing access_token.", "xai_refresh_missing_access_token"),
350
+ )
351
+ return {**_xai_tokens_from_payload(payload, refreshed_access, refresh_token), "last_refresh": _utc_now_z()}
352
+
353
+
354
+ def _refresh_xai_oauth_tokens(
355
+ tokens: Dict[str, Any], *, token_endpoint: str, redirect_uri: str = "", timeout_seconds: float
356
+ ) -> Dict[str, Any]:
357
+ # Keep the stored auth_mode (legacy logins may carry ``oauth_pkce``): refresh must not relabel it.
358
+ from hermes_cli.auth import _load_auth_store, _load_provider_state, refresh_xai_oauth_pure
359
+ try:
360
+ state = _load_provider_state(_load_auth_store(), "xai-oauth") or {}
361
+ auth_mode = str(state.get("auth_mode") or "oauth_device_code")
362
+ except Exception:
363
+ auth_mode = "oauth_device_code"
364
+ refreshed = refresh_xai_oauth_pure(
365
+ _clean(tokens.get("access_token")), _clean(tokens.get("refresh_token")),
366
+ token_endpoint=token_endpoint, timeout_seconds=timeout_seconds,
367
+ )
368
+ updated_tokens = dict(tokens)
369
+ updated_tokens["access_token"] = refreshed["access_token"]
370
+ updated_tokens["refresh_token"] = refreshed["refresh_token"]
371
+ if refreshed.get("id_token"):
372
+ updated_tokens["id_token"] = refreshed["id_token"]
373
+ if refreshed.get("expires_in") is not None:
374
+ updated_tokens["expires_in"] = refreshed["expires_in"]
375
+ if refreshed.get("token_type"):
376
+ updated_tokens["token_type"] = refreshed["token_type"]
377
+ # set_active=False: side tools (TTS) refresh xAI tokens while chat routes elsewhere.
378
+ _save_xai_oauth_tokens(
379
+ updated_tokens, discovery={"token_endpoint": token_endpoint}, redirect_uri=redirect_uri,
380
+ last_refresh=refreshed["last_refresh"], auth_mode=auth_mode, set_active=False,
381
+ )
382
+ return updated_tokens
383
+
384
+
385
+ def _quarantine_xai_oauth_tokens(exc: AuthError) -> None:
386
+ """Clear dead xAI tokens after a terminal (400/401/403) refresh failure so later sessions fail fast.
387
+
388
+ Best-effort: persistence failures are logged and swallowed; the caller re-raises regardless.
389
+ """
390
+ from hermes_cli.auth import _last_auth_error_marker, _load_auth_store, _load_provider_state, _save_auth_store, _store_provider_state
391
+ try:
392
+ store = _load_auth_store()
393
+ state = _load_provider_state(store, "xai-oauth") or {}
394
+ tokens = dict(state.get("tokens") or {})
395
+ tokens.pop("access_token", None)
396
+ tokens.pop("refresh_token", None)
397
+ # Capture the previous singleton tokens BEFORE overwriting them. The pool-sync step uses this to
398
+ # distinguish legacy singleton-aliases (which should be refreshed) from independent accounts that
399
+ # ``hermes auth add openai-codex`` created (which must not be overwritten — see #39236).
400
+ state["tokens"] = tokens
401
+ state["last_auth_error"] = _last_auth_error_marker(
402
+ "xai-oauth", exc, reason="runtime_refresh_failure", default_code="xai_refresh_failed",
403
+ )
404
+ _store_provider_state(store, "xai-oauth", state, set_active=False)
405
+ _save_auth_store(store)
406
+ except Exception as save_exc:
407
+ logger.debug("xAI OAuth: failed to persist quarantined state: %s", save_exc)
408
+
409
+
410
+ def _xai_oauth_inference_base_url() -> str:
411
+ return _xai_validate_inference_base_url(
412
+ os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/") or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"),
413
+ fallback=DEFAULT_XAI_OAUTH_BASE_URL,
414
+ )
415
+
416
+
417
+ def resolve_xai_oauth_runtime_credentials(
418
+ *, force_refresh: bool = False, refresh_if_expiring: bool = True,
419
+ refresh_skew_seconds: Optional[int] = None,
420
+ ) -> Dict[str, Any]:
421
+ from hermes_cli.auth import _auth_store_lock, _is_terminal_xai_oauth_refresh_error, _refresh_xai_oauth_tokens, _xai_oauth_discovery
422
+
423
+ def _should_refresh(data: Dict[str, Any]) -> bool:
424
+ access_token = _clean(data["tokens"].get("access_token"))
425
+ skew = (
426
+ int(refresh_skew_seconds) if refresh_skew_seconds is not None
427
+ else _xai_proactive_refresh_skew_seconds(access_token)
428
+ )
429
+ return bool(force_refresh) or bool(
430
+ refresh_if_expiring and _xai_access_token_is_expiring(access_token, skew)
431
+ )
432
+
433
+ data = _read_xai_oauth_tokens()
434
+ tokens = dict(data["tokens"])
435
+ refresh_timeout_seconds = env_float("HERMES_XAI_REFRESH_TIMEOUT_SECONDS", 20)
436
+ if _should_refresh(data):
437
+ with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)):
438
+ # Re-read under the lock: a concurrent caller may already have rotated the grant.
439
+ data = _read_xai_oauth_tokens(_lock=False)
440
+ tokens = dict(data["tokens"])
441
+ if _should_refresh(data):
442
+ token_endpoint = (
443
+ _clean(dict(data.get("discovery") or {}).get("token_endpoint"))
444
+ or _xai_oauth_discovery(refresh_timeout_seconds)["token_endpoint"]
445
+ )
446
+ try:
447
+ tokens = _refresh_xai_oauth_tokens(
448
+ tokens, token_endpoint=token_endpoint, redirect_uri=_clean(data.get("redirect_uri")),
449
+ timeout_seconds=refresh_timeout_seconds,
450
+ )
451
+ except AuthError as exc:
452
+ if _is_terminal_xai_oauth_refresh_error(exc):
453
+ _quarantine_xai_oauth_tokens(exc)
454
+ raise
455
+
456
+ return {
457
+ "provider": "xai-oauth",
458
+ "base_url": _xai_oauth_inference_base_url(),
459
+ "api_key": _clean(tokens.get("access_token")),
460
+ "source": "hermes-auth-store",
461
+ "last_refresh": data.get("last_refresh"),
462
+ # Display only; auth.json may still carry a legacy ``oauth_pkce`` label.
463
+ "auth_mode": "oauth_device_code",
464
+ }
465
+
466
+
467
+ def _login_xai_oauth(args, pconfig: ProviderConfig, *, force_new_login: bool = False) -> None:
468
+ 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
469
+ del pconfig
470
+
471
+ if not force_new_login and _offer_existing_oauth_credentials(
472
+ "xai-oauth",
473
+ resolve=resolve_xai_oauth_runtime_credentials,
474
+ is_expiring=_xai_access_token_is_expiring,
475
+ display_name="xAI OAuth",
476
+ default_base_url=DEFAULT_XAI_OAUTH_BASE_URL,
477
+ ):
478
+ return
479
+
480
+ print()
481
+ print("Signing in to xAI Grok OAuth (SuperGrok / Premium+)...")
482
+ print("(Hermes creates its own local OAuth session)")
483
+ print()
484
+
485
+ timeout_seconds = float(getattr(args, "timeout", None) or 20.0)
486
+ open_browser = not getattr(args, "no_browser", False)
487
+ if _is_remote_session():
488
+ open_browser = False
489
+
490
+ creds = _xai_oauth_device_code_login(timeout_seconds=timeout_seconds, open_browser=open_browser)
491
+ _save_xai_oauth_tokens(
492
+ creds["tokens"], discovery=creds.get("discovery"),
493
+ redirect_uri=creds.get("redirect_uri", ""), last_refresh=creds.get("last_refresh"),
494
+ auth_mode="oauth_device_code",
495
+ )
496
+ # Explicit re-login re-enables the credential: clear the ``device_code`` suppression marker left
497
+ # by ``hermes auth remove xai-oauth``. Deliberately NOT inside _save_xai_oauth_tokens — the
498
+ # refresh hot path shares that helper and must never mutate suppression state.
499
+ unsuppress_credential_source("xai-oauth", "device_code")
500
+ config_path = _update_config_for_provider("xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL))
501
+ _print_login_success("xai-oauth", config_path, show_auth_state=True)
502
+
503
+
504
+ def _xai_oauth_request_device_code(client: httpx.Client, *, scope: str = XAI_OAUTH_SCOPE) -> Dict[str, Any]:
505
+ response = client.post(
506
+ XAI_OAUTH_DEVICE_CODE_URL, headers=_FORM_JSON_HEADERS, data={"client_id": XAI_OAUTH_CLIENT_ID, "scope": scope},
507
+ )
508
+ if response.status_code != 200:
509
+ raise _xai_err(
510
+ f"xAI device-code request failed (HTTP {response.status_code})."
511
+ + (f" Response: {response.text.strip()}" if response.text else ""),
512
+ "device_code_request_failed",
513
+ )
514
+ payload = response.json()
515
+ required = ("device_code", "user_code", "verification_uri", "verification_uri_complete", "expires_in", "interval")
516
+ missing = [key for key in required if key not in payload]
517
+ if missing:
518
+ raise _xai_err(f"xAI device-code response missing fields: {', '.join(missing)}", "device_code_invalid")
519
+ return payload
520
+
521
+
522
+ def _xai_oauth_poll_device_token(
523
+ client: httpx.Client, *, token_endpoint: str, device_code: str, expires_in: int,
524
+ poll_interval: int,
525
+ ) -> Dict[str, Any]:
526
+ from hermes_cli.auth import _poll_device_token_generic
527
+ def _validate(payload: Dict[str, Any]) -> None:
528
+ for field_name, article in (("access_token", "an"), ("refresh_token", "a")):
529
+ if not payload.get(field_name):
530
+ raise _xai_err(
531
+ f"xAI device-code token response did not include {article} {field_name}.",
532
+ "xai_device_token_invalid",
533
+ )
534
+
535
+ def _error(response, error_payload) -> Exception:
536
+ description = error_payload.get("error_description") or error_payload.get("error") or response.text
537
+ return _xai_err(f"xAI device-code token polling failed: {description}", "xai_device_token_failed")
538
+
539
+ return _poll_device_token_generic(
540
+ lambda: client.post(
541
+ token_endpoint, headers=_FORM_JSON_HEADERS,
542
+ data={"grant_type": DEVICE_CODE_GRANT_TYPE, "client_id": XAI_OAUTH_CLIENT_ID, "device_code": device_code},
543
+ ),
544
+ expires_in=int(expires_in),
545
+ poll_interval=max(1, int(poll_interval)),
546
+ validate_success=_validate,
547
+ on_non_json_error=lambda _r: _xai_err(
548
+ "xAI device-code token polling returned a non-JSON error response.", "xai_device_token_failed",
549
+ ),
550
+ on_error=_error,
551
+ on_timeout=lambda: _xai_err("Timed out waiting for xAI device authorization.", "device_code_timeout"),
552
+ )
553
+
554
+
555
+ def _xai_oauth_device_code_login(*, timeout_seconds: float = 20.0, open_browser: bool = True) -> Dict[str, Any]:
556
+ 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
557
+ discovery = _xai_oauth_discovery(timeout_seconds)
558
+ timeout = httpx.Timeout(max(20.0, timeout_seconds))
559
+ with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client:
560
+ device_data = _xai_oauth_request_device_code(client)
561
+ interval = int(device_data["interval"])
562
+ _print_device_code_instructions(
563
+ str(device_data.get("verification_uri_complete") or device_data["verification_uri"]),
564
+ str(device_data["user_code"]),
565
+ open_browser=open_browser and not _is_remote_session() and _can_open_graphical_browser(),
566
+ swallow_open_errors=True,
567
+ )
568
+ print(f"Waiting for approval (polling every {max(1, interval)}s)...")
569
+ payload = _xai_oauth_poll_device_token(
570
+ client, token_endpoint=discovery["token_endpoint"],
571
+ device_code=str(device_data["device_code"]), expires_in=int(device_data["expires_in"]),
572
+ poll_interval=interval,
573
+ )
574
+
575
+ access_token, refresh_token = _token_pair(payload)
576
+ if not access_token or not refresh_token:
577
+ raise _xai_err("xAI device-code token response was missing required tokens.", "xai_device_token_invalid")
578
+ return {
579
+ "tokens": _xai_tokens_from_payload(payload, access_token, refresh_token),
580
+ "discovery": discovery, "redirect_uri": "", "base_url": _xai_oauth_inference_base_url(),
581
+ "last_refresh": _utc_now_z(), "source": "oauth-device-code",
582
+ }
hermes_cli/cli_output.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared CLI output helpers (``print_*`` + ``prompt()``) for the setup/config wizards."""
2
+
3
+ import sys
4
+
5
+ from hermes_cli.colors import Colors, color
6
+ from hermes_cli.secret_prompt import masked_secret_prompt
7
+
8
+
9
+ def print_info(text: str) -> None:
10
+ print(color(f" {text}", Colors.DIM))
11
+
12
+
13
+ def print_success(text: str) -> None:
14
+ print(color(f"✓ {text}", Colors.GREEN))
15
+
16
+
17
+ def print_warning(text: str) -> None:
18
+ print(color(f"⚠ {text}", Colors.YELLOW))
19
+
20
+
21
+ def print_error(text: str) -> None:
22
+ print(color(f"✗ {text}", Colors.RED))
23
+
24
+
25
+ def print_header(text: str) -> None:
26
+ print(color(f"\n {text}", Colors.YELLOW))
27
+
28
+
29
+ def line_input(prompt_text: str) -> str:
30
+ """Read non-secret text with normal cursor-editing keys on a real TTY.
31
+
32
+ Setup/model-selection commands run outside the chat's prompt_toolkit application, so a
33
+ short-lived prompt is safe here. Redirected stdin/stdout keep the built-in ``input`` used by
34
+ scripts, tests and numbered fallbacks.
35
+ """
36
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
37
+ return input(prompt_text)
38
+ try:
39
+ from prompt_toolkit import prompt as prompt_toolkit_prompt
40
+ from prompt_toolkit.formatted_text import ANSI
41
+ except ImportError:
42
+ return input(prompt_text)
43
+ try:
44
+ return prompt_toolkit_prompt(ANSI(prompt_text))
45
+ except (KeyboardInterrupt, EOFError):
46
+ raise
47
+ except Exception:
48
+ # Some terminals report isatty() yet reject registering stdin with the asyncio selector
49
+ # (macOS kqueue raises EINVAL for fd 0). Any prompt_toolkit runtime failure degrades to
50
+ # the built-in reader, which needs no selector — the wizard proceeds instead of crashing.
51
+ return input(prompt_text)
52
+
53
+
54
+ def prompt(question: str, default: str | None = None, password: bool = False) -> str:
55
+ """Prompt for input (stripped), or ``default`` on plain Enter; "" on Ctrl-C/EOF."""
56
+ suffix = f" [{default}]" if default else ""
57
+ display = color(f" {question}{suffix}: ", Colors.YELLOW)
58
+ try:
59
+ value = (masked_secret_prompt(display) if password else line_input(display)).strip()
60
+ return value if value else (default or "")
61
+ except (KeyboardInterrupt, EOFError):
62
+ print()
63
+ return ""
64
+
65
+
66
+ def prompt_yes_no(question: str, default: bool = True) -> bool:
67
+ answer = prompt(f"{question} ({'Y/n' if default else 'y/N'})")
68
+ if not answer:
69
+ return default
70
+ return answer.lower().startswith("y")
hermes_cli/colors.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared ANSI color utilities for Hermes CLI modules."""
2
+
3
+ import os
4
+ import sys
5
+
6
+
7
+ def should_use_color() -> bool:
8
+ """Return True when colored output is appropriate."""
9
+ if os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb":
10
+ return False
11
+ return bool(sys.stdout.isatty())
12
+
13
+
14
+ class Colors:
15
+ RESET = "\033[0m"
16
+ BOLD = "\033[1m"
17
+ DIM = "\033[2m"
18
+ RED = "\033[31m"
19
+ GREEN = "\033[32m"
20
+ YELLOW = "\033[33m"
21
+ BLUE = "\033[34m"
22
+ MAGENTA = "\033[35m"
23
+ CYAN = "\033[36m"
24
+
25
+
26
+ def color(text: str, *codes) -> str:
27
+ """Apply color codes to text (only when color output is appropriate)."""
28
+ if not should_use_color():
29
+ return text
30
+ return "".join(codes) + text + Colors.RESET
hermes_cli/curator.py ADDED
@@ -0,0 +1,710 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI subcommand: `hermes curator <subcommand>`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+
12
+ def _parse_ts(ts) -> Optional[datetime]:
13
+ """ISO timestamp -> aware UTC datetime, or None when unparseable."""
14
+ try:
15
+ dt = datetime.fromisoformat(ts)
16
+ except (TypeError, ValueError):
17
+ return None
18
+ return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
19
+
20
+
21
+ def _fmt_ts(ts: Optional[str]) -> str:
22
+ if not ts:
23
+ return "never"
24
+ dt = _parse_ts(ts)
25
+ if dt is None:
26
+ return str(ts)
27
+ secs = int((datetime.now(timezone.utc) - dt).total_seconds())
28
+ for unit, div, limit in (("s", 1, 60), ("m", 60, 3600), ("h", 3600, 86400)):
29
+ if secs < limit:
30
+ return f"{secs // div}{unit} ago"
31
+ return f"{secs // 86400}d ago"
32
+
33
+
34
+ def _confirm(prompt: str, cancel: str = "cancelled", eof_prefix: str = "\n") -> bool:
35
+ """Ask ``prompt``; print ``cancel`` (prefixed on EOF/Ctrl-C) and return False unless y/yes."""
36
+ try:
37
+ if input(prompt).strip().lower() in {"y", "yes"}:
38
+ return True
39
+ except (EOFError, KeyboardInterrupt):
40
+ print(eof_prefix, end="")
41
+ print(cancel)
42
+ return False
43
+
44
+
45
+ def _print_skill_rows(title: str, rows: list) -> None:
46
+ print(f"\n{title}:")
47
+ for r in rows:
48
+ print(
49
+ f" {r['name']:40s} "
50
+ f"activity={r.get('activity_count', 0):3d} "
51
+ f"use={r.get('use_count', 0):3d} "
52
+ f"view={r.get('view_count', 0):3d} "
53
+ f"patches={r.get('patch_count', 0):3d} "
54
+ f"last_activity={_fmt_ts(r.get('last_activity_at'))}")
55
+
56
+
57
+ def _print_unmanaged_summary() -> None:
58
+ """Report curation-eligible skills that carry no provenance marker: only background-review
59
+ creations get ``created_by: agent``; older skills and every foreground ``skill_manage(create)``
60
+ are eligible but unmanaged, so no automatic transition touches them."""
61
+ from tools import skill_usage
62
+ try:
63
+ unmanaged = skill_usage.unmanaged_report()
64
+ except Exception:
65
+ return
66
+ if not unmanaged:
67
+ return
68
+ legacy = sum(1 for r in unmanaged if not r.get("has_provenance_key"))
69
+ foreground = len(unmanaged) - legacy
70
+ print(f"\nunmanaged (no provenance marker): {len(unmanaged)} total")
71
+ print(f" pre-dates marker {legacy}")
72
+ print(f" foreground-created {foreground}")
73
+ print(" never auto-staled or archived — `hermes curator adopt <name>` hands one over")
74
+
75
+
76
+ def _print_curator_config(curator) -> None:
77
+ state = curator.load_state()
78
+ status_line = (
79
+ "PAUSED" if state.get("paused", False)
80
+ else "ENABLED" if curator.is_enabled() else "DISABLED")
81
+ print(f"curator: {status_line}")
82
+ print(f" runs: {state.get('run_count', 0)}")
83
+ print(f" last run: {_fmt_ts(state.get('last_run_at'))}")
84
+ # Multi-line when the curator archived skills (rename map appended as `name → umbrella`
85
+ # lines); indent continuation lines so the block reads as one field.
86
+ summary = state.get("last_run_summary") or "(none)"
87
+ first, *rest = summary.splitlines() if "\n" in summary else [summary]
88
+ print(f" last summary: {first}")
89
+ for line in rest:
90
+ print(f" {line}")
91
+ report = state.get("last_report_path")
92
+ if report:
93
+ print(f" last report: {report}{'' if Path(report).exists() else ' (missing)'}")
94
+ ih = curator.get_interval_hours()
95
+ print(f" interval: every {f'{ih // 24}d' if ih % 24 == 0 and ih >= 24 else f'{ih}h'}")
96
+ print(f" stale after: {curator.get_stale_after_days()}d unused")
97
+ print(f" archive after: {curator.get_archive_after_days()}d unused")
98
+ consolidate = curator.get_consolidate()
99
+ print(
100
+ f" consolidate: {'on' if consolidate else 'off'}"
101
+ f"{'' if consolidate else ' (prune-only; LLM merge pass opt-in)'}")
102
+
103
+
104
+ def _cmd_status(args) -> int:
105
+ from agent import curator
106
+ from tools import skill_usage
107
+ _print_curator_config(curator)
108
+ rows = skill_usage.curated_report()
109
+ if not rows:
110
+ print("\nno curator-managed skills")
111
+ _print_unmanaged_summary()
112
+ return 0
113
+ by_state: dict = {}
114
+ for r in rows:
115
+ by_state.setdefault(r.get("state", "active"), []).append(r)
116
+ pinned = [r["name"] for r in rows if r.get("pinned")]
117
+ provenance = [r.get("provenance", "agent") for r in rows]
118
+ print(f"\ncurator-managed skills: {len(rows)} total "
119
+ f"(agent-created={provenance.count('agent')} bundled={provenance.count('bundled')})")
120
+ for state_name in ("active", "stale", "archived"):
121
+ print(f" {state_name:10s} {len(by_state.get(state_name, []))}")
122
+ if pinned:
123
+ print(f"\npinned ({len(pinned)}): {', '.join(pinned)}")
124
+ _print_unmanaged_summary() # the curation blind spot matters on the managed path too
125
+ # Views and edits count as activity: a skill must not read as "never used" right after
126
+ # skill_view()/skill_manage() touched it. Recency (last_activity_at) and frequency
127
+ # (activity_count) are different signals, so both top-5s are shown.
128
+ active_all = by_state.get("active", [])
129
+ if not active_all:
130
+ return 0
131
+ recency = sorted(
132
+ active_all, key=lambda r: r.get("last_activity_at") or r.get("created_at") or "")
133
+ _print_skill_rows("least recently active (top 5)", recency[:5])
134
+
135
+ def _freq(r):
136
+ return (r.get("activity_count") or 0, r.get("last_activity_at") or "")
137
+
138
+ most_active = sorted(active_all, key=_freq, reverse=True)[:5]
139
+ if (most_active[0].get("activity_count") or 0) > 0:
140
+ _print_skill_rows("most active (top 5)", most_active)
141
+ _print_skill_rows("least active (top 5)", sorted(active_all, key=_freq)[:5])
142
+ return 0
143
+
144
+
145
+ def _cmd_run(args) -> int:
146
+ from agent import curator
147
+ if not curator.is_enabled():
148
+ print("curator: disabled via config; enable with `curator.enabled: true`")
149
+ return 1
150
+ dry = bool(getattr(args, "dry_run", False))
151
+ background = bool(getattr(args, "background", False))
152
+ synchronous = bool(getattr(args, "synchronous", False)) or not background
153
+ # --consolidate forces the LLM umbrella pass on; absent => None so run_curator_review reads
154
+ # curator.consolidate from config.
155
+ consolidate = True if getattr(args, "consolidate", False) else None
156
+ print(
157
+ "curator: running DRY-RUN (report only, no mutations)..." if dry
158
+ else "curator: running review pass...")
159
+ if consolidate is None and not curator.get_consolidate():
160
+ print(
161
+ "curator: consolidation is off — running prune-only "
162
+ "(deterministic stale/archive). Pass --consolidate or set "
163
+ "`curator.consolidate: true` to enable the LLM merge pass.")
164
+ result = curator.run_curator_review(
165
+ on_summary=print, synchronous=synchronous, dry_run=dry, consolidate=consolidate)
166
+ auto = result.get("auto_transitions", {})
167
+ if auto and dry:
168
+ print(
169
+ f"auto (preview): {auto.get('checked', 0)} candidate skill(s) "
170
+ "— no transitions applied in dry-run")
171
+ elif auto:
172
+ print(
173
+ f"auto: checked={auto.get('checked', 0)} "
174
+ f"stale={auto.get('marked_stale', 0)} "
175
+ f"archived={auto.get('archived', 0)} "
176
+ f"reactivated={auto.get('reactivated', 0)}")
177
+ if not synchronous:
178
+ print("llm pass running in background — check `hermes curator status` later")
179
+ if dry:
180
+ print(
181
+ "dry-run: no changes applied. Read the report with "
182
+ "`hermes curator status` and run `hermes curator run` (no flag) to apply."
183
+ if synchronous else
184
+ "dry-run: no changes applied. When the report lands, read it with "
185
+ "`hermes curator status` and run `hermes curator run` (no flag) to apply.")
186
+ return 0
187
+
188
+
189
+ def _set_paused(paused: bool) -> int:
190
+ from agent import curator
191
+ curator.set_paused(paused)
192
+ print("curator: paused" if paused else "curator: resumed")
193
+ return 0
194
+
195
+
196
+ def _cmd_pause(args) -> int: return _set_paused(True)
197
+ def _cmd_resume(args) -> int: return _set_paused(False)
198
+
199
+
200
+ _PIN_MESSAGES = {
201
+ True: (
202
+ "cannot pin (only agent-created skills participate in curation)",
203
+ "could not pin '{skill}' — the skill is not curation-eligible (protected built-in or "
204
+ "external). `hermes curator list-unmanaged` shows which skills the curator tracks.",
205
+ # Unmanaged skills are never auto-transitioned, so the pin is recorded but only
206
+ # becomes protective once the skill is adopted — say so and point at `adopt`.
207
+ "pinned '{skill}' (recorded; this skill is unmanaged — auto-transitions never consider "
208
+ "it. Run `hermes curator adopt {skill}` to put it under curator management)",
209
+ "pinned '{skill}' (will bypass auto-transitions)"),
210
+ False: (
211
+ "there's nothing to unpin (curator only tracks agent-created skills)",
212
+ "could not unpin '{skill}' — the skill is not curation-eligible (protected built-in or "
213
+ "external).",
214
+ "unpinned '{skill}' (recorded; this skill is unmanaged — it was never under "
215
+ "auto-transitions to begin with)",
216
+ "unpinned '{skill}'")}
217
+
218
+
219
+ def _set_pin(args, pinned: bool) -> int:
220
+ from tools import skill_usage
221
+ not_agent, not_eligible, unmanaged, done = _PIN_MESSAGES[pinned]
222
+ skill = args.skill
223
+ if not skill_usage.is_agent_created(skill):
224
+ print(f"curator: '{skill}' is bundled or hub-installed — {not_agent}")
225
+ return 1
226
+ if not skill_usage.set_pinned(skill, pinned):
227
+ print("curator: " + not_eligible.replace("{skill}", skill))
228
+ return 1
229
+ if not skill_usage.is_curator_managed(skill):
230
+ print("curator: " + unmanaged.replace("{skill}", skill))
231
+ return 0
232
+ print("curator: " + done.replace("{skill}", skill))
233
+ return 0
234
+
235
+
236
+ def _cmd_pin(args) -> int: return _set_pin(args, True)
237
+ def _cmd_unpin(args) -> int: return _set_pin(args, False)
238
+
239
+
240
+ def _cmd_list_unmanaged(args) -> int:
241
+ """Itemize the unmanaged population that `status` summarizes (input for `adopt`)."""
242
+ from tools import skill_usage
243
+ rows = skill_usage.unmanaged_report()
244
+ if not rows:
245
+ print("curator: no unmanaged skills — every eligible skill is managed")
246
+ return 0
247
+ print(f"unmanaged skills ({len(rows)}):")
248
+ for r in sorted(rows, key=lambda x: x["name"]):
249
+ why = "created_by:null" if r.get("has_provenance_key") else "no marker"
250
+ print(
251
+ f" {r['name']:44s} activity={r.get('activity_count', 0):4d} "
252
+ f"last_activity={_fmt_ts(r.get('last_activity_at')):14s} ({why})")
253
+ print("\nadopt one with `hermes curator adopt <name>`, "
254
+ "or all with `hermes curator adopt --all-unmanaged`")
255
+ return 0
256
+
257
+
258
+ def _cmd_adopt(args) -> int:
259
+ """Hand unmanaged skills to the curator by explicit user declaration: provenance cannot be
260
+ inferred from telemetry (a high patch count proves the agent MAINTAINS a skill, not that it
261
+ AUTHORED it)."""
262
+ from tools import skill_usage
263
+ names = list(getattr(args, "skill", None) or [])
264
+ adopt_all = bool(getattr(args, "all_unmanaged", False))
265
+ if adopt_all:
266
+ if names:
267
+ print("curator: pass either skill names or --all-unmanaged, not both")
268
+ return 1
269
+ names = skill_usage.list_unmanaged_skill_names()
270
+ if not names:
271
+ print("curator: no unmanaged skills to adopt")
272
+ return 0
273
+ if not names:
274
+ print("curator: name a skill to adopt, or pass --all-unmanaged")
275
+ return 1
276
+ if getattr(args, "dry_run", False):
277
+ print(f"curator: would adopt {len(names)} skill(s) (dry run):")
278
+ for n in names:
279
+ print(f" + {n}")
280
+ return 0
281
+ # Bulk adoption is a lifecycle change (adopted skills become archivable): confirm.
282
+ if adopt_all and not getattr(args, "yes", False):
283
+ print(f"curator: adopt {len(names)} unmanaged skill(s) into curator management?")
284
+ print(" they become eligible for automatic staleness + archival")
285
+ if not _confirm(" proceed? [y/N] ", "curator: aborted", eof_prefix=""):
286
+ return 1
287
+ failed = 0
288
+ for n in names:
289
+ ok, msg = skill_usage.adopt_skill(n)
290
+ print(f"curator: {msg}")
291
+ failed += not ok
292
+ if len(names) > 1:
293
+ print(f"curator: adopted {len(names) - failed}/{len(names)}")
294
+ return 1 if failed else 0
295
+
296
+
297
+ def _as_user(fn, skill: str) -> int:
298
+ """Run a skill mutation with the ledger actor set to ``user``; print and map its result."""
299
+ from tools import skill_ledger
300
+ tok = skill_ledger.set_ledger_actor("user")
301
+ try:
302
+ ok, msg = fn(skill)
303
+ finally:
304
+ skill_ledger.reset_ledger_actor(tok)
305
+ print(f"curator: {msg}")
306
+ return 0 if ok else 1
307
+
308
+
309
+ def _cmd_restore(args) -> int:
310
+ from tools import skill_usage
311
+ return _as_user(skill_usage.restore_skill, args.skill)
312
+
313
+
314
+ def _cmd_archive(args) -> int:
315
+ """Manually archive an agent-created skill. Refuses if pinned."""
316
+ from tools import skill_usage
317
+ if skill_usage.get_record(args.skill).get("pinned"):
318
+ print(
319
+ f"curator: '{args.skill}' is pinned — unpin first with "
320
+ f"`hermes curator unpin {args.skill}`")
321
+ return 1
322
+ return _as_user(skill_usage.archive_skill, args.skill)
323
+
324
+
325
+ def _idle_days(record: dict) -> Optional[int]:
326
+ """Days since last activity, falling back to ``created_at`` so never-used skills aren't
327
+ immortal; None only when both fields are missing or unparseable."""
328
+ ts = record.get("last_activity_at") or record.get("created_at")
329
+ dt = _parse_ts(str(ts)) if ts else None
330
+ return None if dt is None else max(0, (datetime.now(timezone.utc) - dt).days)
331
+
332
+
333
+ def _cmd_prune(args) -> int:
334
+ """Bulk-archive curator-managed skills idle for >= N days (pinned exempt, archived skipped)."""
335
+ from agent import curator
336
+ from tools import skill_usage
337
+ days = getattr(args, "days", None)
338
+ if days is None:
339
+ days = curator.get_archive_after_days()
340
+ if days < 1:
341
+ print(f"curator: --days must be >= 1 (got {days})", file=sys.stderr)
342
+ return 2
343
+ candidates = [
344
+ (r["name"], idle) for r in skill_usage.curated_report()
345
+ if not (r.get("pinned") or r.get("state") == skill_usage.STATE_ARCHIVED)
346
+ and (idle := _idle_days(r)) is not None and idle >= days]
347
+ if not candidates:
348
+ print(f"curator: nothing to prune (no unpinned skills idle >= {days}d)")
349
+ return 0
350
+ candidates.sort(key=lambda c: -c[1])
351
+ print(f"curator: {len(candidates)} skill(s) idle >= {days}d:")
352
+ for name, idle in candidates:
353
+ print(f" {name:40s} idle {idle}d")
354
+ if getattr(args, "dry_run", False):
355
+ print("\n(dry run — no changes made)")
356
+ return 0
357
+ if not getattr(args, "yes", False) and not _confirm(
358
+ f"\nArchive {len(candidates)} skill(s)? [y/N] ", "curator: aborted"):
359
+ return 1
360
+ results = [(name, *skill_usage.archive_skill(name)) for name, _ in candidates]
361
+ failures = [(name, msg) for name, ok, msg in results if not ok]
362
+ print(f"\ncurator: archived {len(results) - len(failures)}/{len(candidates)}")
363
+ if failures:
364
+ print("failures:")
365
+ for name, msg in failures:
366
+ print(f" {name}: {msg}")
367
+ return 1
368
+ return 0
369
+
370
+
371
+ def _cmd_backup(args) -> int:
372
+ """Manual skills-tree snapshot (same mechanism as the automatic pre-run snapshot)."""
373
+ from agent import curator_backup
374
+ if not curator_backup.is_enabled():
375
+ print(
376
+ "curator: backups are disabled via config "
377
+ "(`curator.backup.enabled: false`); re-enable to snapshot")
378
+ return 1
379
+ snap = curator_backup.snapshot_skills(reason=getattr(args, "reason", None) or "manual")
380
+ if snap is None:
381
+ print("curator: snapshot failed — check logs (backup disabled or IO error)")
382
+ return 1
383
+ print(f"curator: snapshot created at ~/.hermes/skills/.curator_backups/{snap.name}")
384
+ return 0
385
+
386
+
387
+ def _cmd_ledger(args) -> int:
388
+ """List per-mutation audit ledger entries (newest first)."""
389
+ from tools import skill_ledger
390
+ rows = skill_ledger.list_entries(
391
+ skill=getattr(args, "skill", None), limit=getattr(args, "limit", None) or 20)
392
+ if not rows:
393
+ print("curator: ledger is empty (or skills.ledger is disabled).")
394
+ return 0
395
+ print(f"{'id':<14} {'when':<12} {'actor':<8} {'action':<12} skill")
396
+ for r in rows:
397
+ evidence = r.get("evidence") or {}
398
+ extra = ""
399
+ if evidence.get("absorbed_into"):
400
+ extra = f" → absorbed into '{evidence['absorbed_into']}'"
401
+ elif evidence.get("rollback_target"):
402
+ extra = f" → rollback of {evidence['rollback_target']}"
403
+ print(
404
+ f"{r.get('id', '?'):<14} {_fmt_ts(r.get('ts')):<12} "
405
+ f"{r.get('actor', '?'):<8} {r.get('action', '?'):<12} "
406
+ f"{r.get('skill', '?')}{extra}")
407
+ print(
408
+ "\nRoll back a single mutation with `hermes curator rollback <id>`; "
409
+ "whole-tree snapshots remain available via `hermes curator rollback --list`.")
410
+ return 0
411
+
412
+
413
+ def _cmd_purge(args) -> int:
414
+ """Delete archived skills older than curator.archive_ttl_days. Explicit only — never
415
+ automatic; each purged skill is captured (before-blobs) and recorded as a 'purge' ledger
416
+ entry, so even a purge is auditable and blob-recoverable."""
417
+ import shutil
418
+ import time
419
+ from hermes_cli.config import cfg_get, load_config
420
+ from tools import skill_ledger
421
+ from tools.skill_usage import _archive_dir
422
+ ttl_days = getattr(args, "days", None)
423
+ if ttl_days is None:
424
+ ttl_days = int(cfg_get(load_config(), "curator", "archive_ttl_days", default=0) or 0)
425
+ if ttl_days <= 0:
426
+ print(
427
+ "curator: purge disabled (curator.archive_ttl_days is 0). Set the "
428
+ "config key or pass --days N to purge archives older than N days.")
429
+ return 1
430
+ archive_root = _archive_dir()
431
+ if not archive_root.exists():
432
+ print("curator: no archive directory — nothing to purge.")
433
+ return 0
434
+ cutoff = time.time() - ttl_days * 86400
435
+ candidates = sorted(
436
+ p for p in archive_root.iterdir() if p.is_dir() and p.stat().st_mtime < cutoff)
437
+ if not candidates:
438
+ print(f"curator: no archived skills older than {ttl_days}d.")
439
+ return 0
440
+ print(f"Archived skills older than {ttl_days}d:")
441
+ for p in candidates:
442
+ print(f" {p.name}")
443
+ if getattr(args, "dry_run", False):
444
+ print("(dry run — nothing deleted)")
445
+ return 0
446
+ if not getattr(args, "yes", False) and not _confirm(
447
+ f"Permanently delete {len(candidates)} archived skill(s)? [y/N] "):
448
+ return 1
449
+ purged = 0
450
+ for p in candidates:
451
+ before = skill_ledger.capture_before(p, complete_package=True, skill=p.name)
452
+ try:
453
+ shutil.rmtree(p)
454
+ except OSError as e:
455
+ print(f"curator: failed to purge {p.name}: {e}")
456
+ continue
457
+ skill_ledger.append_entry(
458
+ "purge", p.name, before=before or [], after=[], actor="user",
459
+ evidence={"ttl_days": ttl_days})
460
+ purged += 1
461
+ print(f"curator: purged {purged} archived skill(s). Ledger entries recorded.")
462
+ return 0
463
+
464
+
465
+ def _report_rollback(ok: bool, msg: str) -> int:
466
+ print(f"curator: {msg}" if ok else f"curator: rollback failed — {msg}")
467
+ return 0 if ok else 1
468
+
469
+
470
+ def _rollback_ledger_entry(args, entry_id: str) -> int:
471
+ """Restore exactly the files touched by one ledger entry (from content-addressed blobs); a
472
+ pre-rollback safety ledger entry is taken first and it fails closed if that capture fails."""
473
+ from tools import skill_ledger
474
+ entry = skill_ledger.get_entry(entry_id)
475
+ if entry is None:
476
+ print(
477
+ f"curator: no ledger entry '{entry_id}'. "
478
+ "See `hermes curator ledger` for entry ids, or use "
479
+ "`--id <snapshot>` for whole-tree snapshot rollback.")
480
+ return 1
481
+ print(f"Rollback target: ledger entry {entry_id}")
482
+ print(f" action: {entry.get('action', '?')}")
483
+ print(f" skill: {entry.get('skill', '?')}")
484
+ print(f" actor: {entry.get('actor', '?')}")
485
+ print(f" when: {entry.get('ts', '?')}")
486
+ touched = {i.get("path") for i in (entry.get("before") or []) + (entry.get("after") or [])}
487
+ print(f" files: {len(touched)}")
488
+ if not getattr(args, "yes", False) and not _confirm(
489
+ "Restore this mutation's before-state? [y/N] "):
490
+ return 1
491
+ return _report_rollback(*skill_ledger.rollback_entry(entry_id))
492
+
493
+
494
+ def _cmd_rollback(args) -> int:
495
+ """Restore the skills tree from a snapshot, or a single mutation from the audit ledger."""
496
+ from agent import curator_backup
497
+ entry_id = getattr(args, "entry_id", None)
498
+ if entry_id:
499
+ return _rollback_ledger_entry(args, entry_id)
500
+ if getattr(args, "list", False):
501
+ print(curator_backup.summarize_backups())
502
+ return 0
503
+ backup_id = getattr(args, "backup_id", None)
504
+ target_path = curator_backup._resolve_backup(backup_id)
505
+ if target_path is None:
506
+ if not curator_backup.list_backups():
507
+ print(
508
+ "curator: no snapshots exist yet. Take one with "
509
+ "`hermes curator backup` or wait for the next curator run.")
510
+ else:
511
+ print(
512
+ f"curator: no snapshot matching "
513
+ f"{'id ' + repr(backup_id) if backup_id else 'your query'}.")
514
+ print("Available:")
515
+ print(curator_backup.summarize_backups())
516
+ return 1
517
+ manifest = curator_backup._read_manifest(target_path)
518
+ print(f"Rollback target: {target_path.name}")
519
+ if manifest:
520
+ print(f" reason: {manifest.get('reason', '?')}")
521
+ print(f" created_at: {manifest.get('created_at', '?')}")
522
+ print(f" skill files: {manifest.get('skill_files', '?')}")
523
+ cron = manifest.get("cron_jobs") or {}
524
+ if isinstance(cron, dict):
525
+ if cron.get("backed_up"):
526
+ print(
527
+ f" cron jobs: {cron.get('jobs_count', 0)} "
528
+ f"(will be restored for skill-link fields only)")
529
+ else:
530
+ print(f" cron jobs: not in snapshot ({cron.get('reason', 'not captured')})")
531
+ print(
532
+ "\nThis will replace the current ~/.hermes/skills/ tree (a safety "
533
+ "snapshot of the current state is taken first so this is undoable). "
534
+ "Cron jobs that still exist will have their skills/skill fields "
535
+ "restored from the snapshot; all other cron fields are left alone.")
536
+ if not getattr(args, "yes", False) and not _confirm("Proceed? [y/N] "):
537
+ return 1
538
+ ok, msg, _ = curator_backup.rollback(backup_id=target_path.name)
539
+ return _report_rollback(ok, msg)
540
+
541
+
542
+ def _cmd_list_archived(args) -> int:
543
+ """List archived (recoverable) skills."""
544
+ from tools import skill_usage
545
+ names = skill_usage.list_archived_skill_names()
546
+ print("\n".join(names) if names else "curator: no archived skills")
547
+ return 0
548
+
549
+
550
+ _USAGE_SORTS = {
551
+ "name": (lambda r: r["name"], False),
552
+ "recent": (lambda r: r.get("last_activity_at") or "", True),
553
+ "activity": (lambda r: r.get("activity_count", 0), True)}
554
+
555
+
556
+ def _cmd_usage(args) -> int:
557
+ """Usage telemetry for ALL skills on disk (bundled + hub included), with provenance."""
558
+ import json as _json
559
+ from tools import skill_usage
560
+ rows = skill_usage.usage_report()
561
+ prov_filter = getattr(args, "provenance", None)
562
+ if prov_filter:
563
+ rows = [r for r in rows if r.get("provenance") == prov_filter]
564
+ # name: alphabetical; recent: most-recently-active first (never-active sinks); activity
565
+ # (default): most-used first.
566
+ key, reverse = _USAGE_SORTS.get(getattr(args, "sort", "activity"), _USAGE_SORTS["activity"])
567
+ rows.sort(key=key, reverse=reverse)
568
+ if getattr(args, "json", False):
569
+ print(_json.dumps(rows, indent=2, ensure_ascii=False))
570
+ return 0
571
+ if not rows:
572
+ print("curator: no skills found")
573
+ return 0
574
+ provenance = [r.get("provenance", "agent") for r in rows]
575
+ counts = {k: provenance.count(k) for k in ("agent", "bundled", "hub")}
576
+ print(
577
+ f"skills: {len(rows)} total "
578
+ f"(agent={counts['agent']} bundled={counts['bundled']} hub={counts['hub']})\n")
579
+ print(
580
+ f" {'skill':40s} {'origin':8s} "
581
+ f"{'use':>4s} {'view':>4s} {'patch':>5s} {'act':>4s} last_activity")
582
+ for r in rows:
583
+ print(
584
+ f" {r['name'][:40]:40s} "
585
+ f"{r.get('provenance', 'agent'):8s} "
586
+ f"{r.get('use_count', 0):>4d} "
587
+ f"{r.get('view_count', 0):>4d} "
588
+ f"{r.get('patch_count', 0):>5d} "
589
+ f"{r.get('activity_count', 0):>4d} "
590
+ f"{_fmt_ts(r.get('last_activity_at'))}")
591
+ return 0
592
+
593
+
594
+ def _arg(*flags, **kwargs):
595
+ return flags, kwargs
596
+
597
+
598
+ _SKILL = _arg("skill", help="Skill name")
599
+ _YES = _arg("-y", "--yes", action="store_true", help="Skip the confirmation prompt")
600
+ _STORE_TRUE = dict(action="store_true")
601
+
602
+ # argparse wiring: (name, help, handler, *(flags, add_argument kwargs))
603
+ _SUBCOMMANDS = (
604
+ ("status", "Show curator status and skill stats", _cmd_status),
605
+ (
606
+ "usage", "Show usage telemetry for ALL skills (built-in, hub, agent) with provenance",
607
+ _cmd_usage,
608
+ _arg("--sort", choices=("activity", "recent", "name"), default="activity",
609
+ help="Sort order: activity (most-used first, default), recent "
610
+ "(most-recently-active first), or name (alphabetical)"),
611
+ _arg("--provenance", choices=("agent", "bundled", "hub"), default=None,
612
+ help="Only show skills of this origin"),
613
+ _arg("--json", **_STORE_TRUE, help="Emit the full report as JSON instead of a table")),
614
+ (
615
+ "run", "Trigger a curator review now", _cmd_run,
616
+ _arg("--sync", "--synchronous", dest="synchronous", **_STORE_TRUE,
617
+ help="Wait for the LLM review pass to finish (default for manual runs)"),
618
+ _arg("--background", dest="background", **_STORE_TRUE,
619
+ help="Start the LLM review pass in a background thread and return immediately"),
620
+ _arg("--dry-run", dest="dry_run", **_STORE_TRUE,
621
+ help="Report only — no state changes, no archives, no consolidation "
622
+ "(use this to preview what curator would do)"),
623
+ _arg("--consolidate", dest="consolidate", **_STORE_TRUE,
624
+ help="Force the LLM umbrella-building consolidation pass on for this "
625
+ "run, overriding the config default (off). Without this flag the "
626
+ "run is prune-only unless `curator.consolidate: true` is set.")),
627
+ ("pause", "Pause the curator until resumed", _cmd_pause),
628
+ ("resume", "Resume a paused curator", _cmd_resume),
629
+ ("pin", "Pin a skill so the curator never auto-transitions it", _cmd_pin, _SKILL),
630
+ ("unpin", "Unpin a skill", _cmd_unpin, _SKILL),
631
+ ("list-unmanaged", "List curation-eligible skills with no provenance marker",
632
+ _cmd_list_unmanaged),
633
+ (
634
+ "adopt", "Hand unmanaged skills to the curator (provenance is a user declaration)",
635
+ _cmd_adopt,
636
+ _arg("skill", nargs="*", help="Skill name(s) to adopt. Omit when using --all-unmanaged."),
637
+ _arg("--all-unmanaged", **_STORE_TRUE,
638
+ help="Adopt every curation-eligible skill that has no provenance marker"),
639
+ _arg("--dry-run", **_STORE_TRUE,
640
+ help="List what would be adopted without writing anything"),
641
+ _arg("--yes", **_STORE_TRUE, help="Skip the confirmation prompt for --all-unmanaged")),
642
+ ("restore", "Restore an archived skill", _cmd_restore, _SKILL),
643
+ ("list-archived", "List archived skills", _cmd_list_archived),
644
+ ("archive", "Manually archive a skill (move to .archive/, excluded from prompt)", _cmd_archive,
645
+ _SKILL),
646
+ (
647
+ "prune", "Bulk-archive curator-managed skills idle for >= N days (default: curator.archive_after_days)",
648
+ _cmd_prune,
649
+ _arg("--days", type=int, default=None,
650
+ help="Archive skills idle for at least N days (default: curator.archive_after_days, 30)"),
651
+ _YES,
652
+ _arg("--dry-run", dest="dry_run", **_STORE_TRUE,
653
+ help="Show what would be archived without doing it")),
654
+ (
655
+ "backup",
656
+ "Take a manual tar.gz snapshot of ~/.hermes/skills/ "
657
+ "(curator also does this automatically before every real run)",
658
+ _cmd_backup,
659
+ _arg("--reason", default=None,
660
+ help="Free-text label stored in manifest.json (default: 'manual')")),
661
+ (
662
+ "rollback",
663
+ "Restore ~/.hermes/skills/ from a curator snapshot, or a single "
664
+ "mutation by ledger entry id (see `hermes curator ledger`)",
665
+ _cmd_rollback,
666
+ _arg("entry_id", nargs="?", default=None,
667
+ help="Ledger entry id for single-mutation rollback (from "
668
+ "`hermes curator ledger`). Omit for whole-tree snapshot rollback."),
669
+ _arg("--list", **_STORE_TRUE, help="List available snapshots and exit without restoring"),
670
+ _arg("--id", dest="backup_id", default=None,
671
+ help="Snapshot id to restore (see `--list`); default: newest"),
672
+ _arg("-y", "--yes", **_STORE_TRUE, help="Skip confirmation prompt")),
673
+ (
674
+ "ledger", "List the per-mutation skill audit ledger (all actors: curator/agent/user)",
675
+ _cmd_ledger,
676
+ _arg("--skill", default=None, help="Only show entries for this skill"),
677
+ _arg("--limit", type=int, default=20, help="Max entries to show (default: 20)")),
678
+ (
679
+ "purge",
680
+ "Delete archived skills older than curator.archive_ttl_days "
681
+ "(explicit only — never automatic; recorded in the ledger)",
682
+ _cmd_purge,
683
+ _arg("--days", type=int, default=None,
684
+ help="Override curator.archive_ttl_days for this invocation"),
685
+ _arg("--dry-run", dest="dry_run", **_STORE_TRUE,
686
+ help="Show what would be purged without deleting"),
687
+ _YES))
688
+
689
+
690
+ def register_cli(parent: argparse.ArgumentParser) -> None:
691
+ """Attach `curator` subcommands to *parent*."""
692
+ parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1])
693
+ subs = parent.add_subparsers(dest="curator_command")
694
+ for name, help_text, handler, *arguments in _SUBCOMMANDS:
695
+ sub = subs.add_parser(name, help=help_text)
696
+ for flags, arg_kwargs in arguments:
697
+ sub.add_argument(*flags, **arg_kwargs)
698
+ sub.set_defaults(func=handler)
699
+
700
+
701
+ def cli_main(argv=None) -> int:
702
+ """Standalone entry (also usable by hermes_cli.main fallthrough)."""
703
+ parser = argparse.ArgumentParser(prog="hermes curator")
704
+ register_cli(parser)
705
+ args = parser.parse_args(argv)
706
+ return int(args.func(args) or 0)
707
+
708
+
709
+ if __name__ == "__main__": # pragma: no cover
710
+ sys.exit(cli_main())
hermes_cli/desktop_update_verify.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read-only verification at the Windows Desktop handoff receipt boundary."""
2
+ import json
3
+ from pathlib import Path, PurePosixPath
4
+ import re
5
+ import struct
6
+
7
+ from hermes_cli.main_desktop import (
8
+ _HTML_TAG_WITH_URL,
9
+ _MODULE_TAG,
10
+ _desktop_build_needed,
11
+ _desktop_exe_integrity_error,
12
+ _desktop_packaged_executable,
13
+ )
14
+
15
+
16
+ def _verify_packaged_entry(resources: Path) -> None:
17
+ """Read ASAR's Pickle header and the entry declared by packaged package.json.
18
+
19
+ dist/** is unpacked by electron-builder. No Node install or application
20
+ launch is needed at this boundary; this is not a full dependency audit.
21
+ """
22
+ archive = resources / "app.asar"
23
+ try:
24
+ with archive.open("rb") as stream:
25
+ size, header_size, payload_size, json_size = struct.unpack("<4I", stream.read(16))
26
+ if (size != 4 or header_size != payload_size + 4
27
+ or payload_size != 4 + ((json_size + 3) // 4) * 4
28
+ or not 0 < json_size <= 64 * 1024 * 1024
29
+ or 8 + header_size > archive.stat().st_size):
30
+ raise ValueError("invalid ASAR header")
31
+ header = json.loads(stream.read(json_size))
32
+
33
+ def read_member(name: str) -> bytes:
34
+ path = PurePosixPath(name)
35
+ if not name or path.is_absolute() or ".." in path.parts or "\\" in name or ":" in name:
36
+ raise ValueError("invalid ASAR entry path")
37
+ node = header
38
+ for part in path.parts:
39
+ node = node["files"][part]
40
+ length = node["size"]
41
+ if not isinstance(length, int) or length <= 0:
42
+ raise ValueError(f"empty ASAR entry: {name}")
43
+ if node.get("unpacked"):
44
+ data = (resources / "app.asar.unpacked" / path).read_bytes()
45
+ else:
46
+ offset = int(node["offset"])
47
+ if offset < 0 or 8 + header_size + offset + length > archive.stat().st_size:
48
+ raise ValueError(f"truncated ASAR entry: {name}")
49
+ stream.seek(8 + header_size + offset)
50
+ data = stream.read(length)
51
+ if len(data) != length or not data.strip():
52
+ raise ValueError(f"incomplete ASAR entry: {name}")
53
+ return data
54
+
55
+ package = json.loads(read_member("package.json"))
56
+ read_member(package["main"]).decode("utf-8")
57
+ except (OSError, ValueError, KeyError, TypeError, struct.error) as exc:
58
+ raise RuntimeError(f"The updated Desktop archive or main entry is invalid: {exc}") from exc
59
+
60
+ index = resources / "app.asar.unpacked" / "dist" / "index.html"
61
+ try:
62
+ html = index.read_text(encoding="utf-8")
63
+ if not any(_MODULE_TAG.search(match.group(0))
64
+ and match.group(0).lower().startswith("<script")
65
+ and not re.match(r"^[a-z]+:|^//", match.group(1), re.IGNORECASE)
66
+ for match in _HTML_TAG_WITH_URL.finditer(html)):
67
+ raise ValueError("renderer has no local module entry")
68
+ except (OSError, ValueError) as exc:
69
+ raise RuntimeError(f"The updated Desktop renderer entry is invalid: {exc}") from exc
70
+
71
+
72
+ def checkout_root() -> Path:
73
+ """The checkout this module was imported from: the only root the receipt may describe."""
74
+ return Path(__file__).resolve().parent.parent
75
+
76
+
77
+ def verify_windows_desktop_update(project_root: Path | None = None) -> None:
78
+ """Raise when a zero-exit updater left an incomplete or stale packaged app.
79
+
80
+ The root defaults to the imported checkout, never the caller's cwd: the hand-off
81
+ is spawned from HERMES_HOME by the pre-update Desktop, and a cwd-derived root
82
+ reported a healthy install as "Desktop executable is missing" (Sep 2026).
83
+ """
84
+ if project_root is None:
85
+ project_root = checkout_root()
86
+ desktop = project_root / "apps" / "desktop"
87
+ executable = _desktop_packaged_executable(desktop)
88
+ if executable is None:
89
+ raise RuntimeError("The updated Desktop executable is missing")
90
+ error = _desktop_exe_integrity_error(executable)
91
+ if error:
92
+ raise RuntimeError(f"The updated Desktop executable is invalid: {error}")
93
+ _verify_packaged_entry(executable.parent / "resources")
94
+ if _desktop_build_needed(desktop, project_root, source_mode=False):
95
+ raise RuntimeError("The updated Desktop build is stale, unstamped, or incomplete")
hermes_cli/focus_view.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Focus view — display-only reduced output: "just my prompt and the answer, and tell me what you hid".
2
+ ON snaps ``tool_progress_mode`` to ``"off"`` and remembers the configured mode so the *existing* suppression
3
+ path does the hiding; OFF restores it verbatim. Focus adds a per-turn hidden-line count with a recovery
4
+ hint and a persistent ``focus`` status-bar segment."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+ FOCUS_CONFIG_KEY = "display.focus_view" # plain boolean under ``display``, like /battery /timestamps /footer
11
+ FOCUS_TOOL_PROGRESS_MODE = "off" # the SAME value ``/verbose off`` uses so both features share one suppression path
12
+ # Modes in which the CLI commits a per-tool scrollback line. Mirrors the gate in
13
+ # ``HermesCLI._on_tool_progress`` so the hidden-line counter and the renderer never drift apart.
14
+ TOOL_PROGRESS_VISIBLE_MODES = frozenset({"new", "all", "verbose"})
15
+ TOOL_PROGRESS_MODES = ("off", "new", "all", "verbose") # ``log`` is a gateway-only extra step
16
+ FOCUS_STATUSBAR_LABEL = "◉ focus" # short on purpose — the bar is width-constrained
17
+
18
+ # /focus argument words -> (action, target); bare /focus toggles like /footer, /battery, /timestamps.
19
+ _FOCUS_WORDS = {
20
+ **dict.fromkeys(("status", "show", "?"), ("status", None)),
21
+ **dict.fromkeys(("on", "enable", "enabled", "true", "yes", "1"), ("set", True)),
22
+ **dict.fromkeys(("off", "disable", "disabled", "false", "no", "0"), ("set", False)),
23
+ }
24
+
25
+
26
+ def normalize_tool_progress_mode(mode: object, default: str = "all") -> str:
27
+ """Coerce a raw config/attr value into a known tool-progress mode."""
28
+ if mode is False:
29
+ return "off"
30
+ if mode is True:
31
+ return "all"
32
+ text = str(mode or "").strip().lower()
33
+ # ``log`` is a real gateway mode; any other unknown value becomes the default.
34
+ return text if text in TOOL_PROGRESS_MODES or text == "log" else default
35
+
36
+
37
+ def resolve_focus_arg(arg: str, current: bool) -> tuple[str, Optional[bool]]:
38
+ """Map a ``/focus`` argument onto ``(action, target)``: action is ``"set"``, ``"status"`` or ``"usage"``;
39
+ target is the requested enabled-state for ``"set"`` and ``None`` otherwise."""
40
+ text = str(arg or "").strip().lower()
41
+ if text in ("", "toggle"):
42
+ return "set", not bool(current)
43
+ return _FOCUS_WORDS.get(text, ("usage", None))
44
+
45
+
46
+ def would_display_tool_line(mode: object, function_name: str, last_tool_name: Optional[str] = None) -> bool:
47
+ """Would the CLI have committed a scrollback line for this tool call? Counts honestly: with ``/verbose off``
48
+ focus view hides nothing extra and must not claim otherwise. ``new`` mode skips consecutive repeats of the
49
+ same tool, so the counter does too."""
50
+ if not function_name:
51
+ return False
52
+ normalized = normalize_tool_progress_mode(mode)
53
+ return normalized in TOOL_PROGRESS_VISIBLE_MODES and not (normalized == "new" and function_name == last_tool_name)
54
+
55
+
56
+ def format_hidden_line(count: int) -> Optional[str]:
57
+ """Dim post-turn recovery line, or ``None`` when nothing was hidden."""
58
+ try:
59
+ n = int(count)
60
+ except (TypeError, ValueError):
61
+ return None
62
+ return f"⋯ {n} {'tool line' if n == 1 else 'tool lines'} hidden · /focus off to show" if n > 0 else None
63
+
64
+
65
+ def focus_statusbar_segment(enabled: bool) -> str:
66
+ """Status-bar segment text for focus view (empty when off)."""
67
+ return FOCUS_STATUSBAR_LABEL if enabled else ""
68
+
69
+
70
+ def format_focus_status(enabled: bool, configured_mode: object) -> str:
71
+ """Human-readable ``/focus status`` body (no ANSI — callers colour it)."""
72
+ mode = normalize_tool_progress_mode(configured_mode).upper()
73
+ if enabled:
74
+ return f"Focus view: ON — only your prompt and the final response.\n /focus off restores tool progress: {mode}"
75
+ return f"Focus view: OFF — tool progress: {mode}"
76
+
77
+
78
+ def format_focus_toggle_message(enabled: bool, configured_mode: object) -> str:
79
+ """Confirmation line printed when focus view is switched (no ANSI)."""
80
+ if enabled:
81
+ return "Focus view enabled — just your prompt and the final response"
82
+ return f"Focus view disabled — tool progress: {normalize_tool_progress_mode(configured_mode).upper()}"
83
+
84
+
85
+ # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
86
+ # Names external plugins imported from this module before the Sep 2026 decomposition.
87
+ # Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
88
+ # The whole block is removed by reverting the commit that added it.
89
+
90
+ FOCUS_USAGE = "Usage: /focus [on|off|status]"
91
+
92
+ def effective_tool_progress_mode(focus_enabled: bool, configured_mode: object) -> str:
93
+ """Return the tool-progress mode that should actually be in force.
94
+
95
+ Focus view wins while it is on (it *is* "tool progress off" plus reporting).
96
+ When focus is off the user's configured mode is returned untouched — this is
97
+ what makes ``/focus off`` restore ``/verbose verbose`` rather than clobbering
98
+ it to ``all``.
99
+ """
100
+ normalized = normalize_tool_progress_mode(configured_mode)
101
+ if focus_enabled:
102
+ return FOCUS_TOOL_PROGRESS_MODE
103
+ return normalized
104
+ # ---- END PLUGIN-COMPAT ----
hermes_cli/goal_command.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The CLI's /goal semantics, shared by every goal command surface.
2
+
3
+ Adapters supply authorization and schedule the returned prompt; this module alone
4
+ parses subcommands and mutates goal state. It never changes conversation history.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ import logging
10
+ from typing import Callable
11
+
12
+ from hermes_cli import goals
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class GoalCommandResult:
19
+ output: str
20
+ prompt: str | None = None
21
+ kickoff: bool = False
22
+ clear_pending: str | None = None
23
+ error: bool = False
24
+
25
+
26
+ def _english(key, default, **values):
27
+ return default.format(**values)
28
+
29
+
30
+ def _status(mgr, arg, render):
31
+ return GoalCommandResult(mgr.status_line())
32
+
33
+
34
+ def _show(mgr, arg, render):
35
+ return GoalCommandResult(f"{mgr.status_line()}\n{mgr.render_contract()}")
36
+
37
+
38
+ def _pause(mgr, arg, render):
39
+ state = mgr.pause(reason="user-paused")
40
+ return GoalCommandResult(
41
+ render("gateway.goal.paused", "⏸ Goal paused: {goal}", goal=state.goal) if state
42
+ else render("gateway.goal.no_goal_set", "No goal set."),
43
+ clear_pending="pause" if state else None,
44
+ )
45
+
46
+
47
+ def _resume(mgr, arg, render):
48
+ state = mgr.resume()
49
+ if state is None:
50
+ return GoalCommandResult(render("gateway.goal.no_resume", "No goal to resume."))
51
+ return GoalCommandResult(render("gateway.goal.resumed", "▶ Goal resumed: {goal}", goal=state.goal),
52
+ prompt=mgr.next_continuation_prompt())
53
+
54
+
55
+ def _clear(mgr, arg, render):
56
+ had = mgr.has_goal()
57
+ mgr.clear()
58
+ return GoalCommandResult(render("gateway.goal_cleared", "✓ Goal cleared.") if had else
59
+ render("gateway.no_active_goal", "No active goal."), clear_pending="clear")
60
+
61
+
62
+ def _unwait(mgr, arg, render):
63
+ return GoalCommandResult("▶ Wait barrier cleared — goal loop resumes." if mgr.stop_waiting()
64
+ else "No wait barrier set.")
65
+
66
+
67
+ def _wait(mgr, arg):
68
+ if not arg:
69
+ return GoalCommandResult("Usage: /goal wait <pid> [reason]", error=True)
70
+ tokens = arg.split(None, 1)
71
+ try:
72
+ pid = int(tokens[0])
73
+ except ValueError:
74
+ return GoalCommandResult("/goal wait: <pid> must be an integer process id.", error=True)
75
+ reason = tokens[1].strip() if len(tokens) > 1 else ""
76
+ mgr.wait_on(pid, reason=reason)
77
+ suffix = f" ({reason})" if reason else ""
78
+ return GoalCommandResult(f"⏳ Goal parked on pid {pid}{suffix}. Loop pauses until it exits.")
79
+
80
+
81
+ def _gate_add(mgr, arg):
82
+ gate = mgr.add_gate(arg)
83
+ return GoalCommandResult(f"⚿ Gate added: $ {gate.command} "
84
+ f"({gate.max_retries} retries, {gate.timeout_seconds}s timeout). "
85
+ "It must pass before the goal can complete.")
86
+
87
+
88
+ def _gate_remove(mgr, arg):
89
+ return GoalCommandResult(f"✓ Gate removed: $ {mgr.remove_gate(int(arg))}")
90
+
91
+
92
+ def _gate_clear(mgr, arg):
93
+ count = mgr.clear_gates()
94
+ return GoalCommandResult(f"✓ Cleared {count} gate{'s' if count != 1 else ''}.")
95
+
96
+
97
+ _GATE_HANDLERS = {"add": _gate_add, "remove": _gate_remove, "rm": _gate_remove, "clear": _gate_clear}
98
+ _EXACT_HANDLERS = {
99
+ "": _status, "status": _status, "show": _show, "pause": _pause,
100
+ "resume": _resume, "clear": _clear, "stop": _clear, "done": _clear, "unwait": _unwait,
101
+ }
102
+
103
+
104
+ def _gate(mgr, arg, authorize_gate):
105
+ if not arg or arg.lower() == "list":
106
+ return GoalCommandResult(mgr.render_gates())
107
+ tokens = arg.split(None, 1)
108
+ verb, rest = tokens[0].lower(), tokens[1].strip() if len(tokens) > 1 else ""
109
+ handler = _GATE_HANDLERS.get(verb)
110
+ if handler is None or (verb == "clear" and rest) or (verb != "clear" and not rest):
111
+ return GoalCommandResult("Usage: /goal gate [list | add <command> | remove <N> | clear]", error=True)
112
+ # Gates run shell commands without a later approval. The adapter must explicitly
113
+ # authorize creation; recovery commands remain available to non-admin senders.
114
+ if verb == "add" and (denial := authorize_gate()):
115
+ return GoalCommandResult(denial, error=True)
116
+ try:
117
+ return handler(mgr, rest)
118
+ except (RuntimeError, ValueError, IndexError) as exc:
119
+ operation = "remove" if verb == "rm" else verb
120
+ return GoalCommandResult(f"/goal gate {operation}: {exc}", error=True)
121
+
122
+
123
+ def _set(mgr, arg, *, drafting, last_user_message, render, progress):
124
+ if drafting:
125
+ if not arg:
126
+ return GoalCommandResult("Usage: /goal draft <objective in plain language>", error=True)
127
+ if progress is not None:
128
+ progress("Drafting completion contract…")
129
+ try:
130
+ contract = goals.draft_contract(arg)
131
+ except Exception as exc:
132
+ logger.debug("goal draft failed: %s", exc)
133
+ contract = None
134
+ headline = arg
135
+ else:
136
+ headline, contract = goals.parse_contract(arg)
137
+ contract = contract if not contract.is_empty() else None
138
+ state = mgr.set(headline or arg, contract=contract)
139
+ output = render("gateway.goal.set", "⊙ Goal set ({budget}-turn budget): {goal}",
140
+ budget=state.max_turns, goal=state.goal)
141
+ if state.has_contract():
142
+ label = "Drafted completion contract:" if drafting else "Completion contract:"
143
+ output += f"\n{label}\n{state.contract.render_block()}"
144
+ if drafting:
145
+ output += ("\nTighten any field by re-setting the goal with inline lines "
146
+ "(e.g. verify: <command>), then /goal resume. Use /goal show to review."
147
+ if state.has_contract() else
148
+ "\nCouldn't draft a contract (aux model unavailable) — running as a "
149
+ "free-form goal. The per-turn judge still applies.")
150
+ else:
151
+ against = " against the contract above" if state.has_contract() else ""
152
+ output += (f"\nAfter each turn, a judge model checks if the goal is done{against}. "
153
+ "Hermes keeps working until it is, you pause/clear it, or the budget is "
154
+ "exhausted. Use /goal status, /goal show, /goal pause, /goal resume, /goal clear.")
155
+ return GoalCommandResult(output, goals.goal_kick_prompt(state.goal, last_user_message), kickoff=True)
156
+
157
+
158
+ def is_goal_control(arg: str) -> bool:
159
+ """Whether this command controls an existing goal rather than replacing it."""
160
+ normalized = arg.strip().lower()
161
+ return normalized in _EXACT_HANDLERS or normalized.split(None, 1)[0] in {"wait", "gate"}
162
+
163
+
164
+ def dispatch_goal_command(
165
+ mgr: goals.GoalManager, arg: str, *, authorize_gate: Callable[[], str | None],
166
+ last_user_message=None, render: Callable = _english,
167
+ progress: Callable[[str], None] | None = None,
168
+ ) -> GoalCommandResult:
169
+ """Apply one command. ``authorize_gate`` returns a denial or None (explicit approval).
170
+
171
+ Synchronous like GoalManager: async adapters run this off-loop with copied
172
+ ContextVars so draft credentials and persisted I/O stay in the caller's profile.
173
+ """
174
+ arg = arg.strip()
175
+ tokens = arg.split(None, 1)
176
+ verb = tokens[0].lower() if tokens else ""
177
+ rest = tokens[1].strip() if len(tokens) > 1 else ""
178
+ prefix = "Invalid goal"
179
+ try:
180
+ if handler := _EXACT_HANDLERS.get(arg.lower()):
181
+ return handler(mgr, "", render)
182
+ if verb == "wait":
183
+ prefix = "/goal wait"
184
+ return _wait(mgr, rest)
185
+ if verb == "gate":
186
+ prefix = "/goal gate"
187
+ return _gate(mgr, rest, authorize_gate)
188
+ return _set(mgr, rest if verb == "draft" else arg,
189
+ drafting=verb == "draft", last_user_message=last_user_message,
190
+ render=render, progress=progress)
191
+ except (RuntimeError, ValueError, IndexError) as exc:
192
+ output = (render("gateway.goal.invalid", "Invalid goal: {error}", error=str(exc))
193
+ if prefix == "Invalid goal" else f"{prefix}: {exc}")
194
+ return GoalCommandResult(output, error=True)
hermes_cli/inventory.py ADDED
@@ -0,0 +1,749 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider/model inventory — shared substrate for the dashboard ``/api/model/options``, the TUI
2
+ ``model.options``/``model.save_key`` RPC handlers, and the interactive picker."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from contextvars import copy_context
7
+ from dataclasses import dataclass, replace
8
+ from threading import Lock, Thread, current_thread
9
+ from typing import Any, Optional
10
+
11
+ _pricing_prewarm_lock = Lock()
12
+ _pricing_prewarm_threads: dict[tuple[str, tuple[tuple[str, str], ...]], Thread] = {}
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ConfigContext:
17
+ """Disk-config snapshot (``load_picker_context()``); the TUI overlays live agent state via
18
+ ``with_overrides()``."""
19
+
20
+ current_provider: str
21
+ current_model: str
22
+ current_base_url: str
23
+ user_providers: dict
24
+ custom_providers: list
25
+ excluded_providers: list = None
26
+
27
+ def with_overrides(
28
+ self, *, current_provider: Optional[str] = None, current_model: Optional[str] = None,
29
+ current_base_url: Optional[str] = None,
30
+ ) -> "ConfigContext":
31
+ """Copy with TRUTHY overrides applied: the TUI reads agent attributes that may be empty strings
32
+ before an agent is spawned — empties must not clobber the disk-config values."""
33
+ overrides = (("current_provider", current_provider), ("current_model", current_model),
34
+ ("current_base_url", current_base_url))
35
+ kw = {k: v for k, v in overrides if v}
36
+ return replace(self, **kw) if kw else self
37
+
38
+
39
+ def load_picker_context() -> ConfigContext:
40
+ """Load the disk-config snapshot every consumer needs."""
41
+ from hermes_cli.config import (
42
+ coerce_provider_id, get_compatible_custom_providers, load_config, stringify_provider_map,
43
+ )
44
+ cfg = load_config()
45
+ model_cfg = cfg.get("model", {})
46
+ if isinstance(model_cfg, dict):
47
+ # PyYAML parses unquoted scalars as int (`provider: 2070`); keep strings so picker/options
48
+ # paths never call `.strip()` on an int.
49
+ current_model = str(model_cfg.get("default", model_cfg.get("name", "")) or "")
50
+ current_provider = coerce_provider_id(model_cfg.get("provider", ""))
51
+ current_base_url = str(model_cfg.get("base_url", "") or "")
52
+ else: # config.model can be a bare string in older configs
53
+ current_model, current_provider, current_base_url = (str(model_cfg) if model_cfg else ""), "", ""
54
+ excluded = cfg.get("model_catalog", {}).get("excluded_providers") or []
55
+ return ConfigContext(
56
+ current_provider=current_provider, current_model=current_model, current_base_url=current_base_url,
57
+ user_providers=stringify_provider_map(cfg.get("providers")),
58
+ custom_providers=get_compatible_custom_providers(cfg),
59
+ excluded_providers=excluded if isinstance(excluded, list) else [],
60
+ )
61
+
62
+
63
+ def _slug(row: dict) -> str:
64
+ return str(row.get("slug") or "").strip().lower()
65
+
66
+
67
+ def _without_slug(rows: list[dict], slug: str) -> list[dict]:
68
+ return [r for r in rows if _slug(r) != slug]
69
+
70
+
71
+ # ─── Public: payload builder ────────────────────────────────────────────
72
+
73
+
74
+ def build_models_payload(
75
+ ctx: ConfigContext, *, explicit_only: bool = False, include_unconfigured: bool = False,
76
+ picker_hints: bool = False, canonical_order: bool = False, pricing: bool = False,
77
+ pricing_cache_only: bool = False,
78
+ capabilities: bool = False, featured: bool = False, force_fresh_nous_tier: bool = False,
79
+ refresh: bool = False, probe_custom_providers: bool = True, probe_current_custom_provider: bool = False,
80
+ for_picker: bool = False, max_models: int | None = None,
81
+ ) -> dict:
82
+ """Build the ``{providers, model, provider}`` shape every consumer needs. ``explicit_only`` keeps
83
+ only providers the user explicitly configured — hides ambient/auto-seeded credentials from
84
+ desktop chat pickers. ``pricing_cache_only``: with ``pricing``, use only values already resident
85
+ in process caches (normal picker opens, while a background worker warms cold endpoints)."""
86
+ from hermes_cli.model_switch import list_authenticated_providers
87
+
88
+ rows = list_authenticated_providers(
89
+ current_provider=ctx.current_provider, current_base_url=ctx.current_base_url,
90
+ current_model=ctx.current_model, user_providers=ctx.user_providers,
91
+ custom_providers=ctx.custom_providers, force_fresh_nous_tier=force_fresh_nous_tier,
92
+ max_models=max_models, refresh=refresh, probe_custom_providers=probe_custom_providers,
93
+ probe_current_custom_provider=probe_current_custom_provider, for_picker=for_picker,
94
+ excluded_providers=ctx.excluded_providers or [],
95
+ )
96
+
97
+ # Managed local runtime: staged GGUFs are selectable like any provider's models, but
98
+ # list_authenticated_providers can't know about them (no credential — reachability is the
99
+ # credential), so inject the row here where every picker surface inherits it.
100
+ local_row = _local_runtime_row(ctx)
101
+ if local_row is not None:
102
+ rows = _without_slug(rows, "llamacpp") + [local_row]
103
+ # A live session on the managed server reports provider "custom" (raw base_url label), which
104
+ # would materialize a duplicate "Custom endpoint" row with the same staged models stealing the
105
+ # checkmark. The Local row owns the managed server's identity — drop such custom rows.
106
+ if local_row.get("is_current"):
107
+ staged = set(local_row["models"])
108
+
109
+ def _is_managed_custom(row: dict) -> bool:
110
+ models = {str(m) for m in (row.get("models") or [])}
111
+ return _slug(row) == "custom" and bool(models) and models <= staged
112
+
113
+ rows = [r for r in rows if not _is_managed_custom(r)]
114
+
115
+ moa_row = _moa_provider_row(ctx.current_provider)
116
+ if moa_row is not None:
117
+ rows = [moa_row] + _without_slug(rows, "moa")
118
+
119
+ if explicit_only:
120
+ rows = _filter_explicit_provider_rows(rows, ctx)
121
+ # If the current provider lost its credential, list_authenticated_providers() omits it; keep
122
+ # that one row so the UI shows the saved selection + a re-auth affordance instead of appearing
123
+ # to jump providers. Exception: a "custom" current on the managed local server is already
124
+ # represented by the Local row — the skeleton would resurrect the duplicate removed above.
125
+ _local_owns_current = bool(local_row and local_row.get("is_current")
126
+ and (ctx.current_provider or "").lower() == "custom")
127
+ if not _local_owns_current:
128
+ rows = list(rows) + _append_unconfigured_rows(rows, ctx, current_only=True)
129
+
130
+ # A local proxy serving a model also in an aggregator's catalog would show under both, and picking
131
+ # the aggregator row silently breaks the call — aggregators only list models no specific provider has.
132
+ _strip_aggregator_overlaps(rows)
133
+
134
+ if include_unconfigured:
135
+ rows = list(rows) + _without_slug(_append_unconfigured_rows(rows, ctx), "moa")
136
+ if picker_hints:
137
+ _apply_picker_hints(rows)
138
+ if canonical_order:
139
+ rows = _reorder_canonical(rows)
140
+ if pricing:
141
+ _apply_pricing(rows, force_fresh_nous_tier=force_fresh_nous_tier, cached_only=pricing_cache_only)
142
+ if capabilities:
143
+ _apply_capabilities(rows)
144
+ if featured:
145
+ _apply_featured(rows)
146
+ _apply_custom_aliases(rows)
147
+
148
+ return {"providers": rows, "model": ctx.current_model, "provider": ctx.current_provider}
149
+
150
+
151
+ def _strip_aggregator_overlaps(rows: list[dict]) -> None:
152
+ """Drop models from TRUE routing aggregators (OpenRouter, custom:* proxies) that a user-defined
153
+ provider also serves. The is_user_defined guard matters: is_routing_aggregator() is True for every
154
+ custom:* slug, so without it the dedup would empty a user's own custom row. Flat-namespace
155
+ resellers (opencode-go/zen) serve every model first-party and keep shared names."""
156
+ try:
157
+ from hermes_cli.providers import is_routing_aggregator
158
+ except Exception:
159
+ return
160
+
161
+ user_models: set[str] = set()
162
+ for row in rows:
163
+ if row.get("is_user_defined"):
164
+ user_models.update(m.lower() for m in (row.get("models") or []))
165
+ if not user_models:
166
+ return
167
+ for row in rows:
168
+ if row.get("is_user_defined") or not is_routing_aggregator(row.get("slug", "")):
169
+ continue
170
+ # Only strip overlaps from TRUE routing aggregators (OpenRouter, custom:* proxies). Flat-namespace
171
+ # resellers (opencode-go / opencode-zen) serve every listed model as a first-party model, so their
172
+ # rows must keep models that a user's proxy happens to share a name with — otherwise a subscription
173
+ # provider's own catalog (minimax-m3, glm-5, deepseek-v4-flash, ...) is silently gutted in the
174
+ # picker. (#47077)
175
+ original = row.get("models") or []
176
+ filtered = [m for m in original if m.lower() not in user_models]
177
+ if len(filtered) < len(original):
178
+ row["models"] = filtered
179
+ row["total_models"] = len(filtered)
180
+
181
+
182
+ def build_model_options_payload(
183
+ ctx: ConfigContext, *, explicit_only: bool = False, include_unconfigured: bool = False,
184
+ refresh: bool = False,
185
+ ) -> dict:
186
+ """Shared API-server/dashboard/TUI payload. Normal open probes only the current custom provider so
187
+ offline saved endpoints don't block the picker; explicit refresh probes all and busts the cache."""
188
+ refresh = bool(refresh)
189
+ payload = build_models_payload(
190
+ ctx, explicit_only=bool(explicit_only), include_unconfigured=bool(include_unconfigured),
191
+ picker_hints=True, canonical_order=True, pricing=True, pricing_cache_only=not refresh,
192
+ capabilities=True, featured=True,
193
+ refresh=refresh, probe_custom_providers=refresh, probe_current_custom_provider=not refresh,
194
+ )
195
+ if not refresh:
196
+ _prewarm_pricing_async(payload["providers"], current_provider=ctx.current_provider,
197
+ current_base_url=ctx.current_base_url)
198
+ return payload
199
+
200
+
201
+ # ─── Public: auxiliary-task pickers ─────────────────────────────────────
202
+
203
+
204
+ def build_aux_picker_rows(
205
+ *, current_provider: str = "", current_model: str = "", current_base_url: str = "",
206
+ max_models: int | None = None,
207
+ ) -> list[dict]:
208
+ """Provider rows for any auxiliary-task picker (vision, compression, …). Honours
209
+ ``excluded_providers``; exhausted-pool providers stay visible (``for_picker``); only the active
210
+ custom endpoint is probed. ``moa`` is excluded: auxiliary_client unwraps it to its aggregator
211
+ anyway, so offering it would be a choice silently rewritten.
212
+
213
+ Aux pickers kept re-deriving their own kwargs and each one silently dropped a different slice of the
214
+ user's configuration. Two independent contributor PRs landed against the same two call sites for exactly
215
+ this: 52642 (user ``providers:`` / ``custom_providers:`` entries never appeared) and #66624 (providers
216
+ with an exhausted credential pool were hidden). Both were per-site kwarg patches, so the next aux picker
217
+ would have reintroduced the same gap. Routing through one function makes the correct behaviour the
218
+ default that a new caller cannot forget:
219
+ """
220
+ ctx = load_picker_context().with_overrides(
221
+ current_provider=current_provider, current_model=current_model, current_base_url=current_base_url,
222
+ )
223
+ rows = build_models_payload(
224
+ ctx, for_picker=True, probe_custom_providers=False, probe_current_custom_provider=True,
225
+ max_models=max_models,
226
+ )["providers"]
227
+ return _without_slug(rows, "moa")
228
+
229
+
230
+ def format_aux_picker_entries(
231
+ rows: list[dict], *, current_provider: str = "", current_base_url: str = "",
232
+ ) -> list[tuple[str, str, list[str]]]:
233
+ """Render aux-picker rows as ``(slug, label, models)``. A raw ``base_url`` custom endpoint is
234
+ "current" only through that URL, never a slug — so with ``current_base_url`` set no row is marked."""
235
+ entries: list[tuple[str, str, list[str]]] = []
236
+ current_slug = str(current_provider or "").strip().lower()
237
+ has_base_url = bool(str(current_base_url or "").strip())
238
+ for row in rows:
239
+ slug = str(row.get("slug") or "")
240
+ name = row.get("name") or slug
241
+ total = row.get("total_models") or len(row.get("models") or [])
242
+ model_hint = f" — {total} models" if total else ""
243
+ marker = " ← current" if slug.lower() == current_slug and current_slug and not has_base_url else ""
244
+ entries.append((slug, f"{name}{model_hint}{marker}", list(row.get("models") or [])))
245
+ return entries
246
+
247
+
248
+ def _reasoning_catalog_reader(slug: str):
249
+ """Per-model reasoning-capability reader for aggregators that publish one. Cache-only — the picker
250
+ must never block on HTTP; a cold cache warms in the background and reports no restriction until then."""
251
+ try:
252
+ from hermes_cli.models_reasoning_caps import (
253
+ nous_model_reasoning_capabilities,
254
+ openrouter_model_reasoning_capabilities,
255
+ warm_nous_reasoning_caps_async,
256
+ warm_openrouter_reasoning_caps_async,
257
+ )
258
+ except Exception:
259
+ return None
260
+
261
+ readers = {
262
+ "nous": (warm_nous_reasoning_caps_async, nous_model_reasoning_capabilities),
263
+ "openrouter": (warm_openrouter_reasoning_caps_async, openrouter_model_reasoning_capabilities),
264
+ }
265
+ if slug not in readers:
266
+ return None
267
+ warm, read = readers[slug]
268
+ warm()
269
+ return read
270
+
271
+
272
+ def _apply_capabilities(rows: list[dict]) -> None:
273
+ """Attach ``{model: {fast, reasoning, ...}}`` per row. ``reasoning`` defaults True when the catalog is
274
+ silent (the dial is a no-op on models that ignore it; hiding it from a capable model is worse). A
275
+ serving aggregator's detail overrides models.dev (adds ``can_disable_reasoning``). ``supported_efforts``
276
+ is deliberately NOT forwarded — it under-reports levels that work."""
277
+ from hermes_cli.models import model_supports_fast_mode
278
+
279
+ try:
280
+ from agent.models_dev import get_model_capabilities
281
+ except Exception:
282
+ get_model_capabilities = None # type: ignore[assignment]
283
+
284
+ for row in rows:
285
+ slug = row.get("slug") or ""
286
+ caps: dict[str, dict[str, Any]] = {}
287
+ read_reasoning_catalog = _reasoning_catalog_reader(slug.lower())
288
+
289
+ for model in row.get("models") or []:
290
+ reasoning = True
291
+ if get_model_capabilities is not None and slug:
292
+ try:
293
+ meta = get_model_capabilities(slug, model)
294
+ if meta is not None:
295
+ reasoning = bool(meta.supports_reasoning)
296
+ except Exception:
297
+ reasoning = True
298
+
299
+ entry: dict[str, Any] = {"fast": bool(model_supports_fast_mode(model)), "reasoning": reasoning}
300
+
301
+ if reasoning and read_reasoning_catalog is not None:
302
+ try:
303
+ detail = read_reasoning_catalog(model)
304
+ except Exception:
305
+ detail = None
306
+ if detail and not detail.get("supports_reasoning"):
307
+ # Aggregator catalog beats models.dev for a route it serves: no reasoning param
308
+ # means no reasoning controls, so no disable to describe either.
309
+ entry["reasoning"] = False
310
+ elif detail:
311
+ entry["can_disable_reasoning"] = not detail.get("mandatory")
312
+
313
+ caps[model] = entry
314
+
315
+ row["capabilities"] = caps
316
+
317
+
318
+ # Newest N models per lab an aggregator row features by default (older tail behind search/show-all);
319
+ # 5 keeps a lab's headliners without letting a prolific vendor flood the view.
320
+ _FEATURED_PER_LAB = 5
321
+
322
+
323
+ def _apply_featured(rows: list[dict]) -> None:
324
+ """Attach a ``featured_models`` shortlist to each aggregator row: newest ``_FEATURED_PER_LAB`` per
325
+ vendor by models.dev ``release_date`` (ranked within the row, never vs. today, so it is stable);
326
+ ties keep curated order. Non-aggregators get an empty list and keep top-N behaviour."""
327
+ try:
328
+ from agent.models_dev import get_model_info
329
+ except Exception:
330
+ get_model_info = None # type: ignore[assignment]
331
+
332
+ for row in rows:
333
+ slug = str(row.get("slug") or "").strip().lower()
334
+ models = row.get("models") or []
335
+
336
+ by_lab: dict[str, list[tuple[int, str, str]]] = {} # only multi-lab aggregators get a shortlist
337
+ for pos, model in enumerate(models):
338
+ lab = model.split("/", 1)[0] if "/" in model else ""
339
+ if not lab: # no vendor prefix → single-namespace provider, not an aggregator
340
+ by_lab = {}
341
+ break
342
+ date = ""
343
+ if get_model_info is not None:
344
+ info = get_model_info(slug, model) or get_model_info("openrouter", model)
345
+ date = getattr(info, "release_date", "") if info else ""
346
+ by_lab.setdefault(lab, []).append((pos, date, model))
347
+
348
+ if len(by_lab) < 2:
349
+ row["featured_models"] = []
350
+ continue
351
+
352
+ featured: list[str] = []
353
+ for entries in by_lab.values():
354
+ # Newest release_date first; earlier list position breaks ties (sole key when undated).
355
+ ranked = sorted(entries, key=lambda e: (e[1], -e[0]), reverse=True)
356
+ featured.extend(model for _pos, _date, model in ranked[:_FEATURED_PER_LAB])
357
+ order = {m: i for i, m in enumerate(models)} # keep the row's model order for stable rendering
358
+ row["featured_models"] = sorted(featured, key=lambda m: order[m])
359
+
360
+
361
+ def _apply_custom_aliases(rows: list[dict]) -> None:
362
+ """Attach the accepted identity set to each user-defined row: ``model.options`` reports the canonical
363
+ ``custom:<key>`` while rows carry the bare key as ``slug``, so GUI exact-match never finds the row.
364
+
365
+ GUI pickers compare the two to decide which row is active; exact equality never matches for custom
366
+ providers (#87035). Exposing ``aliases`` — every current and legacy spelling from
367
+ :func:`hermes_cli.providers.custom_provider_aliases` — lets the frontend do a membership check instead.
368
+ """
369
+ from hermes_cli.providers import custom_provider_aliases
370
+
371
+ for row in rows:
372
+ if not row.get("is_user_defined"):
373
+ continue
374
+ try:
375
+ row["aliases"] = sorted(
376
+ custom_provider_aliases(str(row.get("name", "")), str(row.get("slug", ""))))
377
+ except Exception:
378
+ continue
379
+
380
+
381
+ # ─── Internal: row post-processing ──────────────────────────────────────
382
+
383
+
384
+ def _provider_auth_hint(slug: str) -> tuple[str, str]:
385
+ """``(auth_type, key_env)`` for a canonical provider (``("api_key", "")`` when unregistered)."""
386
+ from hermes_cli.auth import PROVIDER_REGISTRY
387
+
388
+ cfg = PROVIDER_REGISTRY.get(slug)
389
+ auth_type = cfg.auth_type if cfg else "api_key"
390
+ key_env = cfg.api_key_env_vars[0] if (cfg and cfg.api_key_env_vars) else ""
391
+ return auth_type, key_env
392
+
393
+
394
+ def _row(slug: str, name: str, is_current: bool, **extra: Any) -> dict:
395
+ return {"slug": slug, "name": name, "is_current": is_current, "is_user_defined": False, **extra}
396
+
397
+
398
+ def _canonical_row(entry, cur: str, **extra: Any) -> dict:
399
+ from hermes_cli.models import _PROVIDER_LABELS
400
+
401
+ return _row(entry.slug, _PROVIDER_LABELS.get(entry.slug, entry.label), entry.slug.lower() == cur, **extra)
402
+
403
+
404
+ def _append_unconfigured_rows(
405
+ rows: list[dict], ctx: ConfigContext, *, current_only: bool = False,
406
+ ) -> list[dict]:
407
+ """Empty setup skeletons for canonical providers missing from ``rows`` — except the *current* one:
408
+ if config.yaml still points at it but credentials are gone, keep a row carrying the saved model so
409
+ GUI pickers don't silently snap to another provider."""
410
+ from hermes_cli.models import CANONICAL_PROVIDERS, _model_requires_account_discovery
411
+
412
+ seen = {r["slug"].lower() for r in rows}
413
+ cur = (ctx.current_provider or "").lower()
414
+ cur_model = str(ctx.current_model or "").strip()
415
+ extras: list[dict] = []
416
+ for entry in CANONICAL_PROVIDERS:
417
+ if entry.slug.lower() in seen:
418
+ continue
419
+ if current_only and entry.slug.lower() != cur:
420
+ continue
421
+ if entry.slug.lower() == cur:
422
+ saved_model = "" if _model_requires_account_discovery(entry.slug, cur_model) else cur_model
423
+ auth_type, key_env = _provider_auth_hint(entry.slug)
424
+ tail = (
425
+ "Astra requires successful account-scoped model discovery."
426
+ if cur_model and not saved_model else "Showing the saved model only."
427
+ )
428
+ warning = (
429
+ f"Configured provider missing usable credentials; paste {key_env} to reactivate. {tail}"
430
+ if auth_type == "api_key" and key_env
431
+ else f"Configured provider is not authenticated; run `hermes model` to reactivate. {tail}"
432
+ )
433
+ extras.append(_canonical_row(
434
+ entry, cur, models=[saved_model] if saved_model else [], total_models=1 if saved_model else 0,
435
+ source="configured-current", authenticated=False, auth_type=auth_type, key_env=key_env,
436
+ warning=warning,
437
+ ))
438
+ continue
439
+ extras.append(_canonical_row(entry, cur, models=[], total_models=0, source="canonical"))
440
+ return extras
441
+
442
+
443
+ def _anthropic_oauth_credentials_present() -> bool:
444
+ """True when the user explicitly authenticated Anthropic via OAuth (Hermes device flow or Claude Code
445
+ login) — those leave no trace in active_provider / model.provider / API-key env vars."""
446
+ try:
447
+ from agent.anthropic_credentials import read_claude_code_credentials, read_hermes_oauth_credentials
448
+
449
+ readers = (read_hermes_oauth_credentials, read_claude_code_credentials)
450
+ if any((read() or {}).get("accessToken") for read in readers):
451
+ return True
452
+ except Exception:
453
+ return False
454
+ # Pool-only OAuth entries (auth.json credential_pool.anthropic) are equally deliberate — discovery
455
+ # accepts them via pool.has_credentials(), so the filter must too or those rows are built then
456
+ # silently dropped. Read-only (no load_pool) so a picker open never mutates auth.json.
457
+ try:
458
+ from agent.credential_pool import AUTH_TYPE_OAUTH
459
+ from hermes_cli.auth import read_credential_pool
460
+
461
+ for entry in read_credential_pool("anthropic"):
462
+ if (isinstance(entry, dict) and entry.get("auth_type") == AUTH_TYPE_OAUTH
463
+ and str(entry.get("access_token") or "").strip()):
464
+ return True
465
+ except Exception:
466
+ pass
467
+ return False
468
+
469
+
470
+ def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]:
471
+ """Keep only rows backed by explicit user configuration — ``list_authenticated_providers`` also
472
+ discovers ambient credentials (e.g. GitHub CLI -> Copilot) Desktop chat pickers must not show."""
473
+ from hermes_cli.auth import is_provider_explicitly_configured
474
+
475
+ current_slug = str(ctx.current_provider or "").strip().lower()
476
+
477
+ def _is_explicit(row: dict, slug: str) -> bool:
478
+ # Managed local models are explicit configuration by existence (gigabytes downloaded into the
479
+ # machine-scoped dir); there is deliberately no config credential, so without the source clause
480
+ # the row would only survive on the profile where Use was last clicked.
481
+ if (row.get("is_user_defined") or (current_slug and slug == current_slug)
482
+ or row.get("source") == "local-runtime"):
483
+ return True
484
+ if slug == "moa":
485
+ # Virtual routing mode, not a configured provider: hide unless current (above) or the user
486
+ # wrote an enabled preset into RAW config (the DEFAULT_CONFIG preset must not show MoA).
487
+ return _raw_config_has_enabled_moa_preset()
488
+ return (
489
+ _provider_is_keyless(slug) # zero-setup providers need no configuration at all
490
+ # Anthropic OAuth (device flow / Claude Code) and external-process CLIs (copilot-acp) are
491
+ # deliberate sign-ins that leave no trace in config/env; keep the rows discovery accepted.
492
+ or (slug == "anthropic" and _anthropic_oauth_credentials_present())
493
+ or _external_process_signed_in(slug)
494
+ or is_provider_explicitly_configured(slug)
495
+ )
496
+
497
+ return [row for row in rows
498
+ if (slug := str(row.get("slug", "")).strip().lower()) and _is_explicit(row, slug)]
499
+
500
+
501
+ def _external_process_signed_in(slug: str) -> bool:
502
+ """True when an external-process provider has verified CLI credentials."""
503
+ try:
504
+ from hermes_cli.auth import PROVIDER_REGISTRY, get_external_process_provider_status
505
+ pconfig = PROVIDER_REGISTRY.get(slug)
506
+ return bool(pconfig and pconfig.auth_type == "external_process"
507
+ and get_external_process_provider_status(slug).get("auth_verified"))
508
+ except Exception:
509
+ return False
510
+
511
+
512
+ def _provider_is_keyless(slug: str) -> bool:
513
+ """True when the provider's Hermes overlay declares it keyless."""
514
+ try:
515
+ from hermes_cli.providers import HERMES_OVERLAYS
516
+ overlay = HERMES_OVERLAYS.get(slug)
517
+ return bool(overlay is not None and getattr(overlay, "keyless", False))
518
+ except Exception:
519
+ return False
520
+
521
+
522
+ def _raw_config_has_enabled_moa_preset() -> bool:
523
+ """True when the user's RAW config enables MoA: ``load_config()`` merges the DEFAULT_CONFIG preset for
524
+ everyone, which is not a user choice; visible once one enabled preset (or legacy flat config) is saved."""
525
+ try:
526
+ from hermes_cli.config import read_raw_config
527
+
528
+ raw = read_raw_config()
529
+ except Exception:
530
+ return False
531
+
532
+ moa = raw.get("moa") if isinstance(raw, dict) else None
533
+ if not isinstance(moa, dict):
534
+ return False
535
+
536
+ presets = moa.get("presets")
537
+ if isinstance(presets, dict):
538
+ return any(
539
+ not isinstance(preset, dict) or preset.get("enabled", True)
540
+ for name, preset in presets.items() if str(name or "").strip()
541
+ )
542
+
543
+ legacy_keys = {"reference_models", "aggregator", "reference_temperature", "aggregator_temperature",
544
+ "max_tokens", "reference_max_tokens", "fanout"}
545
+ return any(key in moa for key in legacy_keys) and bool(moa.get("enabled", True))
546
+
547
+
548
+ def _apply_picker_hints(rows: list[dict]) -> None:
549
+ """Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row."""
550
+ for row in rows:
551
+ if "authenticated" in row:
552
+ continue
553
+ # Skeleton rows (_append_unconfigured_rows) have empty `models` AND source="canonical".
554
+ is_skeleton = row.get("source") == "canonical" and not row.get("models")
555
+ row["authenticated"] = not is_skeleton
556
+ if not is_skeleton or row.get("is_user_defined"):
557
+ continue
558
+ auth_type, key_env = _provider_auth_hint(row["slug"])
559
+ row["auth_type"] = auth_type
560
+ row["key_env"] = key_env
561
+ row["warning"] = (f"paste {key_env} to activate" if auth_type == "api_key" and key_env
562
+ else f"run `hermes model` to configure ({auth_type})")
563
+
564
+
565
+ def _reorder_canonical(rows: list[dict]) -> list[dict]:
566
+ """Canonical slugs in ``CANONICAL_PROVIDERS`` order, truly-custom rows last. Keys on slug membership,
567
+ NOT ``is_user_defined`` — ``providers:`` config rows carry that flag even for canonical slugs."""
568
+ from hermes_cli.models import CANONICAL_PROVIDERS
569
+
570
+ order = {e.slug: i for i, e in enumerate(CANONICAL_PROVIDERS)}
571
+ canon = sorted((r for r in rows if r["slug"] in order), key=lambda r: order[r["slug"]])
572
+ extras = [r for r in rows if r["slug"] not in order]
573
+ return canon + extras
574
+
575
+
576
+ def _apply_pricing(rows: list[dict], *, force_fresh_nous_tier: bool = False, cached_only: bool = False) -> None:
577
+ """Set ``row["pricing"] = {model_id: {input, output, cache | None, free}}``; for Nous also
578
+ ``free_tier`` (account is free-tier) and ``unavailable_models`` (paid models a free user can't pick).
579
+ ``cached_only`` never hits the network: unknown Nous entitlement fails closed (``free_tier_pending``,
580
+ all models locked) and missing pricing is marked ``pricing_pending``."""
581
+ from hermes_cli.models_pricing import (
582
+ _format_price_per_mtok,
583
+ compute_sale_discount,
584
+ get_pricing_for_provider,
585
+ )
586
+ from hermes_cli.models import (
587
+ check_nous_free_tier,
588
+ get_cached_nous_free_tier,
589
+ partition_nous_models_by_tier,
590
+ )
591
+
592
+ nous_free_tier: Optional[bool] = None # resolved once (cached in models.py for the TTL window)
593
+
594
+ for row in rows:
595
+ slug = str(row.get("slug", "")).lower()
596
+ models = row.get("models") or []
597
+ if not models:
598
+ continue
599
+ if row.get("free_tier_row"):
600
+ # The free tier's one model has no Portal pricing and no entitlement to read: pricing
601
+ # it would lock the only row a free-tier install can select.
602
+ continue
603
+ try:
604
+ pricing_kwargs = {"cached_only": True} if cached_only else {}
605
+ raw_pricing = get_pricing_for_provider(slug, **pricing_kwargs) or {}
606
+ except Exception:
607
+ raw_pricing = {}
608
+ cached_nous_tier: Optional[bool] = None
609
+ if slug == "nous" and cached_only:
610
+ cached_nous_tier = get_cached_nous_free_tier()
611
+ if cached_nous_tier is None:
612
+ # Entitlement unknown: stay nonblocking but fail closed until the prewarm has populated
613
+ # both caches, else a free account could briefly select paid models on first open.
614
+ row["free_tier_pending"] = True
615
+ row["unavailable_models"] = list(models)
616
+ if not row.get("warning"): # say why every model renders locked
617
+ row["warning"] = ("Checking Nous plan entitlement… models unlock on the "
618
+ "next picker open or refresh.")
619
+ continue
620
+ if not raw_pricing:
621
+ if slug == "nous":
622
+ row["free_tier"] = bool(cached_nous_tier)
623
+ row["pricing_pending"] = True
624
+ row["unavailable_models"] = list(models) if cached_nous_tier else []
625
+ continue
626
+
627
+ formatted: dict[str, dict] = {}
628
+ for mid in models:
629
+ p = raw_pricing.get(mid)
630
+ if not p:
631
+ continue
632
+ inp_raw, out_raw = p.get("prompt", ""), p.get("completion", "")
633
+ cache_raw = p.get("input_cache_read", "")
634
+ inp = _format_price_per_mtok(inp_raw) if inp_raw != "" else ""
635
+ out = _format_price_per_mtok(out_raw) if out_raw != "" else ""
636
+ entry: dict = {
637
+ "input": inp, "output": out,
638
+ "cache": _format_price_per_mtok(cache_raw) if cache_raw else None,
639
+ "free": inp == "free" and out in ("free", ""), # both input and output cost nothing
640
+ }
641
+ # Sale chrome is Nous Portal-only (other catalogs' nested pricing.original is ignored); free
642
+ # models get flat -100% chrome, was_* only when the gateway served an original.
643
+ if slug == "nous":
644
+ sale = compute_sale_discount(inp_raw, out_raw, p.get("original"))
645
+ if sale is not None:
646
+ discount_percent, was_prompt_raw, was_out_raw = sale
647
+ entry["discount_percent"] = discount_percent
648
+ for key, was_raw in (("was_input", was_prompt_raw), ("was_output", was_out_raw)):
649
+ if was_raw != "":
650
+ entry[key] = _format_price_per_mtok(was_raw)
651
+ formatted[mid] = entry
652
+
653
+ if formatted:
654
+ row["pricing"] = formatted
655
+
656
+ if slug == "nous":
657
+ try:
658
+ if nous_free_tier is None:
659
+ nous_free_tier = (cached_nous_tier if cached_only
660
+ else check_nous_free_tier(force_fresh=force_fresh_nous_tier))
661
+ row["free_tier"] = bool(nous_free_tier)
662
+ row["unavailable_models"] = (
663
+ partition_nous_models_by_tier(list(models), raw_pricing, free_tier=True)[1]
664
+ if nous_free_tier else [])
665
+ except Exception: # tier detection failed — fail open (no gating)
666
+ row["free_tier"] = False
667
+ row["unavailable_models"] = []
668
+
669
+
670
+ def _local_runtime_row(ctx: "ConfigContext") -> dict | None:
671
+ """The ``llamacpp`` row from staged GGUFs (``None`` when none) — downloaded models must be selectable
672
+ before the server runs (selection starts it via the runtime_provider seam)."""
673
+ try:
674
+ from hermes_cli.local_runtime.bootstrap import staged_model_ids
675
+
676
+ staged = staged_model_ids()
677
+ if not staged:
678
+ return None
679
+ current = (ctx.current_provider or "").strip().lower() in ("llamacpp", "llama.cpp", "llama-cpp")
680
+ if not current:
681
+ # A LIVE session on the managed server reports provider "custom" with the managed base_url;
682
+ # match on the endpoint so the session being chatted in still shows a selection.
683
+ try:
684
+ from hermes_cli.local_runtime.endpoint import _state_endpoint
685
+
686
+ managed = _state_endpoint()
687
+ current = bool(managed and (ctx.current_base_url or "").strip().rstrip("/")
688
+ == managed["base_url"].rstrip("/"))
689
+ except Exception:
690
+ current = False
691
+ # Bare "Local" user-facing (engine name is an implementation detail); authenticated = reachability.
692
+ return _row("llamacpp", "Local", current, models=staged, total_models=len(staged),
693
+ source="local-runtime", authenticated=True, auth_type="local", warning=None)
694
+ except Exception:
695
+ return None
696
+
697
+
698
+ def _prewarm_pricing_async(
699
+ rows: list[dict], *, current_provider: str = "", current_base_url: str = "",
700
+ ) -> Optional[Thread]:
701
+ """Warm picker pricing caches without delaying the current payload (one worker per
702
+ profile + endpoint scope; a live worker is reused)."""
703
+ from hermes_constants import hermes_home_key
704
+ from hermes_cli.models_pricing import pricing_cache_scope
705
+
706
+ slugs = {str(row.get("slug") or "").lower() for row in rows if row.get("slug")}
707
+ endpoint_scope = tuple(sorted(
708
+ (slug, pricing_cache_scope(slug, current_provider=current_provider, current_base_url=current_base_url))
709
+ for slug in slugs))
710
+ prewarm_key = (hermes_home_key(), endpoint_scope)
711
+
712
+ with _pricing_prewarm_lock:
713
+ current = _pricing_prewarm_threads.get(prewarm_key)
714
+ if current is not None and current.is_alive():
715
+ return current
716
+ # The worker mutates only private copies; the pricing helpers populate shared process caches.
717
+ worker_rows = [{**row, "models": list(row.get("models") or [])} for row in rows]
718
+
719
+ def _worker() -> None:
720
+ try:
721
+ _apply_pricing(worker_rows)
722
+ finally:
723
+ with _pricing_prewarm_lock:
724
+ if _pricing_prewarm_threads.get(prewarm_key) is current_thread():
725
+ _pricing_prewarm_threads.pop(prewarm_key, None)
726
+
727
+ thread = Thread(target=copy_context().run, args=(_worker,),
728
+ name="hermes-picker-pricing-prewarm", daemon=True)
729
+ _pricing_prewarm_threads[prewarm_key] = thread
730
+ thread.start()
731
+ return thread
732
+
733
+
734
+ def _moa_provider_row(current_provider: str = "") -> dict | None:
735
+ """The virtual ``moa`` row shared by the CLI inventory and gateway picker; ``None`` without presets."""
736
+ try:
737
+ from hermes_cli.config import load_config
738
+ from hermes_cli.moa_config import normalize_moa_config
739
+
740
+ cfg = normalize_moa_config(load_config().get("moa") or {})
741
+ models = list(cfg.get("presets", {}).keys())
742
+ if not models:
743
+ return None
744
+ return _row(
745
+ "moa", "Mixture of Agents", (current_provider or "").lower() == "moa", models=models,
746
+ total_models=len(models), source="virtual", authenticated=True, auth_type="virtual",
747
+ warning="Aggregator acts as the selected model; references provide analysis before each call.")
748
+ except Exception:
749
+ return None
hermes_cli/kanban_db_dispatch.py ADDED
The diff for this file is too large to render. See raw diff
 
hermes_cli/kanban_pr_acceptance_store.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persist acceptance with the same ownership snapshot as the terminal write."""
2
+ from __future__ import annotations
3
+
4
+ from hermes_cli.kanban_db_connect import write_txn
5
+ from hermes_cli.kanban_pr_acceptance import _PR, collect_acceptance
6
+
7
+
8
+ def _snapshot(conn, task_id):
9
+ row = conn.execute("SELECT current_run_id, status, completion_contract FROM tasks WHERE id=?", (task_id,)).fetchone()
10
+ return tuple(row) if row else None
11
+
12
+
13
+ def prepare_acceptance(conn, task_id, expected_run_id, metadata):
14
+ snapshot = _snapshot(conn, task_id)
15
+ if snapshot is None:
16
+ return False
17
+ run_id, status, contract = snapshot
18
+ if not contract or contract == "local-only":
19
+ return None
20
+ if status not in {"running", "ready", "blocked", "review"} or (expected_run_id is not None and run_id != expected_run_id):
21
+ return False
22
+ published_pr = metadata.get("published_pr") if isinstance(metadata, dict) else None
23
+ match = _PR.fullmatch(published_pr) if isinstance(published_pr, str) else None
24
+ # Publication binds once. Retrying cannot replace the task's PR with a green sibling.
25
+ if match and contract == match[1]:
26
+ with write_txn(conn):
27
+ if _snapshot(conn, task_id) != snapshot:
28
+ return False
29
+ conn.execute("UPDATE tasks SET completion_contract=? WHERE id=?", (published_pr, task_id))
30
+ snapshot = (run_id, status, published_pr)
31
+ contract = published_pr
32
+ return snapshot, collect_acceptance(contract, published_pr)
33
+
34
+
35
+ def record_acceptance(conn, task_id, acceptance):
36
+ """Called under complete_task's write_txn, before its terminal UPDATE."""
37
+ from hermes_cli.kanban_db import _append_event
38
+ snapshot, receipt = acceptance
39
+ if _snapshot(conn, task_id) != snapshot:
40
+ return False
41
+ _append_event(conn, task_id, "pr_acceptance", receipt, run_id=snapshot[0])
42
+ if not receipt["ok"]:
43
+ detail = f"PR acceptance {receipt['classification']}: {receipt.get('detail', '')} {receipt['recovery']}"
44
+ conn.execute("UPDATE tasks SET last_failure_error=? WHERE id=?", (detail, task_id))
45
+ return receipt["ok"]
hermes_cli/main_install_repair.py ADDED
@@ -0,0 +1,1245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Install/update recovery: interrupted-install markers, lazy-refresh repair, Windows shim quarantine, dependency verification.
2
+
3
+ Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...)
4
+ are imported lazily inside the functions that use them (avoids an import cycle).
5
+ """
6
+
7
+ import contextlib
8
+ import logging
9
+ import os
10
+ import shlex
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import threading
15
+ import time as _time
16
+
17
+ from pathlib import Path
18
+ from hermes_cli import _early_recovery as _early_recovery_mod
19
+
20
+ # Log-record parity with the origin module.
21
+ logger = logging.getLogger("hermes_cli.main")
22
+
23
+
24
+ def _pyproject_project(debug_fmt: str | None = None) -> dict | None:
25
+ """``[project]`` table of pyproject.toml, or ``None`` when absent/unreadable."""
26
+ from hermes_cli.main import PROJECT_ROOT
27
+ pyproject = PROJECT_ROOT / "pyproject.toml"
28
+ if not pyproject.is_file():
29
+ return None
30
+ try:
31
+ import tomllib
32
+ with pyproject.open("rb") as handle:
33
+ project = tomllib.load(handle).get("project", {})
34
+ except Exception as exc:
35
+ if debug_fmt:
36
+ logger.debug(debug_fmt, exc)
37
+ return None
38
+ return project if isinstance(project, dict) else None
39
+
40
+
41
+ def _naive_requirement(spec: str) -> tuple[str, str]:
42
+ """``(name, head)`` of a ``name OP version ; marker`` spec without ``packaging``."""
43
+ head = spec.split(";", 1)[0].strip()
44
+ bare = head
45
+ for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
46
+ if op in bare:
47
+ bare = bare.split(op, 1)[0]
48
+ break
49
+ return bare.strip().split("[", 1)[0].strip(), head
50
+
51
+
52
+ def _parse_requirements(raw_deps: list[str]) -> list[tuple[str, "object | None", str]]:
53
+ """``(name, marker, head)`` per dep spec — ``packaging`` when importable, else a naive split."""
54
+ parsed: list[tuple[str, "object | None", str]] = []
55
+ try:
56
+ from packaging.requirements import Requirement # type: ignore
57
+ for spec in raw_deps:
58
+ try:
59
+ req = Requirement(spec)
60
+ except Exception:
61
+ continue
62
+ parsed.append((req.name, req.marker, spec.split(";", 1)[0].strip()))
63
+ except Exception:
64
+ for spec in raw_deps:
65
+ name, head = _naive_requirement(spec)
66
+ if name:
67
+ parsed.append((name, None, head))
68
+ return parsed
69
+
70
+
71
+ def _load_installable_optional_extras(group: str = "all") -> list[str]:
72
+ """Return optional extras referenced by a dependency group (``all`` or ``termux-all``)."""
73
+ optional_deps = (_pyproject_project() or {}).get("optional-dependencies", {})
74
+ if not isinstance(optional_deps, dict):
75
+ return []
76
+ referenced: list[str] = []
77
+ for ref in optional_deps.get(group, []):
78
+ if "[" in ref and "]" in ref:
79
+ name = ref.split("[", 1)[1].split("]", 1)[0]
80
+ if name in optional_deps:
81
+ referenced.append(name)
82
+ return referenced
83
+
84
+
85
+ # Install-scoped breadcrumbs live next to the venv (not under $HERMES_HOME)
86
+ # because the venv is shared across profiles.
87
+ # ``.update-incomplete`` — generic core ``.[all]`` install was interrupted;
88
+ # cleared only after a confirmed full dependency reinstall/recovery.
89
+ # ``.lazy-refresh-incomplete`` — lazy-backend refresh may have corrupted packages;
90
+ # cleared only after import-probe repair confirms healthy (never on indeterminate).
91
+ # Narrow lazy probes must NEVER clear the generic core marker.
92
+ # See #58004.
93
+ def _update_marker_path() -> Path:
94
+ from hermes_cli.main import PROJECT_ROOT
95
+ return PROJECT_ROOT / ".update-incomplete"
96
+
97
+
98
+ def _lazy_refresh_marker_path() -> Path:
99
+ from hermes_cli.main import PROJECT_ROOT
100
+ return PROJECT_ROOT / ".lazy-refresh-incomplete"
101
+
102
+
103
+ def _pytest_owns_live_checkout(root: Path) -> bool:
104
+ """True under pytest when ``root`` is this checkout: unsandboxed update/recovery tests must
105
+ neither litter the live repo root with breadcrumbs (false-arming the developer's next launch)
106
+ nor run a real reinstall against the executing venv (cf. ``managed_scope._under_pytest``)."""
107
+ return "PYTEST_CURRENT_TEST" in os.environ and root == Path(__file__).resolve().parent.parent
108
+
109
+
110
+ def _clear_marker_file(path: Path, *, label: str) -> None:
111
+ """Remove an update-recovery breadcrumb. Never raises."""
112
+ try:
113
+ path.unlink()
114
+ except FileNotFoundError:
115
+ pass
116
+ except OSError as exc:
117
+ logger.debug("Could not clear %s marker: %s", label, exc)
118
+
119
+
120
+ def _clear_update_incomplete_marker() -> None:
121
+ """Remove the interrupted core-install breadcrumb. Never raises."""
122
+ _clear_marker_file(_update_marker_path(), label="update-incomplete")
123
+
124
+
125
+ def _clear_lazy_refresh_incomplete_marker() -> None:
126
+ """Remove the interrupted lazy-refresh breadcrumb. Never raises."""
127
+ _clear_marker_file(_lazy_refresh_marker_path(), label="lazy-refresh-incomplete")
128
+
129
+
130
+ def _claim_recovery_lock(lock_path: Path) -> bool:
131
+ """Atomically claim the single-flight recovery lock; False when another process holds it.
132
+
133
+ A crashed holder's stale lock is broken after an hour (well past any realistic install).
134
+ Failing to CREATE the lock (read-only fs, perms) proceeds unlocked — the install itself
135
+ will surface the real problem."""
136
+ try:
137
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
138
+ os.write(fd, f"{os.getpid()}\n".encode())
139
+ os.close(fd)
140
+ except FileExistsError:
141
+ try:
142
+ if _time.time() - lock_path.stat().st_mtime > 3600:
143
+ lock_path.unlink()
144
+ except OSError:
145
+ pass
146
+ return False
147
+ except OSError as exc:
148
+ logger.debug("Could not create install-recovery lock: %s", exc)
149
+ return True
150
+
151
+
152
+ @contextlib.contextmanager
153
+ def _stdout_to_stderr():
154
+ """Route Python prints AND the fd 1 that pip/uv inherit to stderr: launches whose stdout is
155
+ a protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get install noise."""
156
+ saved_stdout_fd = None
157
+ saved_sys_stdout = sys.stdout
158
+ try:
159
+ saved_stdout_fd = os.dup(1)
160
+ os.dup2(2, 1)
161
+ except OSError:
162
+ saved_stdout_fd = None
163
+ sys.stdout = sys.stderr
164
+ try:
165
+ yield
166
+ finally:
167
+ sys.stdout = saved_sys_stdout
168
+ if saved_stdout_fd is not None:
169
+ try:
170
+ os.dup2(saved_stdout_fd, 1)
171
+ os.close(saved_stdout_fd)
172
+ except OSError:
173
+ pass
174
+
175
+
176
+ def _recover_from_interrupted_install() -> None:
177
+ """Finish update work left half-done by a prior ``hermes update``.
178
+
179
+ ``.update-incomplete`` recovers via full quarantined reinstall; ``.lazy-refresh-incomplete``
180
+ via package-only import probes (cleared only when probes confirm healthy/repaired). Never
181
+ raises: on failure it prints the manual command and leaves the marker for the next launch.
182
+ Concurrent launches race on the shared venv, so an ``O_EXCL`` lockfile lets one process
183
+ recover while the others skip.
184
+ """
185
+ from hermes_cli.main import PROJECT_ROOT
186
+ if _pytest_owns_live_checkout(PROJECT_ROOT):
187
+ return
188
+ lazy_marker = _lazy_refresh_marker_path().exists()
189
+ if not lazy_marker and not _update_marker_path().exists():
190
+ return
191
+ # Managed/Docker installs and git-less PyPI installs never run the source-tree
192
+ # update path, so a stray marker is not ours to act on. Just clear it.
193
+ if not (PROJECT_ROOT / "pyproject.toml").is_file():
194
+ _clear_update_incomplete_marker()
195
+ _clear_lazy_refresh_incomplete_marker()
196
+ return
197
+ lock_path = PROJECT_ROOT / ".update-incomplete.lock"
198
+ if not _claim_recovery_lock(lock_path):
199
+ return
200
+ try:
201
+ with _stdout_to_stderr():
202
+ if lazy_marker:
203
+ _recover_lazy_refresh_marker_locked()
204
+ if _update_marker_path().exists():
205
+ _recover_core_update_marker_locked()
206
+ finally:
207
+ try:
208
+ lock_path.unlink()
209
+ except OSError:
210
+ pass
211
+
212
+
213
+ def _recover_lazy_refresh_marker_locked() -> None:
214
+ """Heal ``.lazy-refresh-incomplete`` via confirmed import-probe repair."""
215
+ print(
216
+ "⚠ A previous lazy-backend refresh may have left the venv unhealthy — "
217
+ "running import-based package repair...")
218
+ install_prefix, install_env = _default_venv_install_target()
219
+ status = _repair_venv_via_import_probes(install_prefix, env=install_env)
220
+ if status in ("healthy", "repaired"):
221
+ _clear_lazy_refresh_incomplete_marker()
222
+ print("✓ Lazy-refresh venv recovery confirmed — install is healthy again.")
223
+ return
224
+ indeterminate = status == "indeterminate"
225
+ problem = (
226
+ "Import probes unavailable — cannot confirm venv health." if indeterminate
227
+ else "Lazy-refresh package repair incomplete.")
228
+ print(f" ⚠ {problem} Leaving `.lazy-refresh-incomplete` for the next launch.")
229
+ if indeterminate:
230
+ return
231
+ print(" Recover manually with:")
232
+ all_specs = _lazy_refresh_repair_specs(sorted(set(_LAZY_REFRESH_REPAIR_PACKAGES.values())))
233
+ print(
234
+ f" {' '.join(install_prefix)} install --force-reinstall "
235
+ + " ".join(shlex.quote(s) for s in all_specs))
236
+
237
+
238
+ def _recover_core_update_marker_locked() -> None:
239
+ """Heal ``.update-incomplete`` via full ``.[all]`` reinstall only.
240
+
241
+ Narrow lazy-refresh import probes are not proof that a generic interrupted core
242
+ install finished — a missing dep outside that probe set would look healthy and
243
+ clear the breadcrumb too early.
244
+ """
245
+ from hermes_cli.main import PROJECT_ROOT
246
+ print(
247
+ "⚠ A previous `hermes update` was interrupted mid-install — "
248
+ "finishing dependency installation now...")
249
+
250
+ # Windows: a ``hermes.exe`` launch has the launcher as an ancestor; the quarantined full
251
+ # reinstall can still replace it. Package-only repair is first aid and NEVER clears the marker.
252
+ # Full editable reinstall uses quarantine so the live shim can still be replaced. Package-only import
253
+ # repair may help as first aid but must NEVER clear this core marker on its own (#58004 review).
254
+ self_locked = _windows_running_hermes_launcher_locked()
255
+ if self_locked:
256
+ install_prefix, install_env = _default_venv_install_target()
257
+ print(
258
+ " → Running from hermes.exe; applying package-only first aid, "
259
+ "then quarantined full reinstall (core marker stays until that "
260
+ "succeeds)...")
261
+ _repair_venv_via_import_probes(install_prefix, env=install_env)
262
+ try:
263
+ from hermes_cli import _install_repair as _ir
264
+
265
+ # ensure_uv bootstraps uv itself when missing (the early pass's stdlib-only lookup
266
+ # cannot), so a venv whose uv vanished mid-update still heals.
267
+ from hermes_cli.managed_uv import ensure_uv
268
+ ensure_uv()
269
+ # Shared stdlib executor: late path and pre-import early pass run exactly the same
270
+ # reinstall. Its own stdout→stderr redirect nests harmlessly inside ours.
271
+ _ir.run_core_install(PROJECT_ROOT)
272
+ _clear_update_incomplete_marker()
273
+ print("✓ Dependency installation recovered — your install is healthy again.")
274
+ except Exception as exc:
275
+ # Leave the marker so the next launch retries; give the exact manual command.
276
+ logger.debug("Interrupted-install recovery failed: %s", exc)
277
+ print("✗ Could not auto-recover the interrupted install.")
278
+ manual = (
279
+ " Hermes is still running from the launcher that needs "
280
+ "replacing. Close other Hermes windows, restart from a "
281
+ "different terminal, then run:",
282
+ f' cd /d "{PROJECT_ROOT}"',
283
+ f' "{sys.executable}" -m pip install -e ".[all]"',
284
+ ) if self_locked else (
285
+ " Recover manually with:",
286
+ f" cd {PROJECT_ROOT}",
287
+ f" {sys.executable} -m ensurepip --upgrade",
288
+ f" {sys.executable} -m pip install -e '.[all]'",
289
+ )
290
+ for line in manual:
291
+ print(line)
292
+
293
+
294
+ def _norm_exe_path(path) -> str:
295
+ """Case-folded resolved path, for comparing executables on Windows."""
296
+ try:
297
+ return str(Path(path).resolve()).lower()
298
+ except OSError:
299
+ return str(path).lower()
300
+
301
+
302
+ def _windows_shim_in_process_chain() -> Path | None:
303
+ """The venv console shim this process runs from or under, if any.
304
+
305
+ ``venv\\Scripts\\hermes.exe`` holds itself open (no ``FILE_SHARE_DELETE``) for the whole
306
+ process lifetime, so an editable install run from one can never rewrite it. Two probes, since
307
+ either can come up empty: own launch paths (argv[0], ``__main__`` file/spec origin — runpy/
308
+ zipapp puts ``<shim>\\__main__.py`` there) and psutil ancestry. Candidates are intersected
309
+ with the project venv's own shims so a foreign ``hermes.exe`` never matches.
310
+
311
+ See #88838, #89599.
312
+ """
313
+ if not _is_windows():
314
+ return None
315
+ scripts_dir = _venv_scripts_dir()
316
+ if scripts_dir is None:
317
+ return None
318
+ shims = {_norm_exe_path(shim): shim for shim in _hermes_exe_shims(scripts_dir)}
319
+ if not shims:
320
+ return None
321
+
322
+ def _match(candidate) -> Path | None:
323
+ path = Path(candidate)
324
+ if path.name.lower() == "__main__.py":
325
+ path = path.parent
326
+ return shims.get(_norm_exe_path(path))
327
+
328
+ main_mod = sys.modules.get("__main__")
329
+ candidates = [*sys.argv[:1], *filter(None, (
330
+ getattr(main_mod, "__file__", None),
331
+ getattr(getattr(main_mod, "__spec__", None), "origin", None)))]
332
+ for candidate in candidates:
333
+ matched = _match(candidate)
334
+ if matched is not None:
335
+ return matched
336
+
337
+ with contextlib.suppress(Exception):
338
+ import psutil
339
+ me = psutil.Process()
340
+ for proc in [me] + list(me.parents()):
341
+ try:
342
+ matched = _match(proc.exe())
343
+ except Exception:
344
+ continue
345
+ if matched is not None:
346
+ return matched
347
+ return None
348
+
349
+
350
+ def _windows_running_hermes_launcher_locked() -> bool:
351
+ """True when a venv ``hermes*.exe`` shim is this process or an ancestor (best-effort)."""
352
+ return _windows_shim_in_process_chain() is not None
353
+
354
+
355
+ # Set on the re-exec'd child so it can never spawn another one.
356
+ _UPDATE_REEXEC_ENV = "HERMES_UPDATE_REEXEC"
357
+
358
+
359
+ def _reexec_dependency_sync_off_windows_shim() -> bool:
360
+ """Hand the dependency sync to the venv interpreter, off the console shim.
361
+
362
+ Returns True when a child was spawned and the caller must exit at once (releasing the
363
+ shim before the child reaches ``pip install -e .``); False to continue in-process.
364
+
365
+ Called at the dependency-sync boundary, NOT at the top of the command: by then the code swap
366
+ is done and every interactive question has been answered; only the venv rewrite — the one
367
+ step that cannot run inside the shim — remains. Earlier would detach every run (even the
368
+ ``Already up to date!`` no-op) and take the prompts along. Waiting on the child deadlocks
369
+ (we hold the handle it needs) and Windows has no exec, so the shell returns; the child keeps
370
+ the console, prints its own result, and ``--gateway`` writes the true exit code to
371
+ ``.update_exit_code``. The child re-runs ``hermes update`` so the sync and its tail happen
372
+ exactly once; ``_UPDATE_REEXEC_ENV`` stops it spawning again and stops the "already up to
373
+ date" early return from swallowing the sync. ``.update-incomplete`` is already written, so
374
+ a child that dies mid-install is finished by the next launch's recovery.
375
+
376
+ Called at the dependency-sync boundary, NOT at the top of the command — the same placement rule as the
377
+ native-module deferral beside it, and for the same reason (#86735): a hand-off that fires before the
378
+ fetch detaches every run, including the ``Already up to date!`` no-op that never touches the venv at
379
+ all, and it takes the interactive prompts with it. By the time we reach here the code swap is done and
380
+ every question — stash, branch switch, config migration — has already been asked and answered in the
381
+ user's own console.
382
+ ``venv\\Scripts\\hermes.exe`` is a launcher that runs the interpreter with the shim as its script and
383
+ holds it open without ``FILE_SHARE_DELETE`` for the whole command, so the quarantine rename is refused
384
+ and uv fails to replace it with os error 32 (#88838, #89599).
385
+ """
386
+ if os.environ.get(_UPDATE_REEXEC_ENV) == "1":
387
+ return False
388
+ shim = _windows_shim_in_process_chain()
389
+ if shim is None:
390
+ return False
391
+ from hermes_constants import venv_python_path
392
+ python_exe = venv_python_path(shim.parent.parent, windows=True)
393
+ cmd = [str(python_exe), "-m", "hermes_cli.main", *sys.argv[1:]]
394
+ if python_exe.is_file():
395
+ try:
396
+ subprocess.Popen(
397
+ cmd, env={**os.environ, _UPDATE_REEXEC_ENV: "1"}, stdin=subprocess.DEVNULL)
398
+ print(
399
+ f"→ Windows: {shim.name} cannot replace itself while it runs; "
400
+ "finishing the dependency install under the venv Python.")
401
+ print(
402
+ " The code update is already applied. The install continues "
403
+ "below and this shell returns right away.")
404
+ return True
405
+ except OSError as exc:
406
+ logger.debug("Dependency-sync hand-off via %s failed: %s", python_exe, exc)
407
+ print(f" ⚠ Could not hand the dependency install off {shim.name}.")
408
+ print(" Continuing in-process; if it cannot replace the shim, run:")
409
+ print(f" {subprocess.list2cmdline(cmd)}")
410
+ return False
411
+
412
+
413
+ def _default_venv_install_target() -> tuple[list[str], dict[str, str] | None]:
414
+ """Return ``(install_cmd_prefix, env)`` for the project venv when possible."""
415
+ from hermes_cli.main import PROJECT_ROOT
416
+ try:
417
+ from hermes_cli.managed_uv import ensure_uv
418
+ uv_bin = ensure_uv()
419
+ except Exception:
420
+ uv_bin = None
421
+ if uv_bin:
422
+ from hermes_constants import project_venv_dir
423
+ venv_dir = project_venv_dir(PROJECT_ROOT) or PROJECT_ROOT / "venv"
424
+ env = {**os.environ, "VIRTUAL_ENV": str(venv_dir)}
425
+ if _is_termux_env(env):
426
+ env.pop("PYTHONPATH", None)
427
+ env.pop("PYTHONHOME", None)
428
+ return [uv_bin, "pip"], env
429
+ return [sys.executable, "-m", "pip"], None
430
+
431
+
432
+ def _run_install_with_heartbeat(
433
+ cmd: list[str], *, env: dict[str, str] | None = None, heartbeat_interval_seconds: int = 30
434
+ ) -> None:
435
+ """Run a dependency install, printing an elapsed-time heartbeat while pip/uv is silent.
436
+
437
+ Resolvers/build backends compiling Rust/C extensions can stay quiet for minutes.
438
+ """
439
+ from hermes_cli.main import PROJECT_ROOT
440
+ done = threading.Event()
441
+ start = _time.time()
442
+
443
+ def _heartbeat() -> None:
444
+ # Wait first, then print, so short installs don't emit noise.
445
+ while not done.wait(heartbeat_interval_seconds):
446
+ elapsed = int(_time.time() - start)
447
+ print(
448
+ f" … still installing dependencies ({elapsed}s elapsed)"
449
+ " — compiling Rust/C extensions can take several minutes",
450
+ flush=True)
451
+
452
+ t = threading.Thread(target=_heartbeat, daemon=True)
453
+ t.start()
454
+ try:
455
+ # stderr=STDOUT: uv/pip write progress to stderr. Legacy desktop
456
+ # hand-offs (pre scripts/desktop-update/windows.ps1, which drains
457
+ # both pipes) only drain the child's stdout, so a full stderr
458
+ # pipe (~64KB) blocks the installer forever. Merged into stdout,
459
+ # the output rides the pipe old hand-offs DO drain. This module
460
+ # is imported when `hermes update` starts, so an update running
461
+ # from an old base executes the old copy regardless of the git
462
+ # reset — this protects updates initiated from bases that ship
463
+ # it. (managed_uv.py gets the same fix AND is imported lazily
464
+ # after the reset, so its sync is protected even on old bases.)
465
+ subprocess.run(cmd, cwd=PROJECT_ROOT, check=True, env=env, stderr=subprocess.STDOUT)
466
+ finally:
467
+ done.set()
468
+ t.join(timeout=0.2)
469
+
470
+
471
+ def _run_repair_step(run, cmd: list[str], *, log_msg: str, fail_msg: str | None, **kwargs) -> bool:
472
+ """``run(cmd, **kwargs)``; on ``CalledProcessError`` log + print the failure and return False."""
473
+ try:
474
+ run(cmd, **kwargs)
475
+ except subprocess.CalledProcessError as e:
476
+ logger.warning(log_msg, e)
477
+ if fail_msg is not None:
478
+ print(fail_msg)
479
+ return False
480
+ return True
481
+
482
+
483
+ def _report_still_missing(missing: list[str], hint: str, *, ok: str) -> None:
484
+ if missing:
485
+ print(f" ⚠ Still missing after repair: {', '.join(missing)}. {hint}")
486
+ else:
487
+ print(ok)
488
+
489
+
490
+ def _is_windows() -> bool:
491
+ return sys.platform == "win32"
492
+
493
+
494
+ def _venv_scripts_dir() -> Path | None:
495
+ """Return the venv Scripts directory if we're running inside the project venv."""
496
+ from hermes_cli.main import PROJECT_ROOT
497
+ from hermes_constants import project_venv_dir, venv_bin_dir
498
+ venv_dir = project_venv_dir(PROJECT_ROOT)
499
+ if venv_dir is None:
500
+ return None
501
+ scripts = venv_bin_dir(venv_dir, windows=_is_windows())
502
+ return scripts if scripts.is_dir() else None
503
+
504
+
505
+ def _hermes_exe_shims(scripts_dir: Path) -> list[Path]:
506
+ """Entry-point shims uv may rewrite during ``pip install -e .`` — Windows .exe launchers
507
+ only; POSIX shims are plain scripts replaced atomically."""
508
+ if not _is_windows():
509
+ return []
510
+ names = set(_load_console_script_names()) or {"hermes", "hermes-agent", "hermes-acp"}
511
+ # Not a [project.scripts] entry point, but older update/install paths still
512
+ # rewrite and quarantine it.
513
+ names.add("hermes-gateway")
514
+ return [scripts_dir / f"{name}.exe" for name in sorted(names)]
515
+
516
+
517
+ _QUARANTINE_BACKOFF_MS = (0, 100, 250, 500, 1000)
518
+
519
+
520
+ def _rename_with_backoff(source: Path, target: Path, attempts: int) -> OSError | None:
521
+ """Rename with the quarantine backoff ladder; returns the last ``OSError`` or ``None``."""
522
+ for delay_ms in _QUARANTINE_BACKOFF_MS[:attempts]:
523
+ if delay_ms:
524
+ _time.sleep(delay_ms / 1000.0)
525
+ try:
526
+ source.rename(target)
527
+ return None
528
+ except OSError as e:
529
+ last_exc = e
530
+ return last_exc
531
+
532
+
533
+ def _quarantine_running_hermes_exe(
534
+ scripts_dir: Path, *, max_attempts: int = 4, failed_out: list[str] | None = None
535
+ ) -> list[tuple[Path, Path]]:
536
+ """Pre-empt the Windows file lock on the running ``hermes.exe``.
537
+
538
+ Windows allows RENAMING a running executable but blocks DELETE/REPLACE (uv fails with
539
+ ``Access is denied. (os error 5)``), so live shims are renamed to ``<shim>.old.<unix-ms>``
540
+ first; ``_cleanup_quarantined_exes`` sweeps the ``.old`` files next invocation. Rename can
541
+ still fail when another process holds the .exe without ``FILE_SHARE_DELETE`` (AV scanner:
542
+ transient; Hermes Desktop backend child: until closed) — retry with backoff, then warn
543
+ naming the likely culprit. Returns ``(original, quarantined)`` pairs for rollback;
544
+ ``failed_out`` collects shims whose rename failed every attempt so the update dependency
545
+ sync can refuse instead of stranding a half-broken venv.
546
+
547
+ See #87331.
548
+ """
549
+ moved: list[tuple[Path, Path]] = []
550
+ if not _is_windows():
551
+ return moved
552
+ stamp = int(_time.time() * 1000)
553
+ # First attempt immediate; 100/250/500ms covers the typical AV re-scan window.
554
+ attempts = max(1, min(max_attempts, len(_QUARANTINE_BACKOFF_MS)))
555
+ for shim in _hermes_exe_shims(scripts_dir):
556
+ if not shim.exists():
557
+ continue
558
+ target = shim.with_suffix(shim.suffix + f".old.{stamp}")
559
+ last_exc = _rename_with_backoff(shim, target, attempts)
560
+ if last_exc is None:
561
+ moved.append((shim, target))
562
+ continue
563
+
564
+ # Every rename failed. MOVEFILE_DELAY_UNTIL_REBOOT is no fallback (needs elevation, frees
565
+ # nothing now, moves a later repaired shim aside at boot). Report; let uv try its luck.
566
+ print(
567
+ f" ⚠ Could not quarantine {shim.name} ({last_exc.__class__.__name__}: "
568
+ f"another process is holding it open).")
569
+ print(
570
+ " Close Hermes Desktop, exit other `hermes` REPLs, stop the "
571
+ "gateway, or pause AV scanning, then re-run `hermes update`.")
572
+ if failed_out is not None:
573
+ failed_out.append(shim.name)
574
+
575
+ return moved
576
+
577
+
578
+ _PENDING_RENAME_KEY = r"SYSTEM\CurrentControlSet\Control\Session Manager"
579
+ _PENDING_RENAME_VALUE = "PendingFileRenameOperations"
580
+
581
+
582
+ def _filter_pending_shim_renames(entries: list[str], shims: list[Path]) -> tuple[list[str], int]:
583
+ """Drop our ``<shim>`` -> ``<shim>.old.<stamp>`` pairs from a PendingFileRenameOperations
584
+ value (a flat REG_MULTI_SZ of (source, target) pairs shared with other installers).
585
+ Returns the entries to keep and how many pairs were dropped."""
586
+ import ntpath
587
+
588
+ def _norm(value: str) -> str:
589
+ path = str(value).lstrip("!")
590
+ if path.startswith("\\??\\"):
591
+ path = path[4:]
592
+ return ntpath.normcase(ntpath.normpath(path))
593
+
594
+ shim_paths = {_norm(str(shim)) for shim in shims}
595
+ kept: list[str] = []
596
+ removed = 0
597
+ for index in range(0, len(entries) - 1, 2):
598
+ source, target = entries[index], entries[index + 1]
599
+ source_norm = _norm(source)
600
+ if source_norm in shim_paths and _norm(target).startswith(f"{source_norm}.old."):
601
+ removed += 1
602
+ else:
603
+ kept.extend((source, target))
604
+ if len(entries) % 2:
605
+ kept.append(entries[-1])
606
+ return kept, removed
607
+
608
+
609
+ def _cleanup_pending_shim_renames(scripts_dir: Path) -> int:
610
+ """Drop reboot renames older Hermes versions queued for our shims: ``MOVEFILE_DELAY_UNTIL_REBOOT``
611
+ fallbacks outlive the update that queued them and move away whatever sits at the shim path
612
+ at next boot — even a shim a later repair just wrote. Needs elevation; a no-op otherwise."""
613
+ if not _is_windows():
614
+ return 0
615
+ try:
616
+ import winreg
617
+ with winreg.OpenKey(
618
+ winreg.HKEY_LOCAL_MACHINE, _PENDING_RENAME_KEY, 0,
619
+ winreg.KEY_QUERY_VALUE | winreg.KEY_SET_VALUE,
620
+ ) as key:
621
+ entries, value_type = winreg.QueryValueEx(key, _PENDING_RENAME_VALUE)
622
+ if value_type != winreg.REG_MULTI_SZ or not isinstance(entries, list):
623
+ return 0
624
+ kept, removed = _filter_pending_shim_renames(entries, _hermes_exe_shims(scripts_dir))
625
+ if not removed:
626
+ return 0
627
+ if kept:
628
+ winreg.SetValueEx(key, _PENDING_RENAME_VALUE, 0, winreg.REG_MULTI_SZ, kept)
629
+ else:
630
+ winreg.DeleteValue(key, _PENDING_RENAME_VALUE)
631
+ return removed
632
+ except (OSError, ValueError):
633
+ return 0
634
+
635
+
636
+ def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None:
637
+ """Roll back ``_quarantine_running_hermes_exe`` if uv didn't write replacements. Safety-
638
+ critical: a failed quarantine only aborts an update; a failed restore leaves no ``hermes``
639
+ on PATH. Delegates to the stdlib-only retrying helper shared with ``_install_repair``.
640
+
641
+ The outbound rename already retries a lock, so this one must too rather than swallow the first
642
+ ``OSError`` in silence. See #75584.
643
+ """
644
+ _early_recovery_mod.restore_quarantined_shims(moved)
645
+
646
+
647
+ class ShimQuarantineError(RuntimeError):
648
+ """A live ``hermes*.exe`` shim could not be renamed aside. Raised by
649
+ :func:`_run_quarantined_install` in ``strict_quarantine`` mode BEFORE the install runs: a
650
+ process holds the venv hard enough that the sync would die partway — refuse, don't warn.
651
+
652
+ See #87331.
653
+ """
654
+
655
+ def __init__(self, failed_shims: list[str]):
656
+ self.failed_shims = list(failed_shims)
657
+ super().__init__("could not quarantine live shim(s): " + ", ".join(self.failed_shims))
658
+
659
+
660
+ def _run_quarantined_install(
661
+ cmd: list[str], *, env: dict[str, str] | None = None, scripts_dir: Path | None = None,
662
+ strict_quarantine: bool = False,
663
+ ) -> None:
664
+ """Run an editable install, quarantining the running ``hermes.exe`` first.
665
+
666
+ Every editable install rewrites the entry-point shims; on Windows the live ``hermes.exe``
667
+ can be neither deleted nor overwritten, so without quarantine ``hermes`` drops off PATH.
668
+ ``strict_quarantine=True`` (the update dependency sync): a shim whose rename failed every
669
+ retry proves a hard venv hold — the install WILL hit the same lock on .pyd files — so roll
670
+ back and raise :class:`ShimQuarantineError` without installing. Non-strict callers already
671
+ mutated the venv, so refusing buys nothing. ``scripts_dir is None`` is a pass-through.
672
+
673
+ See #87331.
674
+ """
675
+ moved: list[tuple[Path, Path]] = []
676
+ failed: list[str] = []
677
+ if scripts_dir is not None:
678
+ moved = _quarantine_running_hermes_exe(scripts_dir, failed_out=failed)
679
+ if strict_quarantine and failed:
680
+ _restore_quarantined_exes(moved)
681
+ raise ShimQuarantineError(failed)
682
+ try:
683
+ _run_install_with_heartbeat(cmd, env=env)
684
+ finally:
685
+ # Restore on FAILURE and SUCCESS: an already-satisfied editable install is a uv no-op
686
+ # that rewrites no entry points. Skips shims the installer replaced; finally re-raises.
687
+ if scripts_dir is not None:
688
+ _restore_quarantined_exes(moved)
689
+
690
+
691
+ # A quarantine file younger than this may belong to an update running RIGHT NOW in
692
+ # another process, whose restore step still needs it — the only copy of that shim.
693
+ # Restore shims when the installer didn't write replacements — on FAILURE (install died before the
694
+ # entry-points step) and on SUCCESS too: uv audits an already-satisfied editable install as a no-op and
695
+ # rewrites no entry points, which would otherwise leave the shims quarantined aside and `hermes` missing
696
+ # from PATH after a green install (#75584). _restore_quarantined_exes skips any shim the installer actually
697
+ # replaced, so this never clobbers fresh output. Errors are not swallowed — the finally re-raises whatever
698
+ # escaped.
699
+ _QUARANTINE_GRACE_SECONDS = 15 * 60
700
+
701
+
702
+ def _quarantine_stamp_ms(stale: Path) -> int | None:
703
+ """The ``.old.<unix-ms>`` stamp in a quarantine filename; ``None`` (not ours — neither rescued
704
+ nor deleted) otherwise. Parsed from the NAME, not ``st_mtime``: ``rename`` preserves the
705
+ shim's mtime (when uv wrote it), not when it was quarantined."""
706
+ try:
707
+ return int(stale.name.rsplit(".old.", 1)[1])
708
+ except (IndexError, ValueError):
709
+ return None
710
+
711
+
712
+ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
713
+ """Sweep — and where necessary RESCUE — ``hermes.exe.old.*`` from updates.
714
+
715
+ Called early on every invocation. Two cases an unconditional ``unlink()`` gets wrong:
716
+ (1) orphan rescue — ``hermes.exe`` missing while ``hermes.exe.old.*`` exists means the .old
717
+ file is the ONLY surviving copy (update died between rename and uv's write); put it back via
718
+ the same retry-and-report helper the update-time restore uses. (2) concurrency — a fresh
719
+ quarantine file may belong to an update in flight in another process; leave anything inside
720
+ the grace window alone. Silent no-op on non-Windows, nothing to do, or locked/permission errors.
721
+
722
+ Deleting it converts a one-rename recovery into a full reinstall. See #75584.
723
+ """
724
+ if not _is_windows():
725
+ return
726
+ scripts_dir = scripts_dir if scripts_dir is not None else _venv_scripts_dir()
727
+ if scripts_dir is None:
728
+ return
729
+ _cleanup_pending_shim_renames(scripts_dir)
730
+ now = _time.time()
731
+ try:
732
+ candidates = [
733
+ (stamp, stale) for stale in scripts_dir.glob("*.exe.old.*")
734
+ if (stamp := _quarantine_stamp_ms(stale)) is not None]
735
+ except OSError:
736
+ return
737
+ # Newest first by PARSED stamp: lexicographic order breaks when a stray ``.old.999`` exists.
738
+ candidates.sort(key=lambda pair: pair[0], reverse=True)
739
+ for stamp, stale in candidates:
740
+ try:
741
+ original = stale.with_name(stale.name.rsplit(".old.", 1)[0])
742
+ if not original.exists():
743
+ # Orphan rescue: last copy of the shim — retry ladder + recovery message.
744
+ _early_recovery_mod.restore_quarantined_shims([(original, stale)])
745
+ continue
746
+ if now - stamp / 1000.0 < _QUARANTINE_GRACE_SECONDS:
747
+ continue # may be a live quarantine from a concurrent update
748
+ stale.unlink()
749
+ except OSError:
750
+ pass # still locked or in use — try again next run
751
+
752
+
753
+ # Import probes for venv corruption after a failed lazy ``uv pip install`` (metadata can
754
+ # look fine while ``.py`` files were removed mid-install). Canonical tables live in the
755
+ # stdlib-only ``_early_recovery`` module so the early and full recovery layers never drift.
756
+ # See #57828.
757
+ _LAZY_REFRESH_IMPORT_PROBES: tuple[tuple[str, str], ...] = (
758
+ _early_recovery_mod.LAZY_REFRESH_IMPORT_PROBES)
759
+ _LAZY_REFRESH_REPAIR_PACKAGES: dict[str, str] = _early_recovery_mod.LAZY_REFRESH_REPAIR_PACKAGES
760
+
761
+
762
+ def _run_package_only_install(cmd: list[str], *, env: dict[str, str] | None = None) -> None:
763
+ """Package-only pip/uv install — no shim quarantine: ``--force-reinstall <pkg>`` never rewrites
764
+ ``hermes.exe``, and the quarantine path would rename shims uv then never recreates.
765
+
766
+ See #57828.
767
+ """
768
+ _run_install_with_heartbeat(cmd, env=env)
769
+
770
+
771
+ def _lazy_refresh_repair_specs(packages: list[str]) -> list[str]:
772
+ """Map repair package names to their declared pin specs in pyproject.toml."""
773
+ project = _pyproject_project("lazy refresh repair spec lookup failed: %s")
774
+ if project is None:
775
+ return packages
776
+ name_to_spec = {
777
+ name.lower(): head
778
+ for name, _, head in _parse_requirements(project.get("dependencies", []) or [])}
779
+ return [name_to_spec.get(pkg.lower(), pkg) for pkg in packages]
780
+
781
+
782
+ def _venv_probe(venv_python: Path, script: str, *args: str, env: dict[str, str] | None):
783
+ """Run ``script`` in the target venv's interpreter, capturing UTF-8 stdout."""
784
+ return subprocess.run(
785
+ [str(venv_python), "-c", script, *args],
786
+ capture_output=True,
787
+ text=True, encoding="utf-8", errors="replace",
788
+ check=False,
789
+ env=env)
790
+
791
+
792
+ def _nonblank_lines(text: str) -> list[str]:
793
+ return [line.strip() for line in text.splitlines() if line.strip()]
794
+
795
+
796
+ def _detect_broken_lazy_refresh_imports(
797
+ install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> list[str] | None:
798
+ """Probe lazy-refresh packages via real imports: ``[]`` all clean, ``[dist, ...]`` failures,
799
+ ``None`` when the probe could not run (no venv Python, subprocess failure, non-zero exit)
800
+ — *indeterminate*, not healthy."""
801
+ venv_python = _resolve_install_target_python(install_cmd_prefix, env)
802
+ if venv_python is None:
803
+ return None
804
+ probe_lines = "\n".join(
805
+ f" ({mod!r}, {attr!r})," for mod, attr in _LAZY_REFRESH_IMPORT_PROBES)
806
+ check_script = (
807
+ "import os\n"
808
+ "import sys\n"
809
+ "probes = [\n"
810
+ f"{probe_lines}\n"
811
+ "]\n"
812
+ "broken = []\n"
813
+ "for mod, attr in probes:\n"
814
+ " try:\n"
815
+ " imported = __import__(mod)\n"
816
+ " if not hasattr(imported, attr):\n"
817
+ " broken.append(mod)\n"
818
+ " elif mod == 'certifi':\n"
819
+ " # The module can import cleanly while cacert.pem is\n"
820
+ " # missing/corrupt (brew Python upgrade, interrupted venv\n"
821
+ " # rebuild) - every TLS call then fails (#29866).\n"
822
+ " bundle = imported.where()\n"
823
+ " if not os.path.isfile(bundle) or os.path.getsize(bundle) < 1024:\n"
824
+ " broken.append(mod)\n"
825
+ " except Exception:\n"
826
+ " broken.append(mod)\n"
827
+ "print('\\n'.join(broken))\n")
828
+ try:
829
+ result = _venv_probe(venv_python, check_script, env=env)
830
+ except Exception as exc:
831
+ logger.debug("lazy refresh import probe failed: %s", exc)
832
+ return None
833
+ if result.returncode != 0:
834
+ logger.debug("lazy refresh import probe exited %s: %s",
835
+ result.returncode, (result.stderr or "")[:200])
836
+ return None
837
+ packages: list[str] = []
838
+ for mod in _nonblank_lines(result.stdout):
839
+ pkg = _LAZY_REFRESH_REPAIR_PACKAGES.get(mod)
840
+ if pkg and pkg not in packages:
841
+ packages.append(pkg)
842
+ return packages
843
+
844
+
845
+ def _repair_broken_lazy_refresh_imports(
846
+ install_cmd_prefix: list[str], packages: list[str], *, env: dict[str, str] | None = None
847
+ ) -> bool:
848
+ """Force-reinstall ``packages`` and re-probe imports. Never raises."""
849
+ if not packages:
850
+ return True
851
+ specs = _lazy_refresh_repair_specs(packages)
852
+ if not _run_repair_step(
853
+ _run_package_only_install, install_cmd_prefix + ["install", "--force-reinstall", *specs],
854
+ env=env, log_msg="lazy refresh venv repair failed: %s", fail_msg=None):
855
+ return False
856
+ # Indeterminate re-probe is not confirmed success.
857
+ return _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env) == []
858
+
859
+
860
+ def _repair_venv_via_import_probes(
861
+ install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> str:
862
+ """Probe imports and force-reinstall any broken lazy-refresh packages.
863
+
864
+ Real ``import`` checks (not distribution metadata) catch a venv where METADATA remains
865
+ but ``.py`` files were wiped mid-install. Package-only reinstall — never rewrites
866
+ ``hermes.exe``. Never raises. Returns ``"healthy"``, ``"repaired"``, ``"failed"``
867
+ (repair did not confirm clean) or ``"indeterminate"`` (probes could not run; NOT healthy).
868
+
869
+ See #57828.
870
+ """
871
+ broken = _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env)
872
+ if broken is None:
873
+ print(" ⚠ Import probes unavailable — cannot confirm venv package health.")
874
+ return "indeterminate"
875
+ if not broken:
876
+ return "healthy"
877
+ print(
878
+ " → Detected corrupted venv packages via import probes: "
879
+ f"{', '.join(broken)}; repairing...")
880
+ if _repair_broken_lazy_refresh_imports(install_cmd_prefix, broken, env=env):
881
+ print(" ✓ Venv repair succeeded")
882
+ return "repaired"
883
+ manual = " ".join(shlex.quote(s) for s in _lazy_refresh_repair_specs(broken))
884
+ print(" ⚠ Venv repair incomplete. Run manually, then `hermes update`:")
885
+ print(f" {' '.join(install_cmd_prefix)} install --force-reinstall {manual}")
886
+ return "failed"
887
+
888
+
889
+ def _is_uv_command(install_cmd_prefix: list[str]) -> bool:
890
+ """True for a uv/uvx binary (bare or path) or ``python -m uv`` / ``python -m uvx``."""
891
+ if not install_cmd_prefix:
892
+ return False
893
+ first = str(install_cmd_prefix[0]).lower()
894
+ if "uv" in Path(first).name:
895
+ return True
896
+ return (
897
+ len(install_cmd_prefix) >= 3
898
+ and first.endswith(("python", "python.exe"))
899
+ and install_cmd_prefix[1] == "-m"
900
+ and install_cmd_prefix[2] in ("uv", "uvx"))
901
+
902
+
903
+ def _insert_python_pin(args: list[str]) -> list[str]:
904
+ """Insert ``--python <sys.executable>`` into a uv command line; an explicit caller ``--python`` wins."""
905
+ if "--python" in args:
906
+ return args
907
+ return [args[0], "--python", str(sys.executable), *args[1:]]
908
+
909
+
910
+ def _interpreter_scripts_dir() -> Path | None:
911
+ """Scripts/bin dir of ``sys.executable``: on a site-packages install ``PROJECT_ROOT/venv``
912
+ does not exist and the shims uv rewrites live next to the interpreter. Layout via the
913
+ canonical ``venv_bin_dir`` (hand-rolling Scripts/bin is lint-tested against).
914
+
915
+ See #76105.
916
+ """
917
+ from hermes_constants import venv_bin_dir
918
+ exe = Path(sys.executable)
919
+ # sys.executable lives IN the bin/Scripts dir; parent.parent is the env root.
920
+ cand = venv_bin_dir(exe.parent.parent, windows=_is_windows())
921
+ if cand.is_dir():
922
+ return cand
923
+ return exe.parent if exe.parent.is_dir() else None
924
+
925
+
926
+ def _install_python_dependencies_with_optional_fallback(
927
+ install_cmd_prefix: list[str], *, env: dict[str, str] | None = None, group: str = "all"
928
+ ) -> None:
929
+ """Install base deps plus as many optional extras as the environment supports.
930
+
931
+ Targets ``.[all]`` by default; Termux callers pass ``group='termux-all'``. On Windows every
932
+ attempt quarantines the live ``hermes*.exe`` shims first. When ``env`` carries a
933
+ ``VIRTUAL_ENV`` that does not exist (pip / site-packages install), ``uv pip`` fails with
934
+ ``Failed to inspect Python interpreter from active virtual environment`` before doing any
935
+ work — pin the install at the running interpreter instead.
936
+
937
+ Pin the install at the running interpreter instead so the update/recovery path succeeds on those
938
+ installs (#71510 fixed the ZIP path, #83335 fixed lazy-deps; this closes the shared helper for the
939
+ remaining callers).
940
+ """
941
+ scripts_dir = _venv_scripts_dir() if _is_windows() else None
942
+
943
+ # Only uv needs the explicit pin; pip resolves the target from sys.executable itself.
944
+ pin_python = bool(
945
+ env and env.get("VIRTUAL_ENV") and not Path(env["VIRTUAL_ENV"]).is_dir()
946
+ and install_cmd_prefix and _is_uv_command(install_cmd_prefix))
947
+ if pin_python:
948
+ env = {**env}
949
+ env.pop("VIRTUAL_ENV", None)
950
+ # Pinned to sys.executable, the shims uv rewrites live in THAT interpreter's Scripts
951
+ # dir, not PROJECT_ROOT/venv; quarantining the wrong dir leaves hermes.exe locked.
952
+ if scripts_dir is None and _is_windows():
953
+ scripts_dir = _interpreter_scripts_dir()
954
+
955
+ def _install(args: list[str]) -> None:
956
+ if pin_python:
957
+ args = _insert_python_pin(args)
958
+ # strict_quarantine: this is the UPDATE dependency sync; ShimQuarantineError propagates
959
+ # to the sync boundary, which defers via the update-incomplete marker instead.
960
+ # A shim that cannot be renamed aside proves a hard venv hold; running uv anyway is how installs
961
+ # strand half-updated (#87331).
962
+ _run_quarantined_install(
963
+ install_cmd_prefix + args, env=env, scripts_dir=scripts_dir, strict_quarantine=True)
964
+
965
+ try:
966
+ _install(["install", "-e", f".[{group}]"])
967
+ _verify_console_scripts_installed(install_cmd_prefix, env=env)
968
+ return
969
+ except subprocess.CalledProcessError:
970
+ print(
971
+ " ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..."
972
+ )
973
+
974
+ _install(["install", "-e", "."])
975
+ failed_extras: list[str] = []
976
+ installed_extras: list[str] = []
977
+ for extra in _load_installable_optional_extras(group=group):
978
+ try:
979
+ _install(["install", "-e", f".[{extra}]"])
980
+ installed_extras.append(extra)
981
+ except subprocess.CalledProcessError:
982
+ failed_extras.append(extra)
983
+ if installed_extras:
984
+ print(f" ✓ Reinstalled optional extras individually: {', '.join(installed_extras)}")
985
+ if failed_extras:
986
+ print(f" ⚠ Skipped optional extras that still failed: {', '.join(failed_extras)}")
987
+ _warn_configured_features_missing_deps(install_cmd_prefix, env=env)
988
+ # uv's incremental resolver has left newly added base deps silently missing on a half-stale
989
+ # venv, surfacing hours later as a downstream ModuleNotFoundError. Verify here instead.
990
+ _verify_core_dependencies_installed(install_cmd_prefix, env=env, group=group)
991
+ _verify_console_scripts_installed(install_cmd_prefix, env=env)
992
+
993
+
994
+ _CONFIGURED_FEATURES_SCRIPT = (
995
+ "import importlib.util\n"
996
+ "try:\n"
997
+ " from gateway.config import load_gateway_config\n"
998
+ " from gateway.platform_registry import platform_registry\n"
999
+ " for platform in load_gateway_config().get_connected_platforms():\n"
1000
+ " entry = platform_registry.get(platform.value)\n"
1001
+ " if entry is not None and not entry.check_fn():\n"
1002
+ " print(entry.label + '\\t' + (entry.install_hint or 'reinstall the %r extra' % platform.value))\n"
1003
+ "except Exception:\n"
1004
+ " pass\n"
1005
+ "try:\n"
1006
+ " from hermes_cli.config import load_config\n"
1007
+ " if (load_config().get('mcp_servers') or {}) and importlib.util.find_spec('mcp') is None:\n"
1008
+ " print('MCP servers\\tinstall the ' + repr('mcp') + ' extra')\n"
1009
+ "except Exception:\n"
1010
+ " pass\n"
1011
+ )
1012
+
1013
+
1014
+ def _configured_features_missing_deps(
1015
+ install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> list[tuple[str, str]]:
1016
+ """``(feature, hint)`` for every configured platform / MCP whose optional deps are not importable
1017
+ in the TARGET venv. A running gateway masks the gap until its next restart (#10651), so the
1018
+ update has to say which configured feature will fail to load. Probed in a fresh interpreter:
1019
+ the updater's own process may be the outer Python and its import caches predate the install,
1020
+ so an in-process ``check_fn`` reports stale state."""
1021
+ venv_python = _resolve_install_target_python(install_cmd_prefix, env)
1022
+ if venv_python is None:
1023
+ return []
1024
+ try:
1025
+ result = _venv_probe(venv_python, _CONFIGURED_FEATURES_SCRIPT, env=env)
1026
+ except Exception as exc: # the update must finish even when the probe cannot run
1027
+ logger.debug("configured-feature dependency check skipped: %s", exc)
1028
+ return []
1029
+ missing: list[tuple[str, str]] = []
1030
+ for line in _nonblank_lines(result.stdout):
1031
+ feature, _, hint = line.partition("\t")
1032
+ missing.append((feature, hint or "reinstall its optional extra"))
1033
+ return missing
1034
+
1035
+
1036
+ def _warn_configured_features_missing_deps(install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None:
1037
+ missing = _configured_features_missing_deps(install_cmd_prefix, env=env)
1038
+ if not missing:
1039
+ return
1040
+ prefix = " ".join(shlex.quote(part) for part in install_cmd_prefix)
1041
+ print(" ⚠ Configured features whose dependencies are still missing — the gateway will fail to load them on restart:")
1042
+ for feature, hint in missing:
1043
+ print(f" - {feature}: {hint}")
1044
+ print(f" Retry with: {prefix} install -e '.[all]'")
1045
+
1046
+
1047
+ def _load_console_script_names() -> list[str]:
1048
+ """Return ``[project.scripts]`` entry-point names from pyproject.toml."""
1049
+ project = _pyproject_project("console script verification: failed to read pyproject.toml: %s")
1050
+ scripts = (project or {}).get("scripts", {}) or {}
1051
+ return [str(name) for name in scripts if name]
1052
+
1053
+
1054
+ def _verify_console_scripts_installed(
1055
+ install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None:
1056
+ """Ensure every declared console_script shim exists on disk after install.
1057
+
1058
+ On Windows ``uv pip install -e .`` can register ``hermes.exe`` in the wheel RECORD while the
1059
+ file never lands (live shim locked, launcher write skipped), so ``hermes`` drops off PATH
1060
+ after a "successful" install. Missing shims get ``--reinstall -e .`` under quarantine.
1061
+
1062
+ The symptom is ``hermes-agent.exe`` and ``hermes-acp.exe`` present but ``hermes.exe`` missing, so
1063
+ ``hermes`` drops off PATH even though the install reported success (issue #52931).
1064
+ """
1065
+ if not _is_windows():
1066
+ return
1067
+ scripts_dir = _venv_scripts_dir()
1068
+ names = _load_console_script_names() if scripts_dir is not None else []
1069
+ if not names:
1070
+ return
1071
+
1072
+ def _missing() -> list[str]:
1073
+ return [name for name in names if not (scripts_dir / f"{name}.exe").is_file()]
1074
+
1075
+ missing = _missing()
1076
+ if not missing:
1077
+ return
1078
+ print(
1079
+ f" ⚠ Verification: {len(missing)} console script(s) missing on disk: "
1080
+ f"{', '.join(missing)}")
1081
+ print(" → Reinstalling entry points with --reinstall...")
1082
+ if not _run_repair_step(
1083
+ _run_quarantined_install, install_cmd_prefix + ["install", "--reinstall", "-e", "."],
1084
+ env=env, scripts_dir=scripts_dir,
1085
+ log_msg="console script verification: repair install failed: %s",
1086
+ fail_msg=(
1087
+ " ⚠ Entry point repair failed; try `hermes update --force` after "
1088
+ "closing other hermes processes.")):
1089
+ return
1090
+ _report_still_missing(
1091
+ _missing(), "Workaround: python -m hermes_cli.main <command>",
1092
+ ok=" ✓ All console entry points restored")
1093
+
1094
+
1095
+ def _applicable_dependency_names(raw_deps: list[str]) -> list[str]:
1096
+ """Declared dep names whose ``;`` markers apply here (else ``ptyprocess ; sys_platform !=
1097
+ 'win32'`` would false-positive on Windows). An unevaluable marker counts as applicable."""
1098
+ applicable: list[str] = []
1099
+ for name, marker, _ in _parse_requirements(raw_deps):
1100
+ try:
1101
+ if marker is None or marker.evaluate(): # type: ignore[union-attr]
1102
+ applicable.append(name)
1103
+ except Exception:
1104
+ applicable.append(name)
1105
+ return applicable
1106
+
1107
+
1108
+ _MISSING_DEPS_SCRIPT = (
1109
+ "import importlib.metadata as md, sys\n"
1110
+ "missing=[]\n"
1111
+ "for name in sys.argv[1:]:\n"
1112
+ " try: md.version(name)\n"
1113
+ " except md.PackageNotFoundError: missing.append(name)\n"
1114
+ "print('\\n'.join(missing))\n")
1115
+
1116
+
1117
+ def _verify_core_dependencies_installed(
1118
+ install_cmd_prefix: list[str], *, env: dict[str, str] | None = None, group: str = "all"
1119
+ ) -> None:
1120
+ """Check that every base dep from pyproject.toml is installed in the target venv; if not, retry.
1121
+
1122
+ Reads ``pyproject.toml`` directly (not the venv's stale metadata), drops deps whose ``;``
1123
+ markers don't apply here, and probes ``importlib.metadata.version()`` in the venv
1124
+ interpreter. Missing deps trigger a base-group ``--reinstall``, then a per-package force
1125
+ install. The final state is a warning, not a hard failure, so one broken-on-PyPI dep can't
1126
+ block an otherwise-successful update — but the partial install is visible where it happened.
1127
+ """
1128
+ project = _pyproject_project("dep verification: failed to read pyproject.toml: %s")
1129
+ if project is None:
1130
+ return
1131
+ raw_deps = project.get("dependencies", []) or []
1132
+ applicable = _applicable_dependency_names(raw_deps)
1133
+ if not applicable:
1134
+ return
1135
+ # Probe inside the venv Python — sys.executable may be the outer Python that drove
1136
+ # ``hermes update``; the install prefix/env encode which environment we targeted.
1137
+ venv_python = _resolve_install_target_python(install_cmd_prefix, env)
1138
+ if venv_python is None:
1139
+ return
1140
+
1141
+ def _missing_deps() -> list[str]:
1142
+ try:
1143
+ result = _venv_probe(venv_python, _MISSING_DEPS_SCRIPT, *applicable, env=env)
1144
+ except Exception as e:
1145
+ logger.debug("dep verification: subprocess failed: %s", e)
1146
+ return []
1147
+ return _nonblank_lines(result.stdout)
1148
+
1149
+ missing = _missing_deps()
1150
+ if not missing:
1151
+ return
1152
+ print(
1153
+ f" ⚠ Verification: {len(missing)} declared dep(s) missing after install: "
1154
+ f"{', '.join(missing[:8])}{'...' if len(missing) > 8 else ''}")
1155
+ print(" → Reinstalling base group with --reinstall to repair...")
1156
+ # Base group only, not ``[{group}]``: the missing dep is a *base* dep and the all-extras
1157
+ # install costs minutes. Quarantine first: ``--reinstall -e .`` rewrites the shims.
1158
+ scripts_dir = _venv_scripts_dir() if _is_windows() else None
1159
+ if not _run_repair_step(
1160
+ _run_quarantined_install, install_cmd_prefix + ["install", "--reinstall", "-e", "."],
1161
+ env=env, scripts_dir=scripts_dir,
1162
+ log_msg="dep verification: repair install failed: %s",
1163
+ fail_msg=" ⚠ Repair install failed; check `hermes update` output above."):
1164
+ return
1165
+ still_missing = _missing_deps()
1166
+ if not still_missing:
1167
+ print(" ✓ All declared core dependencies now installed")
1168
+ return
1169
+ # Last-ditch: install each remaining missing dep with its pin directly — uv's
1170
+ # resolver can think the env is satisfied while on-disk metadata disagrees.
1171
+ name_to_spec = dict(_naive_requirement(spec) for spec in raw_deps)
1172
+ specs = [name_to_spec.get(n, n) for n in still_missing]
1173
+ print(f" → Force-installing remaining missing dep(s): {', '.join(specs)}")
1174
+ if not _run_repair_step(
1175
+ _run_install_with_heartbeat, install_cmd_prefix + ["install", "--reinstall", *specs],
1176
+ env=env,
1177
+ log_msg="dep verification: per-package repair failed: %s",
1178
+ fail_msg=(
1179
+ f" ⚠ Could not install: {', '.join(still_missing)}. "
1180
+ "Run `hermes update --force` after closing other hermes processes.")):
1181
+ return
1182
+ _report_still_missing(
1183
+ _missing_deps(), "Run `hermes update --force` after closing other hermes processes.",
1184
+ ok=" ✓ All declared core dependencies now installed")
1185
+
1186
+
1187
+ def _resolve_install_target_python(
1188
+ install_cmd_prefix: list[str], env: dict[str, str] | None) -> Path | None:
1189
+ """Python interpreter the install targeted: ``VIRTUAL_ENV`` from ``env`` for the
1190
+ ``[uv, pip]`` shape, else ``install_cmd_prefix[0]`` for ``[sys.executable, -m, pip]``."""
1191
+ if env and "VIRTUAL_ENV" in env:
1192
+ from hermes_constants import venv_python_path
1193
+ candidate = venv_python_path(Path(env["VIRTUAL_ENV"]), windows=_is_windows())
1194
+ if candidate.exists():
1195
+ return candidate
1196
+ if install_cmd_prefix:
1197
+ first = Path(install_cmd_prefix[0])
1198
+ if first.exists() and "uv" not in first.name.lower():
1199
+ return first
1200
+ return None
1201
+
1202
+
1203
+ def _is_termux_env(env: dict[str, str] | None = None) -> bool:
1204
+ from hermes_cli.main import _is_termux_startup_environment
1205
+ return _is_termux_startup_environment(env)
1206
+
1207
+
1208
+ def _is_windows_npm_path(npm_path: str) -> bool:
1209
+ """True if ``npm_path`` points at a Windows npm shim (WSL ``/mnt/c`` interop, ``.cmd``/``.exe``, UNC).
1210
+
1211
+ Callers use this only on a POSIX host — on native Windows ``npm.cmd`` is correct.
1212
+ """
1213
+ low = npm_path.lower()
1214
+ return low.endswith((".exe", ".cmd", ".bat")) or low.startswith("/mnt/") or "\\" in npm_path
1215
+
1216
+
1217
+ def _resolve_node_runtime_npm() -> str | None:
1218
+ """Resolve an npm executable that belongs to the host's Node runtime.
1219
+
1220
+ On WSL, PATH interop can hand back a Windows npm that fails with EISDIR / symlink errors over
1221
+ ``\\\\wsl.localhost\\...`` UNC paths. Refuse it on a POSIX host and re-scan PATH minus the
1222
+ ``/mnt/*`` drive mounts. ``None`` when no suitable npm is reachable.
1223
+
1224
+ On WSL/Linux ``shutil.which("npm")`` may resolve a Windows npm exposed through PATH interop. See #30271.
1225
+ """
1226
+ from hermes_constants import find_node_executable
1227
+ npm = find_node_executable("npm")
1228
+ if _is_windows():
1229
+ return npm
1230
+ if not npm:
1231
+ return None
1232
+ if not _is_windows_npm_path(npm):
1233
+ return npm
1234
+ for directory in os.environ.get("PATH", "").split(os.pathsep):
1235
+ if not directory or directory.lower().startswith("/mnt/"):
1236
+ continue
1237
+ candidate = shutil.which("npm", path=directory)
1238
+ if candidate and not _is_windows_npm_path(candidate):
1239
+ return candidate
1240
+ return None
1241
+
1242
+
1243
+ def _resolve_update_branch(args) -> str:
1244
+ """Normalize ``args.branch`` to a non-empty name (default ``main``; blank/whitespace = default)."""
1245
+ return (getattr(args, "branch", None) or "main").strip() or "main"
hermes_cli/main_provider_setup.py ADDED
@@ -0,0 +1,946 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider setup wizard helpers: provider picker, custom-provider save/remove, auxiliary-model
2
+ routing menu, API-key/reasoning prompts, Anthropic OAuth.
3
+
4
+ Split out of ``hermes_cli/main.py``. Names that still live in main are imported lazily at call time.
5
+ """
6
+
7
+ import contextlib
8
+
9
+ from typing import Optional
10
+ from hermes_cli.model_setup_flows_common import _ask, _ensure_dict_section, _print_numbered, _radiolist, _say
11
+
12
+
13
+ def _is_profile_api_key_provider(provider_id: str) -> bool:
14
+ """True when *provider_id* maps to a profile with ``auth_type='api_key'`` — the catch-all
15
+ in select_provider_and_model() so plugin providers dispatch to the generic key flow."""
16
+ try:
17
+ from providers import get_provider_profile
18
+ _p = get_provider_profile(provider_id)
19
+ return _p is not None and _p.auth_type == "api_key"
20
+ except Exception:
21
+ return False
22
+
23
+
24
+ _GENERIC_API_KEY_PROVIDERS = frozenset({
25
+ "openai-api", "gemini", "deepseek", "xai", "zai", "kimi-coding-cn",
26
+ "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go",
27
+ "opencode-free", "alibaba", "huggingface", "xiaomi", "arcee", "gmi",
28
+ "nvidia", "ollama-cloud", "tencent-tokenhub", "tencent-tokenplan", "lmstudio"})
29
+
30
+
31
+ def _short_url(url: str) -> str:
32
+ """``https://host/path/`` -> ``host/path`` for menu labels."""
33
+ return url.replace("https://", "").replace("http://", "").rstrip("/")
34
+
35
+
36
+ def _clear_stale_openai_base_url():
37
+ """Remove OPENAI_BASE_URL from ~/.hermes/.env unless the active provider is 'custom' — a
38
+ leftover value routes provider:auto auxiliary clients to the old custom endpoint."""
39
+ from hermes_cli.config import get_env_value, save_env_value, load_config
40
+ model_cfg = load_config().get("model", {})
41
+ provider = (model_cfg.get("provider") or "").strip().lower() if isinstance(model_cfg, dict) else ""
42
+ if provider == "custom" or not provider:
43
+ return # custom provider legitimately uses OPENAI_BASE_URL
44
+
45
+ stale_url = get_env_value("OPENAI_BASE_URL")
46
+ if stale_url:
47
+ save_env_value("OPENAI_BASE_URL", "")
48
+ shown = f"{stale_url[:40]}..." if len(stale_url) > 40 else stale_url
49
+ print(f"Cleared stale OPENAI_BASE_URL from .env (was: {shown})")
50
+
51
+
52
+ # (task_key, display_name, short_description)
53
+ _AUX_TASKS: list[tuple[str, str, str]] = [
54
+ ("vision", "Vision", "image/screenshot analysis"),
55
+ ("compression", "Compression", "context summarization"),
56
+ ("approval", "Approval", "smart command approval"),
57
+ ("mcp", "MCP", "MCP tool reasoning"),
58
+ ("title_generation", "Title generation", "session titles"),
59
+ ("review", "Review", "/review reviewer subagent"),
60
+ ("memory_query_rewrite", "Memory query rewrite", "memory retrieval queries"),
61
+ ("tts_audio_tags", "TTS audio tags", "Gemini TTS tag insertion"),
62
+ ("skills_hub", "Skills hub", "skills search/install"),
63
+ ("triage_specifier", "Triage specifier", "kanban spec fleshing"),
64
+ ("kanban_decomposer", "Kanban decomposer", "task decomposition"),
65
+ ("profile_describer", "Profile describer", "auto profile descriptions"),
66
+ ("curator", "Curator", "skill-usage review pass")]
67
+
68
+ # Special non-auxiliary task surfaced in the same picker: subagent delegation. Routing lives
69
+ # under top-level `delegation.*` (NOT `auxiliary.delegation`) because delegate_task spawns full
70
+ # child agents via tools/delegate_tool.py::_resolve_delegation_credentials(), which reads that
71
+ # section directly. "auto" means "inherit the parent agent" and is stored as empty strings —
72
+ # never persist the literal "auto", or it would be resolved as a provider name.
73
+ _DELEGATION_TASK_KEY = "delegation"
74
+ _DELEGATION_TASK_NAME = "Delegation"
75
+ _DELEGATION_TASK_DESC = "subagent model (delegate_task)"
76
+
77
+
78
+ def _all_aux_tasks() -> list[tuple[str, str, str]]:
79
+ """Built-in aux tasks (in order) followed by plugin-registered ones
80
+ (:meth:`hermes_cli.plugins.PluginContext.register_auxiliary_task`)."""
81
+ tasks = list(_AUX_TASKS)
82
+ # Plugin discovery failure must not break the aux config UI.
83
+ with contextlib.suppress(Exception):
84
+ from hermes_cli.plugins import get_plugin_auxiliary_tasks
85
+ for entry in get_plugin_auxiliary_tasks():
86
+ tasks.append((entry["key"], entry["display_name"], entry["description"]))
87
+ return tasks
88
+
89
+
90
+ def _format_aux_current(task_cfg: dict) -> str:
91
+ """Render the current aux config for display in the task menu."""
92
+ if not isinstance(task_cfg, dict):
93
+ return "auto"
94
+ base_url = str(task_cfg.get("base_url") or "").strip()
95
+ provider = str(task_cfg.get("provider") or "auto").strip() or "auto"
96
+ model = str(task_cfg.get("model") or "").strip()
97
+ effort = _aux_effort_word(task_cfg)
98
+ suffix = (f" · {model}" if model else "") + (f" · {effort}" if effort else "")
99
+ if base_url:
100
+ return f"custom ({_short_url(base_url)})" + suffix
101
+ if provider == "auto":
102
+ return "auto" + suffix
103
+ if model:
104
+ return f"{provider}{suffix}"
105
+ return provider + suffix
106
+
107
+
108
+ def _aux_effort_word(task_cfg: dict) -> str:
109
+ """The stored ``reasoning_effort`` as a display word ("" = provider default; YAML False = "none")."""
110
+ raw = task_cfg.get("reasoning_effort") if isinstance(task_cfg, dict) else None
111
+ if raw is False:
112
+ return "none"
113
+ return str(raw or "").strip().lower()
114
+
115
+
116
+ def _delegation_cfg_as_task(cfg: dict) -> dict:
117
+ """Project the top-level ``delegation`` section into aux-task shape (provider/model/
118
+ base_url/api_key); an empty provider means "inherit parent" and renders as "auto"."""
119
+ d = cfg.get("delegation")
120
+ if not isinstance(d, dict):
121
+ d = {}
122
+ out = {k: str(d.get(k) or "").strip() for k in ("provider", "model", "base_url", "api_key")}
123
+ out["reasoning_effort"] = d.get("reasoning_effort", "")
124
+ return out
125
+
126
+
127
+ def _aux_task_cfg(cfg: dict, task: str) -> dict:
128
+ """The stored routing dict for *task* (delegation reads its top-level section)."""
129
+ if task == _DELEGATION_TASK_KEY:
130
+ return _delegation_cfg_as_task(cfg)
131
+ aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
132
+ return aux.get(task, {}) if isinstance(aux.get(task), dict) else {}
133
+
134
+
135
+ def _aux_task_display_name(task: str) -> str:
136
+ """Display name for a task key, covering the special delegation entry."""
137
+ if task == _DELEGATION_TASK_KEY:
138
+ return _DELEGATION_TASK_NAME
139
+ return next((name for key, name, _ in _all_aux_tasks() if key == task), task)
140
+
141
+
142
+ def _save_aux_choice(task: str, *, provider: str, model: str = "", base_url: str = "",
143
+ api_key: str = "", reasoning_effort: Optional[str] = None) -> None:
144
+ """Persist an aux task's four routing fields (timeout etc. untouched; main model config never
145
+ modified). ``delegation`` writes the top-level section, with "auto" stored as an empty provider.
146
+ ``reasoning_effort``: a level word or "" (provider default) to write; None leaves the key alone."""
147
+ from hermes_cli.config import load_config, save_config
148
+ cfg = load_config()
149
+ if task == _DELEGATION_TASK_KEY:
150
+ entry = _ensure_dict_section(cfg, "delegation")
151
+ provider = "" if provider == "auto" else provider
152
+ else:
153
+ entry = _ensure_dict_section(_ensure_dict_section(cfg, "auxiliary"), task)
154
+ entry["provider"] = provider
155
+ entry["model"] = model or ""
156
+ entry["base_url"] = base_url or ""
157
+ entry["api_key"] = api_key or ""
158
+ if reasoning_effort is not None and _aux_task_takes_reasoning(task):
159
+ entry["reasoning_effort"] = reasoning_effort
160
+ save_config(cfg)
161
+
162
+
163
+ def _aux_task_takes_reasoning(task: str) -> bool:
164
+ """Whether the task's config block honours ``reasoning_effort``. MoA slots and
165
+ ``memory_query_rewrite`` omit the key by design (``config_defaults._aux``); ``review``
166
+ routes through delegation which reads ``delegation.reasoning_effort``, not its own block."""
167
+ if task == _DELEGATION_TASK_KEY:
168
+ return True
169
+ from hermes_cli.config_defaults import DEFAULT_CONFIG
170
+ block = (DEFAULT_CONFIG.get("auxiliary") or {}).get(task)
171
+ if isinstance(block, dict):
172
+ return "reasoning_effort" in block
173
+ return True # plugin-registered task: the runtime folds the key in via _get_task_extra_body
174
+
175
+
176
+ def _prompt_aux_reasoning_effort(task: str, current: str) -> Optional[str]:
177
+ """Effort step for an aux task: a level, "none", "" (provider default / inherit parent), or None to
178
+ keep current. The empty-value row is "Inherit parent" for delegation (a child inherits the parent's
179
+ effort; wording from #105431 by @fangliquanflq) and "Provider default" for aux tasks."""
180
+ from hermes_constants import VALID_REASONING_EFFORTS
181
+ label = "Inherit parent" if task == _DELEGATION_TASK_KEY else "Provider default"
182
+ return _prompt_reasoning_effort_selection(
183
+ list(VALID_REASONING_EFFORTS), current_effort=current, default_label=label)
184
+
185
+
186
+ def _reset_aux_to_auto() -> int:
187
+ """Reset every known aux task (built-in + plugin) back to auto/empty. Returns number reset."""
188
+ from hermes_cli.config import load_config, save_config
189
+ def _clear(entry: dict, auto: str) -> bool:
190
+ # Only the routing fields; timeout/download_timeout (aux) and max_concurrent_children
191
+ # etc. (delegation) are user-tuned and preserved. *auto* is the reset provider value
192
+ # ("auto" for aux tasks, "" for delegation); anything else counts as a change.
193
+ changed = False
194
+ if entry.get("provider") not in {None, "", auto}:
195
+ entry["provider"] = auto
196
+ changed = True
197
+ for field in ("model", "base_url", "api_key", "reasoning_effort"):
198
+ if entry.get(field) or entry.get(field) is False:
199
+ entry[field] = ""
200
+ changed = True
201
+ return changed
202
+
203
+ cfg = load_config()
204
+ aux = _ensure_dict_section(cfg, "auxiliary")
205
+ count = sum(_clear(_ensure_dict_section(aux, task), "auto") for task, _name, _desc in _all_aux_tasks())
206
+ dele = cfg.get("delegation")
207
+ if isinstance(dele, dict):
208
+ count += _clear(dele, "")
209
+ save_config(cfg)
210
+ return count
211
+
212
+
213
+ def _aux_config_menu() -> None:
214
+ """Top-level auxiliary-model picker; loops until the user picks "Back"."""
215
+ from hermes_cli.config import load_config
216
+ while True:
217
+ cfg = load_config()
218
+ _say("", " Auxiliary models — side-task routing", "",
219
+ " Side tasks (vision, compression, web extraction, etc.) default",
220
+ ' to your main chat model. "auto" means "use my main model" —',
221
+ " Hermes only falls back to a lightweight backend (OpenRouter,",
222
+ " Nous Portal) if the main model is unavailable. Override a",
223
+ " task below if you want it pinned to a specific provider/model.", "")
224
+
225
+ menu_tasks = _all_aux_tasks() + [(_DELEGATION_TASK_KEY, _DELEGATION_TASK_NAME, _DELEGATION_TASK_DESC)]
226
+ name_col = max(len(name) for _, name, _ in menu_tasks) + 2
227
+ desc_col = max(len(desc) for _, _, desc in menu_tasks) + 4
228
+ entries = [
229
+ (task_key, f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}"
230
+ f"{_format_aux_current(_aux_task_cfg(cfg, task_key))}")
231
+ for task_key, name, desc in menu_tasks]
232
+ entries.append(("__reset__", "Reset all to auto"))
233
+ entries.append(("__back__", "Back"))
234
+
235
+ idx = _prompt_provider_choice([label for _, label in entries], default=0)
236
+ if idx is None:
237
+ return
238
+ key = entries[idx][0]
239
+ if key == "__back__":
240
+ return
241
+ if key == "__reset__":
242
+ n = _reset_aux_to_auto()
243
+ _say(f"Reset {n} auxiliary task(s) to auto." if n else "All auxiliary tasks were already set to auto.",
244
+ "")
245
+ continue
246
+ _aux_select_for_task(key)
247
+
248
+
249
+ def _aux_select_for_task(task: str) -> None:
250
+ """Pick a provider + model for one aux task and persist it. Rows come from
251
+ ``build_aux_picker_rows()`` (shared substrate): only already-configured providers appear."""
252
+ from hermes_cli.config import load_config
253
+ from hermes_cli.inventory import build_aux_picker_rows, format_aux_picker_entries
254
+ task_cfg = _aux_task_cfg(load_config(), task)
255
+ current_provider = str(task_cfg.get("provider") or "auto").strip() or "auto"
256
+ current_model = str(task_cfg.get("model") or "").strip()
257
+ current_base_url = str(task_cfg.get("base_url") or "").strip()
258
+ display_name = _aux_task_display_name(task)
259
+
260
+ try:
261
+ providers = build_aux_picker_rows(current_provider=current_provider, current_model=current_model,
262
+ current_base_url=current_base_url)
263
+ except Exception as exc:
264
+ print(f"Could not detect authenticated providers: {exc}")
265
+ providers = []
266
+
267
+ # (slug, label, models); "auto" always first
268
+ auto_marker = " ← current" if current_provider == "auto" and not current_base_url else ""
269
+ auto_label = "auto (inherit main agent)" if task == _DELEGATION_TASK_KEY else "auto (recommended)"
270
+ entries: list[tuple[str, str, list[str]]] = [("__auto__", f"{auto_label}{auto_marker}", [])]
271
+ entries.extend(format_aux_picker_entries(providers, current_provider=current_provider,
272
+ current_base_url=current_base_url))
273
+ custom_marker = " ← current" if current_base_url else ""
274
+ entries.append(("__custom__", f"Custom endpoint (direct URL){custom_marker}", []))
275
+ entries.append(("__back__", "Back", []))
276
+
277
+ _say("", f" Configure {display_name} — current: {_format_aux_current(task_cfg)}", "")
278
+ idx = _prompt_provider_choice([label for _, label, _ in entries], default=0)
279
+ if idx is None:
280
+ return
281
+ slug, _label, models = entries[idx]
282
+ if slug == "__back__":
283
+ return
284
+ if slug == "__auto__":
285
+ _save_aux_choice(task, provider="auto", model="", base_url="", api_key="", reasoning_effort="")
286
+ print(f"{display_name}: reset to auto.")
287
+ elif slug == "__custom__":
288
+ _aux_flow_custom_endpoint(task, task_cfg)
289
+ else:
290
+ _aux_flow_provider_model(task, slug, models, current_model, current_effort=_aux_effort_word(task_cfg))
291
+
292
+
293
+ def _aux_flow_provider_model(task: str, provider_slug: str, curated_models: list,
294
+ current_model: str = "", current_effort: str = "") -> None:
295
+ """Prompt for a model under an already-authenticated provider (then its reasoning effort),
296
+ save to aux."""
297
+ from hermes_cli.auth import _prompt_model_selection
298
+ from hermes_cli.models_pricing import get_pricing_for_provider
299
+ display_name = _aux_task_display_name(task)
300
+ try:
301
+ pricing = get_pricing_for_provider(provider_slug) or {}
302
+ except Exception:
303
+ pricing = {}
304
+
305
+ model_list = list(curated_models)
306
+ # _prompt_model_selection supports "Enter custom model name" and cancel; with no curated
307
+ # list (rare) fall back to a raw input prompt.
308
+ if not model_list:
309
+ _say(f"No curated model list for {provider_slug}.", "Enter a model slug manually (blank = use provider default):")
310
+ selected = _ask("Model: ", cancel_msg="")
311
+ if selected is None:
312
+ return
313
+ else:
314
+ selected = _prompt_model_selection(model_list, current_model=current_model, pricing=pricing,
315
+ confirm_provider=provider_slug)
316
+ if selected is None:
317
+ print("No change.")
318
+ return
319
+
320
+ effort = _prompt_aux_reasoning_effort(task, current_effort) if _aux_task_takes_reasoning(task) else None
321
+ _save_aux_choice(task, provider=provider_slug, model=selected or "", base_url="", api_key="",
322
+ reasoning_effort=effort)
323
+ effort_note = f" · reasoning {effort}" if effort else ""
324
+ if selected:
325
+ print(f"{display_name}: {provider_slug} · {selected}{effort_note}")
326
+ else:
327
+ print(f"{display_name}: {provider_slug} (provider default model){effort_note}")
328
+
329
+
330
+ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
331
+ """Prompt for a direct OpenAI-compatible base_url + optional api_key/model."""
332
+ display_name = _aux_task_display_name(task)
333
+ current_base_url = str(task_cfg.get("base_url") or "").strip()
334
+ current_model = str(task_cfg.get("model") or "").strip()
335
+
336
+ _say("", f" Custom endpoint for {display_name}",
337
+ " Provide an OpenAI-compatible base URL (e.g. http://localhost:11434/v1)", "")
338
+ url = _ask(f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: ", cancel_msg="")
339
+ if url is None:
340
+ return
341
+ url = url or current_base_url
342
+ if not url:
343
+ print("No URL provided. No change.")
344
+ return
345
+ model = _ask(f"Model slug (optional) [{current_model}]: " if current_model else "Model slug (optional): ",
346
+ cancel_msg="")
347
+ if model is None:
348
+ return
349
+ model = model or current_model
350
+ api_key = _ask("API key (optional, blank = use OPENAI_API_KEY): ", secret=True, cancel_msg="")
351
+ if api_key is None:
352
+ return
353
+ effort = (_prompt_aux_reasoning_effort(task, _aux_effort_word(task_cfg))
354
+ if _aux_task_takes_reasoning(task) else None)
355
+
356
+ _save_aux_choice(task, provider="custom", model=model, base_url=url, api_key=api_key, reasoning_effort=effort)
357
+ print(f"{display_name}: custom ({_short_url(url)})" + (f" · {model}" if model else "")
358
+ + (f" · reasoning {effort}" if effort else ""))
359
+
360
+
361
+ _CANCELLED = object()
362
+
363
+
364
+ def _ask_index(prompt: str, count: int, *, echo_cancel: bool):
365
+ """Numbered-menu input loop: 0-based index in ``range(count)``, ``None`` on blank,
366
+ ``_CANCELLED`` on Ctrl-C/EOF (*echo_cancel* prints a blank line first)."""
367
+ while True:
368
+ try:
369
+ val = input(prompt).strip()
370
+ if not val:
371
+ return None
372
+ idx = int(val) - 1
373
+ if 0 <= idx < count:
374
+ return idx
375
+ print(f"Please enter 1-{count}")
376
+ except ValueError:
377
+ print("Please enter a number")
378
+ except (KeyboardInterrupt, EOFError):
379
+ if echo_cancel:
380
+ print()
381
+ return _CANCELLED
382
+
383
+
384
+ def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
385
+ """Provider menu with curses arrow keys; numbered-list fallback when curses is unavailable
386
+ (piped stdin, non-TTY). Returns the selected index, or None if the user cancels."""
387
+ with contextlib.suppress(Exception):
388
+ from hermes_cli.setup import _curses_prompt_choice
389
+ idx = _curses_prompt_choice(title, choices, default)
390
+ if idx >= 0:
391
+ print()
392
+ return idx
393
+
394
+ _print_numbered(title, choices, default)
395
+ print()
396
+ idx = _ask_index(f"Choice [1-{len(choices)}] ({default + 1}): ", len(choices), echo_cancel=True)
397
+ if idx is None:
398
+ return default
399
+ return None if idx is _CANCELLED else idx
400
+
401
+
402
+ _DEFAULT_QWEN_PORTAL_MODELS = [
403
+ "qwen3-coder-plus", "qwen3-coder"]
404
+
405
+ # (mode value, label, description, accepted answers); "" = auto-detect
406
+ _CUSTOM_API_MODES = (
407
+ ("", "Auto-detect", "Use Hermes URL heuristics; best for standard OpenAI-compatible endpoints.",
408
+ ("1", "auto", "detect", "auto-detect")),
409
+ ("chat_completions", "Chat Completions", "Use /chat/completions for standard OpenAI-compatible servers.",
410
+ ("2", "chat", "chat_completions", "completions")),
411
+ ("codex_responses", "Responses / Codex", "Use /responses for Codex-compatible tool-calling backends.",
412
+ ("3", "responses", "codex", "codex_responses")),
413
+ ("anthropic_messages", "Anthropic Messages", "Use /v1/messages for Anthropic-compatible endpoints.",
414
+ ("4", "anthropic", "anthropic_messages", "messages")))
415
+ _CUSTOM_API_MODE_ANSWERS = {answer: value for value, _, _, answers in _CUSTOM_API_MODES for answer in answers}
416
+
417
+
418
+ def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]:
419
+ """Prompt for a custom provider API mode: an explicit mode string, or None for auto-detect."""
420
+ from hermes_cli.runtime_provider import _detect_api_mode_for_url
421
+ detected_mode = _detect_api_mode_for_url(base_url)
422
+ default_mode = str(current_api_mode or "").strip().lower() or detected_mode or ""
423
+
424
+ _say("", "Select API compatibility mode:")
425
+ for idx, (value, label, description, _answers) in enumerate(_CUSTOM_API_MODES, 1):
426
+ markers = [m for m, hit in (("detected", value == detected_mode), ("current", value == default_mode)) if hit]
427
+ suffix = f" [{' / '.join(markers)}]" if markers else ""
428
+ _say(f" {idx}. {label}{suffix}", f" {description}")
429
+
430
+ try:
431
+ raw = input("Choice [1-4, Enter to keep current/detected]: ").strip().lower()
432
+ except (KeyboardInterrupt, EOFError):
433
+ print("\nCancelled.")
434
+ raise
435
+
436
+ if not raw:
437
+ return default_mode or None
438
+ if raw in _CUSTOM_API_MODE_ANSWERS:
439
+ return _CUSTOM_API_MODE_ANSWERS[raw] or None
440
+ print(f"Invalid API mode choice: {raw}. Falling back to auto-detect.")
441
+ return None
442
+
443
+
444
+ def _auto_provider_name(base_url: str) -> str:
445
+ """Display name from a custom endpoint URL, e.g. "Local (localhost:11434)" or
446
+ "RunPod (xyz.runpod.io)" — the default offered during custom endpoint setup."""
447
+ import re
448
+ name = re.sub(r"/v1/?$", "", _short_url(base_url)).split("/")[0]
449
+ if "localhost" in name or "127.0.0.1" in name:
450
+ return f"Local ({name})"
451
+ if "runpod" in name.lower():
452
+ return f"RunPod ({name})"
453
+ return name.capitalize()
454
+
455
+
456
+ def _custom_provider_api_key_config_value(provider_info, resolved_api_key=""):
457
+ """Return the value that should be persisted for a custom provider key."""
458
+ api_key_ref = str(provider_info.get("api_key_ref", "") or "").strip()
459
+ if api_key_ref:
460
+ return api_key_ref
461
+ key_env = str(provider_info.get("key_env", "") or "").strip()
462
+ if key_env and not str(provider_info.get("api_key", "") or "").strip():
463
+ return f"${{{key_env}}}"
464
+ return str(resolved_api_key or "").strip()
465
+
466
+
467
+ def _custom_provider_base_url_config_value(provider_info, resolved_base_url=""):
468
+ """Return the value that should be persisted for a custom provider URL."""
469
+ return str(provider_info.get("base_url_ref", "") or "").strip() or str(resolved_base_url or "").strip()
470
+
471
+
472
+ def _save_custom_provider(base_url, api_key="", model="", context_length=None, name=None, api_mode=None,
473
+ key_env=""):
474
+ """Save a custom endpoint to ``custom_providers`` in config.yaml, deduplicated by base_url (an
475
+ existing entry gets model / context_length / api_mode updated). *key_env* set means the caller
476
+ already wrote the key to ``.env``; the entry references it instead of inlining the secret.
477
+
478
+ See #69449.
479
+ """
480
+ from hermes_cli.config import load_config, save_config
481
+ cfg = load_config()
482
+ providers = cfg.get("custom_providers") or []
483
+ if not isinstance(providers, list):
484
+ providers = []
485
+ for entry in providers:
486
+ if not (isinstance(entry, dict) and entry.get("base_url", "").rstrip("/") == base_url.rstrip("/")):
487
+ continue
488
+ changed = False
489
+ if model and entry.get("model") != model:
490
+ entry["model"] = model
491
+ changed = True
492
+ if model and context_length:
493
+ _ensure_dict_section(entry, "models")[model] = {"context_length": context_length}
494
+ changed = True
495
+ if api_mode:
496
+ if entry.get("api_mode") != api_mode:
497
+ entry["api_mode"] = api_mode
498
+ changed = True
499
+ elif "api_mode" in entry:
500
+ entry.pop("api_mode", None)
501
+ changed = True
502
+ if key_env and (entry.get("key_env") != key_env or entry.get("api_key")):
503
+ entry["key_env"] = key_env
504
+ entry.pop("api_key", None)
505
+ changed = True
506
+ if changed:
507
+ cfg["custom_providers"] = providers
508
+ save_config(cfg)
509
+ return # already saved, updated if needed
510
+
511
+ name = name or _auto_provider_name(base_url)
512
+ entry = {"name": name, "base_url": base_url}
513
+ if key_env:
514
+ entry["key_env"] = key_env
515
+ elif api_key:
516
+ entry["api_key"] = api_key
517
+ if model:
518
+ entry["model"] = model
519
+ if api_mode:
520
+ entry["api_mode"] = api_mode
521
+ if model and context_length:
522
+ entry["models"] = {model: {"context_length": context_length}}
523
+
524
+ providers.append(entry)
525
+ cfg["custom_providers"] = providers
526
+ save_config(cfg)
527
+ print(f' 💾 Saved to custom providers as "{name}" (edit in config.yaml)')
528
+
529
+
530
+ def _remove_custom_provider(config):
531
+ """Let the user remove a saved custom provider from config.yaml."""
532
+ from hermes_cli.config import load_config, save_config
533
+ cfg = load_config()
534
+ providers = cfg.get("custom_providers") or []
535
+ if not isinstance(providers, list) or not providers:
536
+ print("No custom providers configured.")
537
+ return
538
+
539
+ print("Remove a custom provider:\n")
540
+ choices = [
541
+ f"{entry.get('name', 'unnamed')} ({_short_url(entry.get('base_url', ''))})" if isinstance(entry, dict) else str(entry)
542
+ for entry in providers]
543
+ choices.append("Cancel")
544
+
545
+ idx = _radiolist("Select provider to remove:", list(choices))
546
+ if idx is not None:
547
+ print()
548
+ if idx < 0:
549
+ idx = None
550
+ else:
551
+ for i, c in enumerate(choices, 1):
552
+ print(f" {i}. {c}")
553
+ print()
554
+ try:
555
+ val = input(f"Choice [1-{len(choices)}]: ").strip()
556
+ idx = int(val) - 1 if val else None
557
+ except (ValueError, KeyboardInterrupt, EOFError):
558
+ idx = None
559
+
560
+ if idx is None or idx >= len(providers):
561
+ print("No change.")
562
+ return
563
+
564
+ removed = providers.pop(idx)
565
+ cfg["custom_providers"] = providers
566
+ save_config(cfg)
567
+ removed_name = removed.get("name", "unnamed") if isinstance(removed, dict) else str(removed)
568
+ print(f'✅ Removed "{removed_name}" from custom providers.')
569
+
570
+
571
+ def _prompt_reasoning_effort_selection(efforts, current_effort="", *, default_label=""):
572
+ """Prompt for a reasoning effort. Returns a level, 'none', "" (only with *default_label*: the
573
+ "use the provider default" row), or None to keep current."""
574
+ deduped = list(dict.fromkeys(str(effort).strip().lower() for effort in efforts if str(effort).strip()))
575
+ canonical_order = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
576
+ ordered = [effort for effort in canonical_order if effort in deduped]
577
+ ordered.extend(effort for effort in deduped if effort not in canonical_order)
578
+ if not ordered:
579
+ return None
580
+
581
+ def _label(effort):
582
+ return f"{effort} ← currently in use" if effort == current_effort else effort
583
+
584
+ disable_label = "Disable reasoning"
585
+ skip_label = "Skip (keep current)"
586
+ # (return value, label) for the rows after the ladder; "" = provider default (aux tasks only).
587
+ tail: list[tuple[Optional[str], str]] = [("none", disable_label)]
588
+ if default_label:
589
+ tail.append(("", default_label + (" ← currently in use" if current_effort == "" else "")))
590
+ tail.append((None, skip_label))
591
+ tail_values = [v for v, _ in tail]
592
+ if current_effort == "none":
593
+ default_idx = len(ordered) + tail_values.index("none")
594
+ elif current_effort in ordered:
595
+ default_idx = ordered.index(current_effort)
596
+ elif default_label and current_effort == "":
597
+ default_idx = len(ordered) + tail_values.index("")
598
+ elif "medium" in ordered:
599
+ default_idx = ordered.index("medium")
600
+ else:
601
+ default_idx = 0
602
+
603
+ n = len(ordered)
604
+ rows = [_label(effort) for effort in ordered] + [label for _, label in tail]
605
+ idx = _radiolist("Select reasoning effort:", rows, default_idx)
606
+ if idx is not None:
607
+ if idx < 0:
608
+ return None
609
+ print()
610
+ else:
611
+ print("Select reasoning effort:")
612
+ for i, row in enumerate(rows, 1):
613
+ print(f" {i}. {row}")
614
+ print()
615
+ idx = _ask_index(f"Choice [1-{len(rows)}] (default: keep current): ", len(rows), echo_cancel=False)
616
+ if idx is None or idx is _CANCELLED:
617
+ return None
618
+ if idx < n:
619
+ return ordered[idx]
620
+ return tail_values[idx - n]
621
+
622
+
623
+ def _offer_reasoning_after_pick(model_before: str) -> None:
624
+ """Post-flow effort step for ``select_provider_and_model``: when a flow saved a different
625
+ ``model.default`` (every flow persists through ``_save_model_choice``), offer the effort for
626
+ the new model + provider. A flow that made no change (cancel, "No change.") never prompts."""
627
+ from hermes_cli.config import load_config
628
+ model_cfg = load_config().get("model")
629
+ if not isinstance(model_cfg, dict):
630
+ return
631
+ model = str(model_cfg.get("default") or "").strip()
632
+ if not model or model == model_before:
633
+ return # same model re-picked or nothing saved: the "Reasoning effort" row covers that
634
+ _prompt_main_reasoning_effort(model, str(model_cfg.get("provider") or ""))
635
+
636
+
637
+ def _prompt_main_reasoning_effort(model: str, provider: str) -> None:
638
+ """The effort step every ``hermes model`` flow shares: after a main-model pick, offer the
639
+ model's supported levels (Copilot publishes a per-model set; everything else gets the full
640
+ ladder) and persist ``agent.reasoning_effort``. Skipped when the catalog says the route has
641
+ no reasoning control; "Skip" leaves the current value alone."""
642
+ from hermes_cli.config import load_config, save_config
643
+ from hermes_cli.setup import _current_reasoning_effort, _set_reasoning_effort
644
+ efforts = _main_model_reasoning_efforts(model, provider)
645
+ if efforts is None:
646
+ return
647
+ selected = _prompt_reasoning_effort_selection(efforts, current_effort=_current_reasoning_effort(load_config()))
648
+ if selected is None:
649
+ return
650
+ cfg = load_config()
651
+ _set_reasoning_effort(cfg, selected)
652
+ save_config(cfg)
653
+ print("Reasoning disabled for this model." if selected == "none" else f"Reasoning effort set to: {selected}")
654
+
655
+
656
+ def _main_model_reasoning_efforts(model: str, provider: str) -> Optional[list[str]]:
657
+ """Levels to offer for *model* on *provider*: None when the route has no reasoning control."""
658
+ from hermes_constants import VALID_REASONING_EFFORTS
659
+ slug = (provider or "").strip().lower()
660
+ if slug == "copilot":
661
+ from hermes_cli.models import github_model_reasoning_efforts
662
+ return github_model_reasoning_efforts(model) or None
663
+ try:
664
+ from agent.models_dev import get_model_capabilities
665
+ meta = get_model_capabilities(slug, model)
666
+ except Exception:
667
+ meta = None
668
+ if meta is not None and not meta.supports_reasoning:
669
+ return None
670
+ return list(VALID_REASONING_EFFORTS)
671
+
672
+
673
+ def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "", existing_source: str = "") -> tuple:
674
+ """API-key entry for ``hermes setup`` / ``hermes model``: first-time entry, or [K]eep / [R]eplace /
675
+ [C]lear when a key exists (a malformed paste is recoverable without editing ``.env``).
676
+ Returns ``(resolved_key, abort)``; ``abort=True`` means the caller must ``return`` at once."""
677
+ from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER
678
+ from hermes_cli.config import save_env_value
679
+ key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else ""
680
+
681
+ def _prompt_new_key(*, allow_lmstudio_default: bool) -> str:
682
+ lmstudio_default = provider_id == "lmstudio" and allow_lmstudio_default
683
+ if lmstudio_default:
684
+ prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): "
685
+ else:
686
+ prompt = f"{key_env} (or Enter to cancel): "
687
+ entered = _ask(prompt, secret=True, cancel_msg="")
688
+ if entered is None:
689
+ return ""
690
+ if not entered and lmstudio_default:
691
+ return LMSTUDIO_NOAUTH_PLACEHOLDER
692
+ return entered
693
+
694
+ if not existing_key:
695
+ print(f"No {pconfig.name} API key configured.")
696
+ if not key_env:
697
+ return "", True
698
+ new_key = _prompt_new_key(allow_lmstudio_default=True)
699
+ if not new_key:
700
+ print("Cancelled.")
701
+ return "", True
702
+ save_env_value(key_env, new_key)
703
+ _say("API key saved.", "")
704
+ return new_key, False
705
+
706
+ # Already configured — offer K / R / C
707
+ from hermes_cli.env_loader import format_secret_source_suffix
708
+ source_suffix = format_secret_source_suffix(key_env) if key_env else ""
709
+ print(f" {pconfig.name} API key: {existing_key[:8]}... ✓{source_suffix}")
710
+ if not key_env:
711
+ # Nothing we can rewrite; just acknowledge and move on.
712
+ print()
713
+ return existing_key, False
714
+ pool_backed = existing_source.startswith("credential_pool:")
715
+ menu = " [K]eep / [R]eplace (default K): " if pool_backed else " [K]eep / [R]eplace / [C]lear (default K): "
716
+ choice = _ask(menu, raw=True, cancel_msg="", on_cancel="k").lower()
717
+
718
+ if choice.startswith("r"):
719
+ new_key = _prompt_new_key(allow_lmstudio_default=False)
720
+ if not new_key:
721
+ _say(" No change.", "")
722
+ return existing_key, False
723
+ save_env_value(key_env, new_key)
724
+ _say(" API key updated.", "")
725
+ return new_key, False
726
+ if choice.startswith("c") and not pool_backed:
727
+ save_env_value(key_env, "")
728
+ print(f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again.")
729
+ return "", True
730
+ # Keep (default, or any other input)
731
+ print()
732
+ return existing_key, False
733
+
734
+
735
+ def _infer_stepfun_region(base_url: str) -> str:
736
+ """Infer the current StepFun region from the configured endpoint."""
737
+ return "china" if "api.stepfun.com" in (base_url or "").strip().lower() else "international"
738
+
739
+
740
+ def _stepfun_base_url_for_region(region: str) -> str:
741
+ from hermes_cli.auth import STEPFUN_STEP_PLAN_CN_BASE_URL, STEPFUN_STEP_PLAN_INTL_BASE_URL
742
+ return STEPFUN_STEP_PLAN_CN_BASE_URL if region == "china" else STEPFUN_STEP_PLAN_INTL_BASE_URL
743
+
744
+
745
+ def _run_anthropic_oauth_flow(save_env_value):
746
+ """Run the Claude OAuth setup-token flow. Returns True if credentials were saved."""
747
+ from agent.anthropic_credentials import run_oauth_setup_token, read_claude_code_credentials, is_claude_code_token_valid
748
+ from hermes_cli.config import save_anthropic_oauth_token, use_anthropic_claude_code_credentials
749
+
750
+ def _activate_claude_code_credentials_if_available() -> bool:
751
+ try:
752
+ creds = read_claude_code_credentials()
753
+ except Exception:
754
+ creds = None
755
+ if creds and (is_claude_code_token_valid(creds) or bool(creds.get("refreshToken"))):
756
+ use_anthropic_claude_code_credentials(save_fn=save_env_value)
757
+ print(" ✓ Claude Code credentials linked.")
758
+ from hermes_constants import display_hermes_home as _dhh_fn
759
+ print(f" Hermes will use Claude's credential store directly instead of copying a setup-token into {_dhh_fn()}/.env.")
760
+ return True
761
+ return False
762
+
763
+ def _paste_token(prompt: str):
764
+ """Manual setup-token entry: True saved, False empty, None cancelled."""
765
+ token = _ask(prompt, secret=True, cancel_msg="")
766
+ if not token:
767
+ return token
768
+ save_anthropic_oauth_token(token, save_fn=save_env_value)
769
+ print(" ✓ Setup-token saved.")
770
+ return True
771
+
772
+ try:
773
+ _say("", " Running 'claude setup-token' — follow the prompts below.",
774
+ " A browser window will open for you to authorize access.", "")
775
+ token = run_oauth_setup_token()
776
+ if token:
777
+ if _activate_claude_code_credentials_if_available():
778
+ return True
779
+ save_anthropic_oauth_token(token, save_fn=save_env_value)
780
+ print(" ✓ OAuth credentials saved.")
781
+ return True
782
+
783
+ # Subprocess completed but no token auto-detected — ask user to paste
784
+ _say("", " If the setup-token was displayed above, paste it here:", "")
785
+ saved = _paste_token(" Paste setup-token (or Enter to cancel): ")
786
+ if saved is None:
787
+ return False
788
+ if saved:
789
+ return True
790
+ print(" ⚠ Could not detect saved credentials.")
791
+ return False
792
+
793
+ except FileNotFoundError:
794
+ # Claude CLI not installed — guide user through manual setup
795
+ _say("", " The 'claude' CLI is required for OAuth login.", "", " To install and authenticate:", "",
796
+ " 1. Install Claude Code: npm install -g @anthropic-ai/claude-code",
797
+ " 2. Run: claude setup-token",
798
+ " 3. Follow the browser prompts to authorize",
799
+ " 4. Re-run: hermes model", "",
800
+ " Or paste an existing setup-token now (sk-ant-oat-...):", "")
801
+ saved = _paste_token(" Setup-token (or Enter to cancel): ")
802
+ if saved is None:
803
+ return False
804
+ if saved:
805
+ return True
806
+ print(" Cancelled — install Claude Code and try again.")
807
+ return False
808
+
809
+
810
+ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
811
+ """Saved custom providers keyed by slug, with raw ``${ENV}`` refs preserved."""
812
+ from hermes_cli.config import get_compatible_custom_providers, read_raw_config
813
+ from hermes_cli.providers import custom_provider_slug
814
+
815
+ # Raw (un-expanded) templates keyed by identity. ``get_compatible_custom_providers(
816
+ # read_raw_config())`` is deliberately bypassed: its normalize step ``urlparse()``s
817
+ # ``base_url`` and drops entries whose base_url is itself an env-ref template.
818
+ raw_api_key_refs: dict[tuple, str] = {}
819
+ raw_base_url_refs: dict[tuple, str] = {}
820
+ raw_cfg = read_raw_config()
821
+
822
+ raw_entries: list[tuple[str, str, dict]] = []
823
+ raw_list = raw_cfg.get("custom_providers")
824
+ if isinstance(raw_list, list):
825
+ raw_entries.extend((e.get("name", ""), "", e) for e in raw_list if isinstance(e, dict))
826
+ raw_providers = raw_cfg.get("providers")
827
+ if isinstance(raw_providers, dict):
828
+ raw_entries.extend((e.get("name", "") or k, k, e) for k, e in raw_providers.items() if isinstance(e, dict))
829
+ for name, provider_key, raw_entry in raw_entries:
830
+ template = str(raw_entry.get("api_key", "") or "").strip()
831
+ base_template = str(raw_entry.get("base_url", "") or raw_entry.get("url", "") or raw_entry.get("api", "") or "").strip()
832
+ name = str(name or "").strip()
833
+ provider_key = str(provider_key or "").strip()
834
+ model = str(raw_entry.get("model", "") or raw_entry.get("default_model", "") or "").strip()
835
+ # Index by every identity the loaded (expanded) config might present: (name),
836
+ # (name, model), (provider_key), (provider_key, model); case-insensitive names.
837
+ keys = [k.lower() for k in (name, provider_key) if k]
838
+ identities = [(k,) for k in keys] + [(k, model) for k in keys]
839
+ for refs, tmpl in ((raw_api_key_refs, template), (raw_base_url_refs, base_template)):
840
+ if "${" in tmpl:
841
+ for identity in identities:
842
+ refs.setdefault(identity, tmpl)
843
+
844
+ def _lookup_ref(refs: dict[tuple, str], name: str, provider_key: str, model: str) -> str:
845
+ name_lc = str(name or "").strip().lower()
846
+ pkey_lc = str(provider_key or "").strip().lower()
847
+ model = str(model or "").strip()
848
+ return next((refs[i] for i in ((pkey_lc, model), (pkey_lc,), (name_lc, model), (name_lc,)) if i[0] and i in refs), "")
849
+
850
+ custom_provider_map = {}
851
+ for entry in get_compatible_custom_providers(cfg):
852
+ if not isinstance(entry, dict):
853
+ continue
854
+ name = (entry.get("name") or "").strip()
855
+ base_url = (entry.get("base_url") or "").strip()
856
+ if not name or not base_url:
857
+ continue
858
+ provider_key = (entry.get("provider_key") or "").strip()
859
+ model = entry.get("model", "")
860
+ custom_provider_map[custom_provider_slug(name, provider_key)] = {
861
+ "name": name,
862
+ "base_url": base_url,
863
+ "api_key": entry.get("api_key", ""),
864
+ "key_env": entry.get("key_env") or entry.get("api_key_env", ""),
865
+ "key_cmd": entry.get("key_cmd", ""),
866
+ "model": model,
867
+ "models": entry.get("models", {}),
868
+ "models_discovered": entry.get("models_discovered", False),
869
+ "extra_headers": entry.get("extra_headers", {}),
870
+ "discover_models": entry.get("discover_models", True),
871
+ "api_mode": entry.get("api_mode", ""),
872
+ "provider_key": provider_key,
873
+ "api_key_ref": _lookup_ref(raw_api_key_refs, name, provider_key, model),
874
+ "base_url_ref": _lookup_ref(raw_base_url_refs, name, provider_key, model)}
875
+ return custom_provider_map
876
+
877
+
878
+ def _build_provider_picker_rows(config: dict, active: str, provider_labels: dict[str, str],
879
+ custom_provider_map: dict[str, dict[str, str]]) -> tuple[list[tuple[str, str, list[str]]], int]:
880
+ """Rows for the ``hermes model`` provider picker plus the pre-selected index. Canonical providers
881
+ fold into display groups (PROVIDER_GROUPS): a group row's ``members`` drive a sub-picker, leaf
882
+ rows have ``members == []``; saved custom providers and trailing actions stay flat. Honors
883
+ ``model_catalog.excluded_providers`` (slug or alias, case-insensitive) like the gateway/TUI."""
884
+ from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_ALIASES
885
+ from hermes_cli.models_catalog_static import group_providers, provider_group_for_slug
886
+ canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS}
887
+ _cli_excluded = {
888
+ str(p).strip().lower()
889
+ for p in (config.get("model_catalog", {}) or {}).get("excluded_providers") or []
890
+ if p}
891
+ if _cli_excluded:
892
+ # A canonical provider is hidden if its slug OR any alias is excluded.
893
+ _names_for: dict[str, set[str]] = {_p.slug: {_p.slug.lower()} for _p in CANONICAL_PROVIDERS}
894
+ for _alias, _canon in _PROVIDER_ALIASES.items():
895
+ _names_for.setdefault(_canon, {_canon.lower()}).add(_alias.lower())
896
+ _visible_slugs = [p.slug for p in CANONICAL_PROVIDERS if not _names_for.get(p.slug, {p.slug.lower()}) & _cli_excluded]
897
+ else:
898
+ _visible_slugs = [p.slug for p in CANONICAL_PROVIDERS]
899
+
900
+ # The active provider's group when grouped, otherwise the active slug itself.
901
+ active_group = provider_group_for_slug(active) if active else ""
902
+
903
+ # (key, label, members): members == [] → leaf row (provider slug / action);
904
+ # members != [] → group row, key is "group:<gid>"
905
+ ordered: list[tuple[str, str, list[str]]] = []
906
+ default_idx = 0
907
+
908
+ def _add(key, label, members, is_active):
909
+ nonlocal default_idx
910
+ if is_active:
911
+ label = f"{label} ← currently active"
912
+ default_idx = len(ordered)
913
+ ordered.append((key, label, members))
914
+
915
+ for row in group_providers(_visible_slugs):
916
+ if row["kind"] == "group":
917
+ gid = row["group_id"]
918
+ group_desc = row.get("description", "")
919
+ label = f"{row['label']} ▸ ({group_desc})" if group_desc else f"{row['label']} ▸"
920
+ _add(f"group:{gid}", label, row["members"], bool(active_group) and gid == active_group)
921
+ else:
922
+ slug = row["slug"]
923
+ label = canonical_descs.get(slug, provider_labels.get(slug, slug))
924
+ if slug == "nous":
925
+ # Same free-tier rule as the gateway/TUI pickers: relabel for a guest, hide
926
+ # when nous.guest is off, untouched for a real account.
927
+ from hermes_cli.model_switch_providers import _free_tier_nous_row
928
+ tier_row = _free_tier_nous_row({"name": label, "models": []})
929
+ if tier_row is None:
930
+ continue
931
+ label = tier_row["name"]
932
+ _add(slug, label, [], bool(active) and slug == active)
933
+
934
+ for key, provider_info in custom_provider_map.items():
935
+ saved_model = provider_info.get("model", "")
936
+ model_hint = f" — {saved_model}" if saved_model else ""
937
+ _add(key, f"{provider_info['name']} ({_short_url(provider_info['base_url'])}){model_hint}", [],
938
+ bool(active) and key == active)
939
+
940
+ ordered.append(("custom", "Custom endpoint (enter URL manually)", []))
941
+ if isinstance(config.get("custom_providers"), list) and config.get("custom_providers"):
942
+ ordered.append(("remove-custom", "Remove a saved custom provider", []))
943
+ ordered.append(("reasoning", "Reasoning effort for the current model...", []))
944
+ ordered.append(("aux-config", "Configure auxiliary models...", []))
945
+ ordered.append(("cancel", "Leave unchanged", []))
946
+ return ordered, default_idx
hermes_cli/main_tui_launch.py ADDED
@@ -0,0 +1,873 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TUI (ui-tui) launcher: node/npm bootstrap, workspace/rebuild checks, argv/env assembly.
2
+
3
+ Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...)
4
+ are imported lazily inside the functions that use them (avoids an import cycle).
5
+ """
6
+
7
+ import logging
8
+ import contextlib
9
+ import json
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ # Log-record parity with the origin module.
19
+ logger = logging.getLogger("hermes_cli.main")
20
+
21
+
22
+ def _read_tui_active_session_file(path: Optional[str]) -> Optional[str]:
23
+ if not path:
24
+ return None
25
+ try:
26
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
27
+ return str(data.get("session_id") or "").strip() or None
28
+ except Exception:
29
+ return None
30
+
31
+
32
+ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Optional[str] = None) -> None:
33
+ """Print a shell-visible epilogue after TUI exits."""
34
+ from hermes_cli.main import _resolve_last_session
35
+ target = (
36
+ _read_tui_active_session_file(active_session_file) or session_id or _resolve_last_session(source="tui")
37
+ )
38
+ if not target:
39
+ return
40
+
41
+ db = None
42
+ try:
43
+ from hermes_state import SessionDB
44
+ db = SessionDB(read_only=True) # exit epilogue only reads
45
+ session = db.get_session(target)
46
+ if not session:
47
+ return
48
+
49
+ title = db.get_session_title(target)
50
+ message_count = int(session.get("message_count") or 0)
51
+ if message_count == 0:
52
+ return # No real conversation — don't show resume info
53
+ tokens = {
54
+ k: int(session.get(f"{k}_tokens") or 0)
55
+ for k in ("input", "output", "cache_read", "cache_write", "reasoning")}
56
+ except Exception:
57
+ return
58
+ finally:
59
+ if db is not None:
60
+ db.close()
61
+
62
+ print(f"\nResume this session with:\n hermes --tui --resume {target}")
63
+ if title:
64
+ print(f' hermes --tui -c "{title}"')
65
+ print(f"\nSession: {target}")
66
+ if title:
67
+ print(f"Title: {title}")
68
+ print(f"Messages: {message_count}")
69
+ print(
70
+ "Tokens: "
71
+ f"{sum(tokens.values())} (in {tokens['input']}, out {tokens['output']}, "
72
+ f"cache {tokens['cache_read'] + tokens['cache_write']}, reasoning {tokens['reasoning']})"
73
+ )
74
+
75
+
76
+ _NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert", "peer", "dev", "extraneous", "hasInstallScript", "optional"})
77
+ """Lockfile fields npm writes non-deterministically at install time.
78
+
79
+ ``ideallyInert`` marks packages npm skipped (per-platform opt-outs); ``peer`` is
80
+ dropped from the hidden ``.package-lock.json`` on dev-deps that are also peers.
81
+ ``dev`` / ``optional`` / ``extraneous`` / ``hasInstallScript`` are boolean
82
+ annotations npm populates differently in the hidden lock (npm >= 10/11), and
83
+ may differ even when present in both. None indicate a real declared-vs-installed
84
+ skew — the authoritative check is the ``resolved``/``integrity`` pair, which the
85
+ intersection comparison in :func:`_tui_need_npm_install` always catches.
86
+ """
87
+
88
+
89
+ def _workspace_root(dir: Path) -> Path:
90
+ """The npm workspace root for *dir*: its parent when *dir* has ``package.json`` but the
91
+ lockfile lives one level up (hoisted node_modules), else *dir* (standalone / prebuilt).
92
+ Shared by the install check, TUI launcher and web build so their cwd can't diverge."""
93
+ if (
94
+ (dir / "package.json").is_file()
95
+ and not (dir / "package-lock.json").is_file()
96
+ and (dir.parent / "package-lock.json").is_file()):
97
+ return dir.parent
98
+ return dir
99
+
100
+
101
+ def _child_workspace_dirs(dir: Path):
102
+ """Sorted ``dir/packages/*`` subdirs that carry a ``package.json``."""
103
+ packages_dir = dir / "packages"
104
+ if not packages_dir.is_dir():
105
+ return
106
+ for child in sorted(packages_dir.iterdir()):
107
+ if child.is_dir() and (child / "package.json").is_file():
108
+ yield child
109
+
110
+
111
+ def _termux_workspace_install_context(
112
+ dir: Path, *, include_child_workspaces: bool = False) -> tuple[Path, tuple[str, ...]]:
113
+ """Return Termux-only ``(cwd, npm_args)`` for installing deps for *dir* only."""
114
+ ws_root = _workspace_root(dir)
115
+ if ws_root == dir:
116
+ return dir, ()
117
+
118
+ try:
119
+ workspace = dir.relative_to(ws_root).as_posix()
120
+ except ValueError:
121
+ return ws_root, ()
122
+
123
+ workspace_args: list[str] = ["--workspace", workspace]
124
+ if include_child_workspaces:
125
+ for child in _child_workspace_dirs(dir):
126
+ workspace_args.extend(["--workspace", child.relative_to(ws_root).as_posix()])
127
+ workspace_args.append("--include-workspace-root=false")
128
+ return ws_root, tuple(workspace_args)
129
+
130
+
131
+ def _npm_lock_workspace_closure(packages: dict, starts) -> Optional[set]:
132
+ """Package-map keys reachable from the selected workspaces (*starts*: set or str) via npm resolution.
133
+
134
+ ``devDependencies`` are followed for each start (npm installs every selected
135
+ workspace's dev toolchain) but not for transitive deps. None when no start is
136
+ in *packages* so callers fall back to the full comparison — which would report
137
+ every OTHER workspace's deps (``apps/desktop``, ``web``) as missing and
138
+ reinstall on every launch. Names resolve by walking up ``node_modules``
139
+ ancestors; ``link: true`` entries are followed to their real package.
140
+
141
+ The launch install is scoped with ``npm install --workspace ui-tui`` (see ``_make_tui_argv``), so only
142
+ the ui-tui workspace's dependency closure is written to the hidden ``.package-lock.json``. On Termux it
143
+ additionally selects ui-tui's child ``packages/*`` workspaces, so their devDependencies join the closure
144
+ too. See #66978.
145
+ """
146
+ start_set = {starts} if isinstance(starts, str) else {s for s in starts if s}
147
+ present = [s for s in start_set if s in packages]
148
+ if not present:
149
+ return None
150
+
151
+ def resolve(from_key: str, dep: str) -> Optional[str]:
152
+ base = from_key
153
+ while True:
154
+ candidate = f"{base}/node_modules/{dep}" if base else f"node_modules/{dep}"
155
+ if candidate in packages:
156
+ return candidate
157
+ if not base:
158
+ return None
159
+ base = base.rsplit("/", 1)[0] if "/" in base else ""
160
+
161
+ seen: set = set()
162
+ stack = list(present)
163
+ while stack:
164
+ key = stack.pop()
165
+ if key in seen:
166
+ continue
167
+ seen.add(key)
168
+ entry = packages.get(key)
169
+ if not isinstance(entry, dict):
170
+ continue
171
+ resolved = entry.get("resolved")
172
+ if entry.get("link") and isinstance(resolved, str) and resolved in packages:
173
+ stack.append(resolved)
174
+ fields = ["dependencies", "optionalDependencies", "peerDependencies"]
175
+ if key in start_set:
176
+ fields.append("devDependencies")
177
+ for field in fields:
178
+ deps = entry.get(field)
179
+ if not isinstance(deps, dict):
180
+ continue
181
+ for dep in deps:
182
+ target = resolve(key, dep)
183
+ if target is not None:
184
+ stack.append(target)
185
+ return seen
186
+
187
+
188
+ def _tui_selected_workspace_keys(tui_dir: Path, ws_root: Path) -> set:
189
+ """Lock-map keys the launch install scopes to: ui-tui, plus its child ``packages/*`` on Termux
190
+ (each a dev-included closure root). Empty when ui-tui isn't under *ws_root*."""
191
+ from hermes_cli.main import _is_termux_startup_environment
192
+ try:
193
+ keys = {tui_dir.relative_to(ws_root).as_posix()}
194
+ except ValueError:
195
+ return set()
196
+ if _is_termux_startup_environment():
197
+ for child in _child_workspace_dirs(tui_dir):
198
+ try:
199
+ keys.add(child.relative_to(ws_root).as_posix())
200
+ except ValueError:
201
+ continue
202
+ return keys
203
+
204
+
205
+ def _tui_need_npm_install(root: Path) -> bool:
206
+ """True when @hermes/ink is missing or node_modules is behind package-lock.json.
207
+
208
+ Prebuilt bundle (``dist/entry.js``, no lockfile): nothing to install. The root
209
+ lock is compared to npm's hidden ``node_modules/.package-lock.json`` by CONTENT
210
+ (git bumps mtimes without changing deps): missing from hidden → reinstall
211
+ unless ``optional``/``peer``/``link`` or outside ``node_modules/``; present in
212
+ both → compare the intersection of non-null fields minus
213
+ ``_NPM_LOCK_RUNTIME_KEYS`` (``resolved``/``integrity`` are always in both).
214
+ Hidden-only entries are ignored; unparseable lockfiles fall back to mtime.
215
+ """
216
+ entry = root / "dist" / "entry.js"
217
+ ws_root = _workspace_root(root)
218
+ lock = ws_root / "package-lock.json"
219
+ if entry.is_file() and not lock.is_file():
220
+ return False
221
+
222
+ if not (ws_root / "node_modules" / "@hermes" / "ink" / "package.json").is_file():
223
+ return True
224
+ if not lock.is_file():
225
+ return False
226
+ marker = ws_root / "node_modules" / ".package-lock.json"
227
+ if not marker.is_file():
228
+ return True
229
+
230
+ try:
231
+ wanted = json.loads(lock.read_text(encoding="utf-8")).get("packages") or {}
232
+ installed = json.loads(marker.read_text(encoding="utf-8")).get("packages") or {}
233
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
234
+ return lock.stat().st_mtime > marker.stat().st_mtime
235
+
236
+ def entries_differ(pkg: dict, installed_pkg: dict) -> bool:
237
+ a = {k: v for k, v in pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS}
238
+ b = {k: v for k, v in installed_pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS}
239
+ 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())
240
+
241
+ # Shared workspace checkout: the launch install is scoped to ui-tui (+ child
242
+ # packages on Termux), so limit the comparison to that closure. Standalone /
243
+ # own-lockfile layouts do a full install and keep the full comparison.
244
+ # Limit the comparison to the same selected-workspace closure so unrelated workspace deps (apps/desktop,
245
+ # web, …) don't force a reinstall every launch (#66978).
246
+ closure: Optional[set] = None
247
+ if ws_root != root:
248
+ selected = _tui_selected_workspace_keys(root, ws_root)
249
+ if selected:
250
+ closure = _npm_lock_workspace_closure(wanted, selected)
251
+
252
+ for name, pkg in wanted.items():
253
+ if not name or (closure is not None and name not in closure) or not isinstance(pkg, dict):
254
+ continue
255
+ if name not in installed:
256
+ # Workspace link entries are never materialized by a partial
257
+ # `npm install --workspace ui-tui`; don't force a reinstall for them.
258
+ # Workspace link entries (`"link": true`, paths outside node_modules/ like `apps/desktop`,
259
+ # `node_modules/web`) are never materialized by a partial `npm install --workspace ui-tui` —
260
+ # they're deliberately skipped (see #38772) and would otherwise force a reinstall on every
261
+ # launch.
262
+ if pkg.get("optional") or pkg.get("peer") or pkg.get("link"):
263
+ continue
264
+ if not name.startswith("node_modules/"):
265
+ continue
266
+ return True
267
+ if isinstance(installed[name], dict) and entries_differ(pkg, installed[name]):
268
+ return True
269
+
270
+ return False
271
+
272
+
273
+ _TUI_BUILD_INPUT_DIRS = ("src", "packages/hermes-ink/src")
274
+
275
+ _TUI_BUILD_INPUT_FILES = (
276
+ "package.json",
277
+ "package-lock.json",
278
+ "tsconfig.json",
279
+ "tsconfig.build.json",
280
+ "babel.compiler.config.cjs",
281
+ "scripts/build.mjs",
282
+ "packages/hermes-ink/package.json",
283
+ "packages/hermes-ink/index.js",
284
+ "packages/hermes-ink/text-input.js",
285
+ )
286
+
287
+ _TUI_BUILD_INPUT_SUFFIXES = frozenset({".cjs", ".js", ".jsx", ".json", ".mjs", ".ts", ".tsx"})
288
+
289
+
290
+ def _iter_tui_build_inputs(root: Path):
291
+ """Yield source/config files that affect ``ui-tui/dist/entry.js``."""
292
+ for rel in _TUI_BUILD_INPUT_FILES:
293
+ path = root / rel
294
+ if path.is_file():
295
+ yield path
296
+
297
+ for rel in _TUI_BUILD_INPUT_DIRS:
298
+ base = root / rel
299
+ if not base.is_dir():
300
+ continue
301
+ for path in base.rglob("*"):
302
+ if path.is_file() and path.suffix in _TUI_BUILD_INPUT_SUFFIXES:
303
+ yield path
304
+
305
+
306
+ def _tui_need_rebuild(root: Path) -> bool:
307
+ """True when ``dist/entry.js`` is missing or older than TUI inputs (Termux cold-start saver);
308
+ ``HERMES_TUI_FORCE_BUILD=1`` forces a rebuild."""
309
+ force = (os.environ.get("HERMES_TUI_FORCE_BUILD") or "").strip().lower()
310
+ if force in {"1", "true", "yes", "on"}:
311
+ return True
312
+
313
+ try:
314
+ output_mtime = (root / "dist" / "entry.js").stat().st_mtime
315
+ except OSError:
316
+ return True
317
+
318
+ for path in _iter_tui_build_inputs(root):
319
+ try:
320
+ if path.stat().st_mtime > output_mtime:
321
+ return True
322
+ except OSError:
323
+ return True
324
+ return False
325
+
326
+
327
+ def _ensure_tui_node() -> None:
328
+ """Ensure `node` + `npm` are on PATH: else run node-bootstrap.sh `ensure_node` and prepend
329
+ the resolved node dir to PATH. ``HERMES_SKIP_NODE_BOOTSTRAP=1`` disables auto-install."""
330
+ from hermes_cli.main import PROJECT_ROOT
331
+ if shutil.which("node") and shutil.which("npm"):
332
+ return
333
+ if os.environ.get("HERMES_SKIP_NODE_BOOTSTRAP"):
334
+ return
335
+
336
+ helper = PROJECT_ROOT / "scripts" / "lib" / "node-bootstrap.sh"
337
+ if not helper.is_file():
338
+ return
339
+
340
+ from hermes_constants import get_hermes_home
341
+ hermes_home = str(get_hermes_home())
342
+ try:
343
+ # Helper logs to stderr; stdout carries `command -v node` — subshell PATH
344
+ # edits don't leak back into Python, so the capture is the bridge.
345
+ result = subprocess.run(
346
+ ["bash", "-c", f'source "{helper}" >&2 && ensure_node >&2 && command -v node'],
347
+ env={**os.environ, "HERMES_HOME": hermes_home},
348
+ capture_output=True, text=True, encoding="utf-8", errors="replace", check=False)
349
+ except (OSError, subprocess.SubprocessError):
350
+ return
351
+
352
+ parts = os.environ.get("PATH", "").split(os.pathsep)
353
+ resolved = (result.stdout or "").strip()
354
+ extras = [Path(resolved).resolve().parent] if resolved else []
355
+ extras += [Path(hermes_home) / "node" / "bin", Path.home() / ".local" / "bin"]
356
+ for extra in extras:
357
+ s = str(extra)
358
+ if extra.is_dir() and s not in parts:
359
+ parts.insert(0, s)
360
+ os.environ["PATH"] = os.pathsep.join(parts)
361
+
362
+
363
+ def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None:
364
+ """Find a pre-built TUI entry.js bundled in the wheel."""
365
+ if hermes_cli_dir is None:
366
+ hermes_cli_dir = Path(__file__).parent
367
+ bundled = hermes_cli_dir / "tui_dist" / "entry.js"
368
+ return bundled if bundled.is_file() else None
369
+
370
+
371
+ def _restore_tui_workspace(tui_dir: Path) -> bool:
372
+ """Best-effort ``git restore`` of a missing ``ui-tui/`` (Windows AV/NTFS filters can delete
373
+ tracked files after ``hermes update``); True when the directory exists afterwards.
374
+
375
+ On Windows an antivirus / NTFS filter driver can leave tracked ``ui-tui/`` files deleted in the working
376
+ tree after ``hermes update`` (HEAD stays intact; the files just vanish — see issue #49145). Those files
377
+ are tracked, so ``git restore`` puts them back deterministically. Best-effort: returns False (rather
378
+ than raising) when git is unavailable, this isn't a checkout, or the restore leaves the directory still
379
+ missing — the caller then prints the manual-recovery message.
380
+ """
381
+ git = shutil.which("git")
382
+ if not git or not (tui_dir.parent / ".git").exists():
383
+ return False
384
+ try:
385
+ subprocess.run(
386
+ [git, "restore", "--", tui_dir.name], cwd=str(tui_dir.parent), capture_output=True,
387
+ text=True, encoding="utf-8", errors="replace", check=False)
388
+ except OSError:
389
+ return False
390
+ return tui_dir.is_dir()
391
+
392
+
393
+ def _ensure_tui_workspace(tui_dir: Path) -> None:
394
+ """Ensure ``ui-tui/`` exists before it is used as a subprocess cwd (else ``NotADirectoryError``
395
+ / ``WinError 267`` with no usable message): git-restore first, then abort with recovery steps.
396
+
397
+ Without this, a missing workspace falls through to ``subprocess.run(..., cwd=<missing ui-tui>)``, which
398
+ crashes with ``NotADirectoryError`` (``WinError 267`` on Windows) instead of a usable message (#49145).
399
+ We first try to self-heal via ``git restore``; only if that can't recover the directory do we abort with
400
+ concrete manual-recovery steps.
401
+ """
402
+ if tui_dir.is_dir():
403
+ return
404
+
405
+ if _restore_tui_workspace(tui_dir):
406
+ if not os.environ.get("HERMES_QUIET"):
407
+ print(f"Restored missing TUI workspace: {tui_dir}")
408
+ return
409
+
410
+ print(
411
+ "Error: the TUI workspace is missing from this Hermes checkout.\n"
412
+ f"Expected directory: {tui_dir}\n"
413
+ "This usually means `hermes update` left tracked ui-tui files deleted.\n"
414
+ "Recovery:\n"
415
+ " 1. From the Hermes checkout, run `git restore -- ui-tui`\n"
416
+ " 2. Run `npm install --silent --no-fund --no-audit --progress=false`\n"
417
+ " 3. Retry `hermes --tui`\n"
418
+ "If the checkout is still inconsistent, run `hermes update --force`.",
419
+ file=sys.stderr)
420
+ sys.exit(1)
421
+
422
+
423
+ def _npm_lifecycle_env(env: dict[str, str] | None = None) -> dict[str, str]:
424
+ """Build a clean environment for the pinned UI toolchain lifecycle."""
425
+ run_env = {**os.environ, **(env or {}), "CI": "1"}
426
+ # esbuild treats this as an executable override. If a shell points it at a
427
+ # different release, the pinned package's postinstall rejects that binary.
428
+ run_env.pop("ESBUILD_BINARY_PATH", None)
429
+ # The repo-root ``.npmrc`` is git-tracked, so the updater's autostash parks
430
+ # any mirror/proxy line added there and every update reinstalls without it
431
+ # (restricted networks then prune optional native deps like get-windows and
432
+ # the rebuild fails). ``$HERMES_HOME`` lives outside the git tree and the
433
+ # update hand-off already carries ``HERMES_HOME`` down to every npm child.
434
+ # An explicit ``NPM_CONFIG_USERCONFIG`` wins (#106373).
435
+ from hermes_constants import get_hermes_home
436
+ npmrc = get_hermes_home() / "npmrc"
437
+ if npmrc.is_file():
438
+ run_env.setdefault("NPM_CONFIG_USERCONFIG", os.fspath(npmrc))
439
+ return run_env
440
+
441
+
442
+ def _tui_node_bin(bin: str) -> str:
443
+ """Resolve ``node``/``npm`` for the TUI launch, or exit with a hint. ``HERMES_NODE`` wins for node;
444
+ ``find_node_executable()`` sees the managed ``$HERMES_HOME/node`` tree a bare which() misses."""
445
+ if bin == "node":
446
+ env_node = os.environ.get("HERMES_NODE")
447
+ if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK):
448
+ return env_node
449
+ from hermes_constants import find_node_executable
450
+ path = find_node_executable(bin)
451
+ if not path and bin == "node":
452
+ with contextlib.suppress(Exception):
453
+ from hermes_cli.dep_ensure import ensure_dependency
454
+ if ensure_dependency("node"):
455
+ path = find_node_executable("node")
456
+ if not path:
457
+ print(f"{bin} not found — install Node.js to use the TUI.")
458
+ sys.exit(1)
459
+ return path
460
+
461
+
462
+ def _exit_on_npm_failure(result: subprocess.CompletedProcess, message: str, *, sep: str) -> None:
463
+ """Print *message* plus the last 30 lines of npm output and exit 1 on a non-zero rc."""
464
+ if result.returncode == 0:
465
+ return
466
+ combined = f"{result.stdout or ''}{sep}{result.stderr or ''}".strip()
467
+ preview = "\n".join(combined.splitlines()[-30:])
468
+ print(message)
469
+ if preview:
470
+ print(preview)
471
+ sys.exit(1)
472
+
473
+
474
+ def _run_tui_npm_build(npm: str, cwd: Path, failure_message: str) -> None:
475
+ """``npm run build`` in *cwd*; exit with *failure_message* + output tail on failure."""
476
+ result = subprocess.run(
477
+ [npm, "run", "build"], cwd=str(cwd), capture_output=True, text=True, encoding="utf-8",
478
+ errors="replace", env=_npm_lifecycle_env())
479
+ _exit_on_npm_failure(result, failure_message, sep="")
480
+
481
+
482
+ def _install_tui_dependencies(tui_dir: Path, *, termux_startup: bool) -> None:
483
+ """``npm install`` for the TUI workspace, with one EBADENGINE repair retry. Exits on failure.
484
+
485
+ ``--workspace ui-tui`` avoids resolving apps/desktop (Electron + node-pty) and
486
+ is omitted when ui-tui/ has its own lockfile. ``--include=dev``: the build
487
+ toolchain is in devDependencies and an inherited ``NODE_ENV=production`` /
488
+ ``omit=dev`` would silently skip it.
489
+ """
490
+ npm = _tui_node_bin("npm")
491
+ if not os.environ.get("HERMES_QUIET"):
492
+ print("Installing TUI dependencies…")
493
+ npm_cwd = _workspace_root(tui_dir)
494
+ # --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty). See #38772. When ui-tui/ has
495
+ # its own package-lock.json (e.g. curl install), _workspace_root() returns tui_dir itself. Passing
496
+ # --workspace in that case fails because npm cannot find a workspace named "ui-tui" inside ui-tui/. See
497
+ # #42973.
498
+ npm_workspace_args: tuple[str, ...] = () if npm_cwd == tui_dir else ("--workspace", "ui-tui")
499
+ if termux_startup:
500
+ npm_cwd, npm_workspace_args = _termux_workspace_install_context(tui_dir, include_child_workspaces=True)
501
+ npm_install_cmd = [
502
+ npm, "install", *npm_workspace_args,
503
+ "--include=dev", "--silent", "--no-fund", "--no-audit", "--progress=false",
504
+ ]
505
+
506
+ def _run_tui_install() -> subprocess.CompletedProcess:
507
+ from hermes_constants import with_hermes_node_path
508
+ # Managed tree first on PATH: if the EBADENGINE repair provisioned a
509
+ # managed Node, npm's shebang/lifecycle scripts must resolve that node.
510
+ return subprocess.run(
511
+ npm_install_cmd, cwd=str(npm_cwd), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
512
+ text=True, encoding="utf-8", errors="replace",
513
+ env=_npm_lifecycle_env(with_hermes_node_path()))
514
+
515
+ result = _run_tui_install()
516
+ if result.returncode != 0:
517
+ # An npm outside the root `engines.npm` range fails before doing any work;
518
+ # repair once (upgrade a managed npm in place, or provision a managed
519
+ # runtime) and retry rather than dumping EBADENGINE at the user.
520
+ from hermes_cli.npm_engine import maybe_repair_npm_engine
521
+ repaired_npm = maybe_repair_npm_engine(npm, f"{result.stdout or ''}\n{result.stderr or ''}")
522
+ if repaired_npm:
523
+ npm_install_cmd[0] = repaired_npm
524
+ result = _run_tui_install()
525
+ _exit_on_npm_failure(result, "npm install failed.", sep="\n")
526
+
527
+
528
+ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
529
+ """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild)."""
530
+ from hermes_cli.main import _is_termux_startup_environment
531
+ _ensure_tui_node()
532
+
533
+ # Footgun: --dev against a prebuilt bundle that has no source/node_modules.
534
+ ext_dir = os.environ.get("HERMES_TUI_DIR")
535
+ if tui_dev and ext_dir:
536
+ print(
537
+ f"Error: --dev is incompatible with HERMES_TUI_DIR={ext_dir}\n"
538
+ f"The prebuilt TUI has no source code to hot-reload.\n"
539
+ f"Unset HERMES_TUI_DIR (e.g. `unset HERMES_TUI_DIR`) to use --dev from a checkout.",
540
+ file=sys.stderr)
541
+ sys.exit(1)
542
+
543
+ # 1. Prebuilt bundle (nix / packaged release / Docker image): just run it.
544
+ # Must run BEFORE _ensure_tui_workspace(): a prebuilt install ships
545
+ # hermes_cli/tui_dist/entry.js but never ui-tui/ (git checkouts only).
546
+ # 1. A prebuilt install (Docker image, Nix build, or prior `npm run build`) ships
547
+ # hermes_cli/tui_dist/entry.js but never ships ui-tui/ at all (that directory only exists in a git
548
+ # checkout) — so requiring the workspace to exist first made every prebuilt dashboard Chat tab
549
+ # connection hard-exit before it ever got a chance to try the bundled entry.js it already has. See
550
+ # #56665.
551
+ if not tui_dev:
552
+ if ext_dir:
553
+ p = Path(ext_dir)
554
+ if (p / "dist" / "entry.js").is_file():
555
+ return [_tui_node_bin("node"), "--expose-gc", str(p / "dist" / "entry.js")], p
556
+
557
+ bundled = _find_bundled_tui()
558
+ if bundled is not None:
559
+ return [_tui_node_bin("node"), "--expose-gc", str(bundled)], bundled.parent
560
+
561
+ # About to npm install/build from source, so the workspace must exist.
562
+ if not ext_dir:
563
+ _ensure_tui_workspace(tui_dir)
564
+
565
+ # 2. Normal flow: npm install if needed, esbuild, then node dist/entry.js.
566
+ # --dev: npm install if needed, then tsx src/entry.tsx.
567
+ termux_startup = _is_termux_startup_environment()
568
+ termux_need_rebuild = termux_startup and not tui_dev and _tui_need_rebuild(tui_dir)
569
+ skip_install_for_fresh_termux_bundle = termux_startup and not tui_dev and not termux_need_rebuild
570
+ did_install = False
571
+ if not skip_install_for_fresh_termux_bundle and _tui_need_npm_install(tui_dir):
572
+ _install_tui_dependencies(tui_dir, termux_startup=termux_startup)
573
+ did_install = True
574
+
575
+ if tui_dev:
576
+ # --dev runs src/entry.tsx directly, but @hermes/ink resolves through
577
+ # packages/hermes-ink/dist/entry-exports.js; a stale dist after a pull
578
+ # leaves newer hooks/components missing at runtime. Prebuild it here.
579
+ npm = _tui_node_bin("npm")
580
+ _run_tui_npm_build(npm, tui_dir / "packages" / "hermes-ink", "TUI dev prebuild failed.")
581
+ tsx = tui_dir / "node_modules" / ".bin" / "tsx"
582
+ if tsx.exists():
583
+ return [str(tsx), "src/entry.tsx"], tui_dir
584
+ return [npm, "start"], tui_dir
585
+
586
+ # Desktop/dev launches always rebuild; Termux cold starts use the freshness
587
+ # check because esbuild startup is expensive on old mobile CPUs.
588
+ if not termux_startup or did_install or termux_need_rebuild:
589
+ _run_tui_npm_build(_tui_node_bin("npm"), tui_dir, "TUI build failed.")
590
+
591
+ return [_tui_node_bin("node"), "--expose-gc", str(tui_dir / "dist" / "entry.js")], tui_dir
592
+
593
+
594
+ def _split_comma_items(items, *, split_non_str: bool = True) -> list[str]:
595
+ """Flatten str / list (comma-separated) input into stripped non-empty parts."""
596
+ raw_items = [items] if isinstance(items, str) else items
597
+ if not isinstance(raw_items, (list, tuple)):
598
+ raw_items = [raw_items]
599
+ normalized: list[str] = []
600
+ for item in raw_items:
601
+ if split_non_str or isinstance(item, str):
602
+ normalized.extend(part.strip() for part in str(item).split(","))
603
+ else:
604
+ normalized.append(str(item).strip())
605
+ return [item for item in normalized if item]
606
+
607
+
608
+ def _normalize_tui_toolsets(toolsets: object) -> list[str]:
609
+ """Normalize argparse/Fire-style toolset input for the TUI subprocess."""
610
+ try:
611
+ from hermes_cli.oneshot import _normalize_toolsets
612
+ return _normalize_toolsets(toolsets) or []
613
+ except (AttributeError, ImportError):
614
+ return _split_comma_items(toolsets, split_non_str=False) if toolsets else []
615
+
616
+
617
+ def _read_cgroup_memory_limit() -> Optional[int]:
618
+ """Container memory limit in bytes, or None if unconstrained (v2 ``memory.max``, then v1).
619
+
620
+ V8 is NOT cgroup-aware: a flat 8GB heap grows past a smaller container limit
621
+ and the OOM-killer SIGKILLs Node with no breadcrumb (bare ``stdin EOF``).
622
+ """
623
+ candidates = (
624
+ "/sys/fs/cgroup/memory.max", # cgroup v2
625
+ "/sys/fs/cgroup/memory/memory.limit_in_bytes", # cgroup v1
626
+ )
627
+ for path in candidates:
628
+ try:
629
+ with open(path, "r", encoding="utf-8") as f:
630
+ raw = f.read().strip()
631
+ except (OSError, ValueError):
632
+ continue
633
+ if raw == "max":
634
+ return None
635
+ if not raw:
636
+ continue # don't mistake an empty v2 file for "unlimited"
637
+ try:
638
+ limit = int(raw)
639
+ except ValueError:
640
+ continue
641
+ if limit <= 0:
642
+ continue
643
+ if limit >= (1 << 50): # >= ~1 PB is the v1 "unlimited" sentinel
644
+ return None
645
+ return limit
646
+ return None
647
+
648
+
649
+ def _resolve_tui_heap_mb(default_mb: int = 8192) -> int:
650
+ """V8 ``--max-old-space-size`` (MB) that fits the container: ``default_mb`` when unconstrained,
651
+ else 75% of the cgroup limit (headroom for non-heap RSS + the gateway child), floored at
652
+ 1536MB when the container is > 2GB (below that V8 GC-thrashes)."""
653
+ limit = _read_cgroup_memory_limit()
654
+ if not limit:
655
+ return default_mb
656
+ limit_mb = limit // (1024 * 1024)
657
+ sized = int(limit_mb * 0.75)
658
+ if sized >= default_mb:
659
+ return default_mb
660
+ # Below the floor, honor the limit-derived value anyway: a graceful V8 exit
661
+ # beats a silent cgroup kill.
662
+ return max(1536, sized) if limit_mb > 2048 else sized
663
+
664
+
665
+ def _safe_tui_cwd(env: Optional[dict] = None) -> str:
666
+ """Return a stable cwd value for the Node TUI child environment."""
667
+ from hermes_cli.main import PROJECT_ROOT
668
+ try:
669
+ return os.getcwd()
670
+ except FileNotFoundError:
671
+ candidate = ((env or {}).get("PWD") or os.environ.get("PWD") or "").strip()
672
+ if candidate and Path(candidate).is_dir():
673
+ return candidate
674
+ return str(PROJECT_ROOT)
675
+
676
+
677
+ def _apply_tui_python_env(env: dict) -> None:
678
+ """Seed/repair Python-related env vars shared by CLI and dashboard TUI launches."""
679
+ from hermes_cli.main import PROJECT_ROOT
680
+ src_root = str(env.get("HERMES_PYTHON_SRC_ROOT") or "").strip()
681
+ if not src_root or not Path(src_root).is_dir():
682
+ env["HERMES_PYTHON_SRC_ROOT"] = str(PROJECT_ROOT)
683
+
684
+ cwd = str(env.get("HERMES_CWD") or "").strip()
685
+ if not cwd or not Path(cwd).is_dir():
686
+ env["HERMES_CWD"] = _safe_tui_cwd(env)
687
+
688
+ python = str(env.get("HERMES_PYTHON") or "").strip()
689
+ if os.path.dirname(python):
690
+ python_path = Path(python)
691
+ if not python_path.is_absolute():
692
+ python_path = Path(env["HERMES_CWD"]) / python_path
693
+ python_is_executable = python_path.is_file() and os.access(python_path, os.X_OK)
694
+ else:
695
+ python_is_executable = bool(shutil.which(python, path=env.get("PATH")))
696
+ if not python_is_executable:
697
+ env["HERMES_PYTHON"] = sys.executable
698
+
699
+
700
+ def _setup_tui_worktree() -> dict:
701
+ """Create the ``--worktree`` checkout for a TUI launch (prune + async pack maintenance); exits on failure."""
702
+ wt_info = None
703
+ try:
704
+ from cli import _git_repo_root, _maintain_pack_health, _prune_stale_worktrees, _setup_worktree
705
+ repo = _git_repo_root()
706
+ if repo:
707
+ _prune_stale_worktrees(repo)
708
+ # Repack on pack sprawl so `worktree add` never crawls on a
709
+ # multi-agent box; on a thread so it can't block launch.
710
+ import threading as _threading
711
+
712
+ _threading.Thread(
713
+ target=_maintain_pack_health, args=(repo,), name="pack-maintenance", daemon=True).start()
714
+ wt_info = _setup_worktree()
715
+ except Exception as exc:
716
+ print(f"✗ Failed to create TUI worktree: {exc}", file=sys.stderr)
717
+ if not wt_info:
718
+ sys.exit(1)
719
+ return wt_info
720
+
721
+
722
+ def _launch_tui(
723
+ resume_session_id: Optional[str] = None, tui_dev: bool = False, model: Optional[str] = None,
724
+ provider: Optional[str] = None, toolsets: object = None, skills: object = None,
725
+ verbose: Optional[bool] = None, quiet: bool = False, query: Optional[str] = None,
726
+ image: Optional[str] = None, worktree: bool = False, checkpoints: bool = False,
727
+ pass_session_id: bool = False, max_turns: Optional[int] = None, accept_hooks: bool = False):
728
+ """Replace current process with the TUI."""
729
+ from hermes_cli.main import PROJECT_ROOT
730
+ tui_dir = PROJECT_ROOT / "ui-tui"
731
+
732
+ import tempfile
733
+ # TUI child is a hermes process: propagate the profile-home contract via
734
+ # the single factory; keep secrets (the TUI/agent needs provider creds).
735
+ from tools.environments.local import build_subprocess_env
736
+ env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=True)
737
+ from hermes_cli.shared_session_attach import configure_tui_attachment
738
+ try:
739
+ configure_tui_attachment(env, resume_session_id)
740
+ except (ValueError, RuntimeError) as exc:
741
+ print(f"Error: {exc}", file=sys.stderr)
742
+ raise SystemExit(1) from None
743
+ try:
744
+ from hermes_cli.config import apply_terminal_config_to_env
745
+ apply_terminal_config_to_env(env=env)
746
+ except Exception:
747
+ logger.debug("Failed to apply terminal config bridge for TUI launch", exc_info=True)
748
+ active_session_fd, active_session_file = tempfile.mkstemp(
749
+ prefix="hermes-tui-active-session-", suffix=".json")
750
+ os.close(active_session_fd)
751
+ env["HERMES_TUI_ACTIVE_SESSION_FILE"] = active_session_file
752
+ env.setdefault("NODE_ENV", "development" if tui_dev else "production")
753
+
754
+ wt_info = None
755
+ if worktree:
756
+ wt_info = _setup_tui_worktree()
757
+ env["HERMES_CWD"] = wt_info["path"]
758
+ env["TERMINAL_CWD"] = wt_info["path"]
759
+
760
+ _apply_tui_python_env(env)
761
+
762
+ skills_value = ""
763
+ if skills:
764
+ skills_value = (
765
+ ",".join(_split_comma_items(skills)) if isinstance(skills, (list, tuple)) else str(skills).strip())
766
+ for key, value in (
767
+ ("HERMES_MODEL", model), ("HERMES_INFERENCE_MODEL", model),
768
+ ("HERMES_TUI_PROVIDER", provider), ("HERMES_INFERENCE_PROVIDER", provider),
769
+ ("HERMES_TUI_TOOLSETS", ",".join(_normalize_tui_toolsets(toolsets))),
770
+ ("HERMES_TUI_SKILLS", skills_value),
771
+ ("HERMES_TUI_QUERY", query), ("HERMES_TUI_IMAGE", image),
772
+ ("HERMES_TUI_CHECKPOINTS", "1" if checkpoints else None),
773
+ ("HERMES_TUI_PASS_SESSION_ID", "1" if pass_session_id else None),
774
+ ("HERMES_TUI_MAX_TURNS", str(max_turns) if max_turns is not None else None),
775
+ ("HERMES_TUI_TOOL_PROGRESS", "verbose" if verbose else "off" if quiet else None),
776
+ ("HERMES_ACCEPT_HOOKS", "1" if accept_hooks else None)):
777
+ if value:
778
+ env[key] = value
779
+ # Generous V8 heap (8GB target; default cap can fatal-OOM on long sessions),
780
+ # sized below the cgroup limit by _resolve_tui_heap_mb() so V8 exits
781
+ # gracefully instead of being reaped silently. Token-level merge respects a
782
+ # user-supplied --max-old-space-size. --expose-gc is NOT added here: Node
783
+ # rejects it in NODE_OPTIONS; _make_tui_argv() passes it as a direct flag.
784
+ _tokens = env.get("NODE_OPTIONS", "").split()
785
+ if not any(t.startswith("--max-old-space-size=") for t in _tokens):
786
+ _tokens.append(f"--max-old-space-size={_resolve_tui_heap_mb()}")
787
+ env["NODE_OPTIONS"] = " ".join(_tokens)
788
+ # HERMES_TUI_RESUME is an internal hand-off to the Ink app. We start from a
789
+ # full os.environ snapshot, so a stale exported value would make a plain
790
+ # `hermes --tui` try to resume a non-existent session; only forward the id
791
+ # argparse resolved for this invocation.
792
+ env.pop("HERMES_TUI_RESUME", None)
793
+ if resume_session_id:
794
+ env["HERMES_TUI_RESUME"] = resume_session_id
795
+
796
+ argv, cwd = _make_tui_argv(tui_dir, tui_dev)
797
+ code: Optional[int] = None
798
+ try:
799
+ try:
800
+ code = subprocess.call(argv, cwd=str(cwd), env=env)
801
+ except KeyboardInterrupt:
802
+ code = 130
803
+
804
+ if code in {0, 130}:
805
+ _print_tui_exit_summary(resume_session_id, active_session_file)
806
+ finally:
807
+ with contextlib.suppress(OSError):
808
+ os.unlink(active_session_file)
809
+ if wt_info:
810
+ with contextlib.suppress(Exception):
811
+ from cli import _cleanup_worktree
812
+ _cleanup_worktree(wt_info)
813
+
814
+ # Exit code 42 = TUI requested an update. Relaunch as `hermes update`;
815
+ # preserve_inherited=False keeps --tui and other flags out of the subcommand.
816
+ if code == 42:
817
+ from hermes_cli.relaunch import relaunch
818
+ print("\n☤ Launching update...\n")
819
+ relaunch(["update"], preserve_inherited=False)
820
+
821
+ sys.exit(code)
822
+
823
+
824
+ def _pin_kanban_board_env() -> None:
825
+ """Pin the active kanban board into ``HERMES_KANBAN_BOARD`` so in-process tools and shelled-out
826
+ ``hermes kanban`` calls agree even if a concurrent ``boards switch`` flips the file mid-turn.
827
+
828
+ Without this, in-process tools (``kanban_*``) and shelled-out CLI calls (``hermes kanban …``) resolve
829
+ the board on different paths: the env-pin if set, otherwise the global ``<root>/kanban/current`` file. A
830
+ concurrent ``hermes kanban boards switch`` from another session can flip the file mid-turn, so the same
831
+ chat sees its tool calls hit board A while its shell calls hit board B (#20074). Pinning at chat boot
832
+ mirrors what the dispatcher already does for spawned workers.
833
+ """
834
+ if os.environ.get("HERMES_KANBAN_BOARD"):
835
+ return
836
+ with contextlib.suppress(Exception):
837
+ from hermes_cli.kanban_db import get_current_board
838
+ os.environ["HERMES_KANBAN_BOARD"] = get_current_board()
839
+
840
+
841
+ def _sync_bundled_skills_quietly() -> None:
842
+ """Seed ``~/.hermes/skills/`` with the bundled library (idempotent, milliseconds when synced).
843
+ Failures are swallowed: skills are an enhancement, not a hard dependency."""
844
+ with contextlib.suppress(Exception):
845
+ from tools.skills_sync import sync_skills
846
+ sync_skills(quiet=True)
847
+
848
+
849
+ def _resolve_use_tui(args) -> bool:
850
+ """Decide whether to launch the TUI: ``--cli`` → classic; ``--tui`` → TUI; no TTY → classic;
851
+ ``HERMES_TUI=1`` → TUI; ``display.interface`` config; default classic.
852
+
853
+ The TTY gate is load-bearing: ambient preferences must never hijack a piped
854
+ ``hermes chat -q`` (kanban workers, cron) — the Ink no-TTY bail-out exits 0 and
855
+ the worker dies with a protocol violation. Explicit ``--tui`` still bails out.
856
+ """
857
+ if getattr(args, "cli", False):
858
+ return False
859
+ if getattr(args, "tui", False):
860
+ return True
861
+ try:
862
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
863
+ return False
864
+ except Exception:
865
+ return False
866
+ if os.environ.get("HERMES_TUI") == "1":
867
+ return True
868
+ try:
869
+ from hermes_cli.config import load_config
870
+ iface = (load_config().get("display", {}) or {}).get("interface", "cli")
871
+ return isinstance(iface, str) and iface.strip().lower() == "tui"
872
+ except Exception:
873
+ return False
hermes_cli/mcp_picker.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MCP picker — interactive `hermes mcp picker` (also the default `hermes mcp`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from typing import List, Optional
8
+
9
+ from hermes_cli.colors import Colors
10
+ from hermes_cli.cli_output import prompt_yes_no
11
+ from hermes_cli.curses_ui import curses_single_select
12
+ from hermes_cli.mcp_catalog import (
13
+ CatalogEntry, CatalogError, catalog_diagnostics, install_entry, is_enabled, is_installed,
14
+ list_catalog, installed_servers, remove_server, server_enabled, uninstall_entry, _say,
15
+ )
16
+ from hermes_cli.config import load_config, save_config
17
+
18
+ _STATUS_NOT_INSTALLED = "available"
19
+ _STATUS_DISABLED = "installed (disabled)"
20
+ _STATUS_ENABLED = "enabled"
21
+ _STATUS_CUSTOM_ENABLED = "custom — enabled"
22
+ _STATUS_CUSTOM_DISABLED = "custom — disabled"
23
+
24
+
25
+ @dataclass
26
+ class _Row:
27
+ """A picker row. ``entry`` is set for catalog rows; custom MCPs carry only name/description/status."""
28
+
29
+ name: str
30
+ description: str
31
+ status: str
32
+ entry: Optional[CatalogEntry] = None # None for non-catalog (custom) rows
33
+
34
+ @property
35
+ def is_custom(self) -> bool:
36
+ return self.entry is None
37
+
38
+
39
+ def _build_rows() -> List[_Row]:
40
+ """Return catalog rows + any custom (non-catalog) MCPs found in config."""
41
+ catalog_entries = list_catalog()
42
+ catalog_names = {e.name for e in catalog_entries}
43
+ servers = installed_servers()
44
+
45
+ rows: List[_Row] = []
46
+ for entry in catalog_entries:
47
+ cfg = servers.get(entry.name)
48
+ if entry.name not in servers:
49
+ status = _STATUS_NOT_INSTALLED
50
+ elif cfg and server_enabled(cfg):
51
+ status = _STATUS_ENABLED
52
+ else:
53
+ status = _STATUS_DISABLED
54
+ rows.append(_Row(entry.name, entry.description, status, entry))
55
+ # Custom (non-catalog) MCPs: the transport URL/command doubles as the description.
56
+ for name, cfg in sorted(servers.items()):
57
+ if name in catalog_names:
58
+ continue
59
+ status = _STATUS_CUSTOM_ENABLED if server_enabled(cfg) else _STATUS_CUSTOM_DISABLED
60
+ rows.append(_Row(name, str(cfg.get("url") or cfg.get("command") or "(no transport)"), status))
61
+ return rows
62
+
63
+
64
+ def _format_row(row: _Row) -> str:
65
+ return f"{row.name:<18} {row.status:<24} {row.description}"
66
+
67
+
68
+
69
+ def _enable_disable(name: str, *, enable: bool) -> None:
70
+ cfg = load_config()
71
+ servers = cfg.get("mcp_servers") or {}
72
+ server = servers.get(name)
73
+ if not server:
74
+ _say(f" '{name}' is not installed.", Colors.RED)
75
+ return
76
+ server["enabled"] = enable
77
+ cfg["mcp_servers"] = servers
78
+ save_config(cfg)
79
+ _say(
80
+ f" ✓ '{name}' {'enabled' if enable else 'disabled'}. "
81
+ "Start a new Hermes session for changes to take effect."
82
+ )
83
+
84
+
85
+ def _configure_tools(name: str) -> None:
86
+ """Open the tool selection checklist for an already-installed MCP."""
87
+ from argparse import Namespace
88
+ from hermes_cli.mcp_config import cmd_mcp_configure
89
+
90
+ cmd_mcp_configure(Namespace(name=name))
91
+
92
+
93
+ def _remove_custom(name: str) -> None:
94
+ """Remove a non-catalog MCP entry from config.yaml."""
95
+ if not is_installed(name):
96
+ _say(f" '{name}' is not configured.", Colors.RED)
97
+ return
98
+ if not prompt_yes_no(f"Remove '{name}' from mcp_servers?", default=False):
99
+ return
100
+ remove_server(name)
101
+ _say(f" ✓ Removed '{name}'")
102
+
103
+
104
+ def _install(entry: CatalogEntry, verb: str) -> bool:
105
+ """Install *entry*, printing (not raising) a CatalogError. True on success."""
106
+ try:
107
+ install_entry(entry, enable=True)
108
+ except CatalogError as exc:
109
+ _say(f" ✗ {verb} failed: {exc}", Colors.RED)
110
+ return False
111
+ return True
112
+
113
+
114
+ def _uninstall(name: str) -> None:
115
+ if not prompt_yes_no(f"Uninstall '{name}'?", default=False):
116
+ return
117
+ if uninstall_entry(name):
118
+ _say(
119
+ f" ✓ Uninstalled '{name}'. "
120
+ "Credentials in .env preserved — delete manually if no longer needed."
121
+ )
122
+ else:
123
+ _say(f" '{name}' was not installed", Colors.DIM)
124
+
125
+
126
+ def _run_submenu(title: str, actions: list) -> None:
127
+ """Show a single-select of ``(label, callback)`` pairs and run the picked callback."""
128
+ choice = curses_single_select(title, [label for label, _ in actions])
129
+ if choice is not None:
130
+ actions[choice][1]()
131
+
132
+
133
+ def _handle_row(row: _Row) -> None:
134
+ """Act on the picked row based on its current status."""
135
+ if row.entry and not is_installed(row.name):
136
+ _install(row.entry, "install")
137
+ return
138
+ if row.entry and not is_enabled(row.name):
139
+ _enable_disable(row.name, enable=True)
140
+ return
141
+ if row.is_custom:
142
+ enabled = is_enabled(row.name)
143
+ _run_submenu(f"Action for '{row.name}' (custom)", [
144
+ ("Configure tools (probe server + re-pick)", lambda: _configure_tools(row.name)),
145
+ ("Enable" if not enabled else "Disable",
146
+ lambda: _enable_disable(row.name, enable=not is_enabled(row.name))),
147
+ ("Remove from config", lambda: _remove_custom(row.name))])
148
+ return
149
+ # Catalog row, installed + enabled
150
+ print()
151
+ _say(f" '{row.name}' is already enabled.", Colors.DIM)
152
+ _run_submenu(f"Action for '{row.name}'", [
153
+ ("Configure tools (probe server + re-pick)", lambda: _configure_tools(row.name)),
154
+ ("Disable (keep config, stop loading on next session)",
155
+ lambda: _enable_disable(row.name, enable=False)),
156
+ ("Uninstall (remove config and any cloned files)", lambda: _uninstall(row.name)),
157
+ ("Reinstall (re-clone, re-prompt for credentials)",
158
+ lambda: _install(row.entry, "reinstall"))])
159
+
160
+
161
+ def _print_rows_text(rows: List[_Row]) -> None:
162
+ """Plain-text catalog dump: `hermes mcp catalog` output and the non-curses fallback."""
163
+ print()
164
+ if not rows:
165
+ _say(" No MCPs in the catalog or configured.", Colors.DIM)
166
+ print()
167
+ return
168
+
169
+ _say(" MCP Catalog + configured servers:", Colors.CYAN + Colors.BOLD)
170
+ print()
171
+ print(f" {'Name':<18} {'Status':<24} Description")
172
+ print(f" {'-' * 18} {'-' * 24} {'-' * 11}")
173
+ for row in rows:
174
+ print(f" {_format_row(row)}")
175
+ print()
176
+ _say(" Install: hermes mcp install <name> Picker: hermes mcp", Colors.DIM)
177
+ # Manifest-version warnings: the user's Hermes is too old to install everything listed.
178
+ future = [d for d in catalog_diagnostics() if d[1] == "future_manifest"]
179
+ if future:
180
+ print()
181
+ for name, _, _msg in future:
182
+ _say(f" ⚠ '{name}' requires a newer Hermes — run `hermes update` to install this entry.", Colors.YELLOW)
183
+ print()
184
+ print()
185
+
186
+
187
+ def show_catalog() -> None:
188
+ """`hermes mcp catalog` — print the curated list + custom servers, no interaction."""
189
+ _print_rows_text(_build_rows())
190
+
191
+
192
+ def run_picker() -> None:
193
+ """`hermes mcp picker` (and default `hermes mcp`) — interactive selector; re-renders after each
194
+ action until ESC/q."""
195
+ while True:
196
+ rows = _build_rows()
197
+ if not rows or not sys.stdin.isatty():
198
+ _print_rows_text(rows) # non-interactive: degrade to the text dump
199
+ return
200
+ idx = curses_single_select(
201
+ "MCP Catalog — ↑↓ navigate ENTER act on entry ESC/q quit",
202
+ [_format_row(r) for r in rows],
203
+ )
204
+ if idx is None:
205
+ return
206
+ _handle_row(rows[idx])
207
+
208
+
209
+ def install_by_name(identifier: str) -> int:
210
+ """`hermes mcp install <name>` — non-interactive entry-point."""
211
+ from hermes_cli.mcp_catalog import get_entry
212
+
213
+ entry = get_entry(identifier)
214
+ if entry is None:
215
+ _say(
216
+ f" ✗ '{identifier}' is not in the catalog. Run `hermes mcp catalog` to see available entries.",
217
+ Colors.RED,
218
+ )
219
+ return 1
220
+ return 0 if _install(entry, "install") else 1
221
+
222
+
223
+ # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
224
+ # Names external plugins imported from this module before the Sep 2026 decomposition.
225
+ # Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
226
+ # The whole block is removed by reverting the commit that added it.
227
+
228
+
229
+ _PLUGIN_COMPAT_LAZY = {
230
+ 'color': ('hermes_cli.colors', 'color'),
231
+ }
232
+
233
+
234
+ def __getattr__(name): # PEP 562 — lazy so no import cycles
235
+ target = _PLUGIN_COMPAT_LAZY.get(name)
236
+ if target is None:
237
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
238
+ import importlib
239
+ from hermes_cli.plugin_compat import warn_once
240
+ warn_once(__name__, name, *target)
241
+ return getattr(importlib.import_module(target[0]), target[1])
242
+ # ---- END PLUGIN-COMPAT ----
hermes_cli/memory_setup.py ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """hermes memory setup|status — configure memory provider plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import sys
8
+ import shlex
9
+
10
+ from hermes_constants import get_hermes_home
11
+ from hermes_cli.secret_prompt import masked_secret_prompt
12
+
13
+ _CANCELLED = -1
14
+
15
+ # pip name → import name mapping for packages where they differ
16
+ _IMPORT_NAMES = {
17
+ "honcho-ai": "honcho",
18
+ "mem0ai": "mem0",
19
+ "hindsight-client": "hindsight_client",
20
+ "hindsight-all": "hindsight"}
21
+
22
+
23
+ def _provider_pip_dependencies(provider_name: str, declared: list) -> list:
24
+ """Return the pip deps a provider actually needs on THIS install.
25
+
26
+ ``plugin.yaml`` declares the baseline bridge packages; some providers add mode-dependent extras
27
+ at setup time that the manifest can't express.
28
+
29
+ Hindsight's ``local_embedded`` mode installs ``hindsight-all`` (daemon + embedder + client) during
30
+ ``hermes memory setup`` — if the update-time refresh only reinstalled the declared ``hindsight-client``,
31
+ the embedded daemon would stay broken after a venv rebuild stripped ``hindsight-embed`` (#70636).
32
+ """
33
+ deps = list(declared or [])
34
+ if provider_name == "hindsight":
35
+ try:
36
+ import json
37
+ cfg_path = get_hermes_home() / "hindsight" / "config.json"
38
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8")) if cfg_path.exists() else {}
39
+ # "local" is a legacy alias for "local_embedded"
40
+ if cfg.get("mode", "") in {"local", "local_embedded"}:
41
+ deps.append("hindsight-all")
42
+ except Exception:
43
+ pass
44
+ return deps
45
+
46
+
47
+ def _curses_select(
48
+ title: str, items: list[tuple[str, str]], default: int = 0, *, cancel_returns: int | None = None
49
+ ) -> int:
50
+ """Interactive single-select with arrow keys."""
51
+ from hermes_cli.curses_ui import curses_radiolist
52
+
53
+ if cancel_returns is None:
54
+ cancel_returns = default
55
+ display_items = [f"{label} - {desc}" if desc else label for label, desc in items]
56
+ result = curses_radiolist(title, display_items, selected=default, cancel_returns=cancel_returns)
57
+ _clear_interactive_transition()
58
+ return result
59
+
60
+
61
+ def _print_cancelled_setup() -> None:
62
+ print("\n Cancelled. No changes saved.\n")
63
+
64
+
65
+ def _clear_interactive_transition() -> None:
66
+ """Clear stale curses content before entering a follow-up setup screen."""
67
+ if not sys.stdout.isatty():
68
+ return
69
+ sys.stdout.write("\033[2J\033[H")
70
+ sys.stdout.flush()
71
+
72
+
73
+ def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
74
+ """Prompt for a value with optional default and secret masking."""
75
+ suffix = f" [{default}]" if default else ""
76
+ if secret:
77
+ val = masked_secret_prompt(f" {label}{suffix}: ")
78
+ else:
79
+ sys.stdout.write(f" {label}{suffix}: ")
80
+ sys.stdout.flush()
81
+ val = sys.stdin.readline().strip()
82
+ return val or (default or "")
83
+
84
+
85
+ def _install_dependencies(provider_name: str, *, force: bool = False) -> None:
86
+ """Install pip dependencies declared in ``plugin.yaml``.
87
+
88
+ With ``force`` every declared dependency goes to the installer even if it imports (the resolver
89
+ no-ops when nothing drifted) — how ``hermes update`` heals a provider after a venv rebuild.
90
+
91
+ When ``force`` is true, every declared dependency is handed to the installer even if its import
92
+ currently succeeds — the resolver then reinstalls anything missing or version-drifted and no-ops on
93
+ satisfied ranges. This is how ``hermes update`` heals the active memory provider after a venv
94
+ rebuild/sync removed or downgraded its bridge packages (#53272, #70636).
95
+ """
96
+ import subprocess
97
+ from plugins.memory import find_provider_dir
98
+
99
+ plugin_dir = find_provider_dir(provider_name)
100
+ if not plugin_dir:
101
+ return
102
+ yaml_path = plugin_dir / "plugin.yaml"
103
+ if not yaml_path.exists():
104
+ return
105
+ try:
106
+ import yaml
107
+ with open(yaml_path, encoding="utf-8") as f:
108
+ meta = yaml.safe_load(f) or {}
109
+ except Exception:
110
+ return
111
+
112
+ pip_deps = _provider_pip_dependencies(provider_name, meta.get("pip_dependencies", []))
113
+ if not pip_deps:
114
+ return
115
+
116
+ missing = []
117
+ for dep in pip_deps:
118
+ if force:
119
+ missing.append(dep)
120
+ continue
121
+ dep_name = re.match(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*", dep)
122
+ base = dep_name.group(0) if dep_name else dep
123
+ import_name = _IMPORT_NAMES.get(base, base.replace("-", "_").split("[")[0])
124
+ try:
125
+ __import__(import_name)
126
+ except ImportError:
127
+ missing.append(dep)
128
+ if not missing:
129
+ return
130
+
131
+ print(f"\n Installing dependencies: {', '.join(missing)}")
132
+
133
+ # install_specs routes to the durable target on sealed hosted images (HERMES_LAZY_INSTALL_TARGET)
134
+ # and is venv-scoped on normal installs.
135
+ from tools.lazy_deps import install_specs
136
+
137
+ manual_cmd = f"uv pip install {' '.join(missing)}"
138
+ try:
139
+ outcome = install_specs(missing, timeout=120)
140
+ if outcome.ok:
141
+ print(f" ✓ Installed {', '.join(missing)}")
142
+ elif outcome.blocked:
143
+ print(f" ⚠ Cannot install {', '.join(missing)}: {outcome.reason}")
144
+ else:
145
+ print(f" ⚠ Failed to install {', '.join(missing)}")
146
+ stderr = (outcome.stderr or "")[:200]
147
+ if stderr:
148
+ print(f" {stderr}")
149
+ print(f" Run manually: {manual_cmd}")
150
+ except Exception as e:
151
+ print(f" ⚠ Install failed: {e}")
152
+ print(f" Run manually: {manual_cmd}")
153
+
154
+ # Also show external (non-pip) dependencies that are missing.
155
+ for dep in meta.get("external_dependencies", []):
156
+ check_cmd = dep.get("check", "")
157
+ install_cmd = dep.get("install", "")
158
+ if check_cmd:
159
+ try:
160
+ subprocess.run(shlex.split(check_cmd), check=True, capture_output=True, timeout=5)
161
+ except Exception:
162
+ if install_cmd:
163
+ print(f"\n ⚠ '{dep.get('name', '')}' not found. Install with:")
164
+ print(f" {install_cmd}")
165
+
166
+
167
+ def _schema_of(provider) -> list:
168
+ return provider.get_config_schema() if hasattr(provider, "get_config_schema") else []
169
+
170
+
171
+ def _get_available_providers() -> list:
172
+ """Discover memory providers from plugins/memory/ as ``(name, setup_hint, provider)`` tuples."""
173
+ try:
174
+ from plugins.memory import discover_memory_providers, load_memory_provider
175
+ raw = discover_memory_providers()
176
+ except Exception:
177
+ raw = []
178
+
179
+ results = []
180
+ for name, desc, available in raw:
181
+ try:
182
+ provider = load_memory_provider(name)
183
+ if not provider:
184
+ continue
185
+ except Exception:
186
+ continue
187
+ schema = _schema_of(provider)
188
+ has_secrets = any(f.get("secret") for f in schema)
189
+ has_non_secrets = any(not f.get("secret") for f in schema)
190
+ if has_secrets and has_non_secrets:
191
+ setup_hint = "API key / local"
192
+ elif has_secrets:
193
+ setup_hint = "requires API key"
194
+ elif not schema:
195
+ setup_hint = "no setup needed"
196
+ else:
197
+ setup_hint = "local"
198
+ results.append((name, setup_hint, provider))
199
+ return results
200
+
201
+
202
+ def _find_provider(providers: list, provider_name: str):
203
+ return next((p for p in providers if p[0] == provider_name), None)
204
+
205
+
206
+ def _post_setup_hook(provider, config: dict) -> bool:
207
+ """Normalize the ``memory`` block; True when the provider's ``post_setup`` took over (it owns
208
+ config, connection test and activation), so the caller must stop."""
209
+ if not isinstance(config.get("memory"), dict):
210
+ config["memory"] = {}
211
+ if hasattr(provider, "post_setup"):
212
+ provider.post_setup(str(get_hermes_home()), config)
213
+ return True
214
+ return False
215
+
216
+
217
+ def cmd_setup_provider(provider_name: str) -> None:
218
+ """Run memory setup for a specific provider, skipping the picker."""
219
+ from hermes_cli.config import load_config, save_config
220
+
221
+ match = _find_provider(_get_available_providers(), provider_name)
222
+ if not match:
223
+ print(f"\n Memory provider '{provider_name}' not found.")
224
+ print(" Run 'hermes memory setup' to see available providers.\n")
225
+ return
226
+ name, _, provider = match
227
+
228
+ _clear_interactive_transition()
229
+ _install_dependencies(name)
230
+ config = load_config()
231
+ if _post_setup_hook(provider, config):
232
+ return
233
+ # Fallback: generic schema-based setup (same as cmd_setup)
234
+ config["memory"]["provider"] = name
235
+ save_config(config)
236
+ print(f"\n Memory provider: {name}")
237
+ print(" Activation saved to config.yaml\n")
238
+
239
+
240
+ def _prompt_schema_fields(name: str, schema: list, provider_config: dict, env_writes: dict) -> bool:
241
+ """Walk a provider's config schema, prompting per field. False when the user cancelled."""
242
+ print(f"\n Configuring {name}:\n")
243
+ for field in schema:
244
+ key = field["key"]
245
+ desc = field.get("description", key)
246
+ default = field.get("default")
247
+ # Dynamic default: look up default from another field's value
248
+ default_from = field.get("default_from")
249
+ if default_from and isinstance(default_from, dict):
250
+ ref_value = provider_config.get(default_from.get("field", ""), "")
251
+ ref_map = default_from.get("map", {})
252
+ if ref_value and ref_value in ref_map:
253
+ default = ref_map[ref_value]
254
+ is_secret = field.get("secret", False)
255
+ choices = field.get("choices")
256
+ env_var = field.get("env_var")
257
+ url = field.get("url")
258
+
259
+ when = field.get("when")
260
+ if when and isinstance(when, dict) and not all(provider_config.get(k) == v for k, v in when.items()):
261
+ continue
262
+
263
+ if choices and not is_secret:
264
+ current = provider_config.get(key, default)
265
+ current_idx = choices.index(current) if current and current in choices else 0
266
+ sel = _curses_select(
267
+ f" {desc}", [(c, "") for c in choices], default=current_idx, cancel_returns=_CANCELLED
268
+ )
269
+ if sel == _CANCELLED:
270
+ _print_cancelled_setup()
271
+ return False
272
+ provider_config[key] = choices[sel]
273
+ elif is_secret:
274
+ existing = os.environ.get(env_var, "") if env_var else ""
275
+ if existing:
276
+ masked = f"...{existing[-4:]}" if len(existing) > 4 else "set"
277
+ val = _prompt(f"{desc} (current: {masked}, blank to keep)", secret=True)
278
+ else:
279
+ if url:
280
+ print(f" Get yours at {url}")
281
+ val = _prompt(desc, secret=True)
282
+ if val and env_var:
283
+ env_writes[env_var] = val
284
+ else:
285
+ effective_default = provider_config.get(key) or default
286
+ val = _prompt(desc, default=str(effective_default) if effective_default else None)
287
+ if val:
288
+ provider_config[key] = val
289
+ if env_var and env_var not in env_writes:
290
+ env_writes[env_var] = val
291
+ return True
292
+
293
+
294
+ def cmd_setup(args) -> None:
295
+ """Interactive memory provider setup wizard."""
296
+ from hermes_cli.config import load_config, save_config
297
+
298
+ providers = _get_available_providers()
299
+ if not providers:
300
+ print("\n No memory provider plugins detected.")
301
+ print(" Install a plugin to ~/.hermes/plugins/ and try again.\n")
302
+ return
303
+
304
+ items = [(name, f"— {desc}") for name, desc, _ in providers]
305
+ items.append(("Built-in only", "— MEMORY.md / USER.md (default)"))
306
+ builtin_idx = len(items) - 1
307
+ selected = _curses_select("Memory provider setup", items, default=builtin_idx, cancel_returns=_CANCELLED)
308
+ if selected == _CANCELLED:
309
+ _print_cancelled_setup()
310
+ return
311
+
312
+ config = load_config()
313
+ if not isinstance(config.get("memory"), dict):
314
+ config["memory"] = {}
315
+ if selected >= len(providers):
316
+ config["memory"]["provider"] = ""
317
+ save_config(config)
318
+ print("\n ✓ Memory provider: built-in only")
319
+ print(" Saved to config.yaml\n")
320
+ return
321
+
322
+ name, _, provider = providers[selected]
323
+ _clear_interactive_transition()
324
+ _install_dependencies(name)
325
+ if _post_setup_hook(provider, config):
326
+ return
327
+
328
+ provider_config = config["memory"].get(name, {})
329
+ if not isinstance(provider_config, dict):
330
+ provider_config = {}
331
+ env_writes: dict = {}
332
+ schema = _schema_of(provider)
333
+ if schema and not _prompt_schema_fields(name, schema, provider_config, env_writes):
334
+ return
335
+
336
+ config["memory"]["provider"] = name
337
+ save_config(config)
338
+
339
+ if provider_config and hasattr(provider, "save_config"):
340
+ try:
341
+ provider.save_config(provider_config, str(get_hermes_home()))
342
+ except Exception as e:
343
+ print(f" Failed to write provider config: {e}")
344
+ if env_writes:
345
+ _write_env_vars(env_writes)
346
+
347
+ print(f"\n Memory provider: {name}")
348
+ print(" Activation saved to config.yaml")
349
+ if provider_config:
350
+ print(" Provider config saved")
351
+ if env_writes:
352
+ print(" API keys saved to .env")
353
+ print("\n Start a new session to activate.\n")
354
+
355
+
356
+ def _write_env_vars(
357
+ env_writes: dict, hermes_home: str | os.PathLike[str] | None = None) -> None:
358
+ """Persist memory-provider env vars through the canonical ``.env`` writer.
359
+
360
+ ``save_env_value`` applies the shared gate (name regex, ``LD_PRELOAD``/``PYTHONPATH``/``HERMES_HOME``
361
+ denylist, CR/LF stripping, atomic 0o600 writes). ``ValueError`` is reported and skipped so one
362
+ bad key doesn't sink the batch; filesystem errors propagate. ``hermes_home`` is applied via the
363
+ context-local override, not ``os.environ``.
364
+ """
365
+ from hermes_cli.config import save_env_value
366
+ from hermes_constants import reset_hermes_home_override, set_hermes_home_override
367
+
368
+ token = set_hermes_home_override(hermes_home) if hermes_home is not None else None
369
+ try:
370
+ for key, val in env_writes.items():
371
+ try:
372
+ save_env_value(key, val)
373
+ except ValueError as exc:
374
+ print(f" Skipping {key}: {exc}")
375
+ finally:
376
+ if token is not None:
377
+ reset_hermes_home_override(token)
378
+
379
+
380
+ def _mark(enabled) -> str:
381
+ return "enabled ✓" if enabled else "disabled ✗"
382
+
383
+
384
+ def cmd_status(args) -> None:
385
+ """Show current memory provider config."""
386
+ from hermes_cli.config import load_config
387
+
388
+ config = load_config()
389
+ mem_config = config.get("memory", {})
390
+ provider_name = mem_config.get("provider", "")
391
+
392
+ # Memory tool enablement for the CLI platform via the canonical resolver, respecting the
393
+ # check_fn gate when both stores are disabled.
394
+ from hermes_cli.tools_config import _get_platform_tools
395
+ from tools.memory_tool import check_memory_requirements
396
+ cli_tools = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
397
+ memory_tool_enabled = ("memory" in cli_tools) and check_memory_requirements()
398
+
399
+ print("\nMemory status\n" + "─" * 40)
400
+ print(" Built-in (MEMORY.md / USER.md):")
401
+ print(f" Memory injection: {_mark(mem_config.get('memory_enabled', True))}")
402
+ print(f" User profile: {_mark(mem_config.get('user_profile_enabled', True))}")
403
+ print(f" Memory tool: {_mark(memory_tool_enabled)}")
404
+ print(f" Provider: {provider_name or '(none — built-in only)'}")
405
+
406
+ providers = _get_available_providers()
407
+ match = _find_provider(providers, provider_name)
408
+ provider = match[2] if match else None
409
+
410
+ if provider_name:
411
+ provider_config = mem_config.get(provider_name, {})
412
+ display_config = provider_config
413
+ if provider and hasattr(provider, "get_status_config"):
414
+ try:
415
+ display_config = provider.get_status_config(provider_config)
416
+ except Exception as e:
417
+ display_config = dict(provider_config) if isinstance(provider_config, dict) else provider_config
418
+ if isinstance(display_config, dict):
419
+ display_config["status_config_error"] = str(e)
420
+ if display_config:
421
+ print(f"\n {provider_name} config:")
422
+ for key, val in display_config.items():
423
+ print(f" {key}: {val}")
424
+
425
+ if provider:
426
+ print("\n Plugin: installed ✓")
427
+ if provider.is_available():
428
+ print(" Status: available ✓")
429
+ else:
430
+ print(" Status: not available ✗")
431
+ # All fields with env_var (secret and non-secret)
432
+ required_fields = [f for f in _schema_of(provider) if f.get("env_var")]
433
+ if required_fields:
434
+ print(" Missing:")
435
+ for f in required_fields:
436
+ env_var = f.get("env_var", "")
437
+ url = f.get("url", "")
438
+ is_set = bool(os.environ.get(env_var))
439
+ line = f" {'✓' if is_set else '✗'} {env_var}"
440
+ if url and not is_set:
441
+ line += f" → {url}"
442
+ print(line)
443
+ print(" Note: systemd/gateway services do not inherit ~/.hermes/.env —")
444
+ print(" set any variables above in the service environment.")
445
+ else:
446
+ print("\n Plugin: NOT installed ✗")
447
+ print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/")
448
+
449
+ if providers:
450
+ print("\n Installed plugins:")
451
+ for pname, desc, _ in providers:
452
+ active = " ← active" if pname == provider_name else ""
453
+ print(f" • {pname} ({desc}){active}")
454
+ print()
455
+
456
+
457
+ def memory_command(args) -> None:
458
+ """Route memory subcommands."""
459
+ if getattr(args, "memory_command", None) == "setup":
460
+ provider = getattr(args, "provider", None)
461
+ if provider:
462
+ cmd_setup_provider(provider)
463
+ else:
464
+ cmd_setup(args)
465
+ else:
466
+ cmd_status(args)
hermes_cli/middleware.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hermes middleware contract helpers.
2
+
3
+ Observer hooks report what happened. Middleware can change what happens by rewriting a request or
4
+ wrapping the actual execution callback. Agent-loop call sites and plugins share this vocabulary.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from copy import deepcopy
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Callable, Dict, List
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ OBSERVER_SCHEMA_VERSION = "hermes.observer.v1"
17
+ MIDDLEWARE_SCHEMA_VERSION = "hermes.middleware.v1"
18
+
19
+ TOOL_REQUEST_MIDDLEWARE = "tool_request"
20
+ TOOL_EXECUTION_MIDDLEWARE = "tool_execution"
21
+ LLM_REQUEST_MIDDLEWARE = "llm_request"
22
+ LLM_EXECUTION_MIDDLEWARE = "llm_execution"
23
+
24
+ VALID_MIDDLEWARE: set[str] = {
25
+ TOOL_REQUEST_MIDDLEWARE, TOOL_EXECUTION_MIDDLEWARE, LLM_REQUEST_MIDDLEWARE, LLM_EXECUTION_MIDDLEWARE,
26
+ }
27
+
28
+
29
+ @dataclass
30
+ class RequestMiddlewareResult:
31
+ """Result of applying request middleware to a mutable payload."""
32
+
33
+ payload: Any
34
+ original_payload: Any
35
+ changed: bool = False
36
+ trace: List[Dict[str, Any]] = field(default_factory=list)
37
+
38
+
39
+ def observer_payload(**kwargs: Any) -> Dict[str, Any]:
40
+ kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION)
41
+ return kwargs
42
+
43
+
44
+ def middleware_payload(**kwargs: Any) -> Dict[str, Any]:
45
+ kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION)
46
+ kwargs.setdefault("middleware_schema_version", MIDDLEWARE_SCHEMA_VERSION)
47
+ return kwargs
48
+
49
+
50
+ def _safe_copy(payload: Any) -> Any:
51
+ """Deep-copy a request payload, tolerating non-deepcopyable members.
52
+
53
+ An LLM request can carry clients/callbacks/file handles; a hard ``deepcopy`` failure would
54
+ otherwise abort the whole request-middleware pass.
55
+ """
56
+ try:
57
+ return deepcopy(payload)
58
+ except Exception as exc: # pragma: no cover - exercised via fallback test
59
+ logger.debug("deepcopy failed for request payload (%s); using shallow copy", exc)
60
+ return dict(payload) if isinstance(payload, dict) else payload
61
+
62
+
63
+ def _apply_request_chain(
64
+ kind: str, payload_key: str, trace: List[Dict[str, Any]], original: Any, **kwargs: Any
65
+ ) -> RequestMiddlewareResult:
66
+ """Feed ``kwargs[payload_key]`` through every ``kind`` middleware; each may return ``{payload_key: {...}}``."""
67
+ from hermes_cli.plugins import invoke_middleware
68
+
69
+ current = kwargs[payload_key]
70
+ for result in invoke_middleware(kind, **middleware_payload(**kwargs)):
71
+ if not isinstance(result, dict):
72
+ continue
73
+ next_payload = result.get(payload_key)
74
+ if not isinstance(next_payload, dict):
75
+ continue
76
+ current = _safe_copy(next_payload)
77
+ entry = {
78
+ key: value
79
+ for key in ("source", "reason", "name")
80
+ if isinstance(value := result.get(key), str) and value
81
+ }
82
+ trace.append(entry or {"source": "plugin"})
83
+ return RequestMiddlewareResult(
84
+ payload=current, original_payload=original, changed=bool(trace), trace=trace,
85
+ )
86
+
87
+
88
+ def apply_llm_request_middleware(request: Dict[str, Any], **context: Any) -> RequestMiddlewareResult:
89
+ """Apply registered LLM request middleware; ``{"request": {...}}`` replaces the provider kwargs."""
90
+ from hermes_cli.plugins import has_middleware
91
+
92
+ if not has_middleware(LLM_REQUEST_MIDDLEWARE):
93
+ return RequestMiddlewareResult(payload=request, original_payload=request)
94
+
95
+ original_request = _safe_copy(request)
96
+ return _apply_request_chain(
97
+ LLM_REQUEST_MIDDLEWARE, "request", [], original_request,
98
+ request=_safe_copy(original_request), original_request=original_request, **context,
99
+ )
100
+
101
+
102
+ def apply_tool_request_middleware(
103
+ tool_name: str, args: Dict[str, Any], **context: Any
104
+ ) -> RequestMiddlewareResult:
105
+ """Apply registered tool request middleware; ``{"args": {...}}`` replaces the effective tool
106
+ arguments before hooks, guardrails, approvals, and execution see them."""
107
+ original_args = _safe_copy(args)
108
+ current_args = _safe_copy(original_args)
109
+ trace: List[Dict[str, Any]] = []
110
+
111
+ session_id = str(context.get("session_id") or "")
112
+ skip_relay = bool(context.pop("skip_relay", False))
113
+ if session_id and not skip_relay:
114
+ from agent import relay_runtime
115
+
116
+ relay_args = relay_runtime.apply_tool_request_intercepts(
117
+ session_id=session_id, tool_name=tool_name, args=current_args)
118
+ if relay_args != current_args:
119
+ current_args = _safe_copy(relay_args)
120
+ trace.append({"source": "nemo_relay"})
121
+
122
+ from hermes_cli.plugins import has_middleware
123
+
124
+ if not has_middleware(TOOL_REQUEST_MIDDLEWARE):
125
+ return RequestMiddlewareResult(
126
+ payload=args if not trace else current_args, original_payload=args,
127
+ changed=bool(trace), trace=trace,
128
+ )
129
+ return _apply_request_chain(
130
+ TOOL_REQUEST_MIDDLEWARE, "args", trace, original_args,
131
+ tool_name=tool_name, args=current_args, original_args=original_args, **context,
132
+ )
133
+
134
+
135
+ def run_llm_execution_middleware(
136
+ request: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any) -> Any:
137
+ """Run provider execution through registered LLM execution middleware."""
138
+ return _run_execution_chain(
139
+ LLM_EXECUTION_MIDDLEWARE, next_call,
140
+ request=request, original_request=context.pop("original_request", request), **context)
141
+
142
+
143
+ def run_tool_execution_middleware(
144
+ tool_name: str, args: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any,
145
+ ) -> Any:
146
+ """Run tool execution through registered tool execution middleware."""
147
+ return _run_execution_chain(
148
+ TOOL_EXECUTION_MIDDLEWARE, next_call,
149
+ tool_name=tool_name, args=args, original_args=context.pop("original_args", args), **context)
150
+
151
+
152
+ class _DownstreamExecutionError(Exception):
153
+ """Marks an exception raised BELOW a middleware frame so the frame's own failure handling
154
+ (skip-and-continue) doesn't swallow it."""
155
+
156
+ def __init__(self, original: BaseException) -> None:
157
+ super().__init__(str(original))
158
+ self.original = original
159
+
160
+
161
+ def _run_execution_chain(kind: str, terminal_call: Callable[[Any], Any], **kwargs: Any) -> Any:
162
+ from hermes_cli.plugins import get_plugin_manager
163
+
164
+ payload_key = "request" if "request" in kwargs else "args"
165
+ callbacks = list(get_plugin_manager()._middleware.get(kind, []))
166
+ if not callbacks:
167
+ return terminal_call(kwargs[payload_key])
168
+
169
+ def call_at(index: int, payload: Any) -> Any:
170
+ if index >= len(callbacks):
171
+ return terminal_call(payload)
172
+
173
+ callback = callbacks[index]
174
+ next_called = False
175
+ next_succeeded = False
176
+ next_result: Any = None
177
+
178
+ def next_call(next_payload: Any = None) -> Any:
179
+ nonlocal next_called, next_succeeded, next_result
180
+ # Single-use per frame: a second call would re-run the downstream provider/tool, so it
181
+ # is a contract violation, not a retry.
182
+ if next_called:
183
+ raise RuntimeError(
184
+ f"Middleware '{kind}' callback "
185
+ f"{getattr(callback, '__name__', repr(callback))} called "
186
+ "next_call() more than once; downstream execution is single-use"
187
+ )
188
+ next_called = True
189
+ try:
190
+ next_result = call_at(index + 1, payload if next_payload is None else next_payload)
191
+ next_succeeded = True
192
+ return next_result
193
+ except Exception as exc:
194
+ raise _DownstreamExecutionError(exc) from exc
195
+
196
+ call_kwargs = middleware_payload(**kwargs)
197
+ call_kwargs[payload_key] = payload
198
+ call_kwargs["next_call"] = next_call
199
+ try:
200
+ return callback(**call_kwargs)
201
+ except _DownstreamExecutionError as exc:
202
+ raise exc.original
203
+ except Exception as exc:
204
+ logger.warning(
205
+ "Middleware '%s' callback %s raised: %s",
206
+ kind, getattr(callback, "__name__", repr(callback)), exc)
207
+ if next_succeeded:
208
+ return next_result
209
+ if next_called:
210
+ raise
211
+ return call_at(index + 1, payload)
212
+
213
+ return call_at(0, kwargs[payload_key])
214
+
215
+
216
+ # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
217
+ # Names external plugins imported from this module before the Sep 2026 decomposition.
218
+ # Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
219
+ # The whole block is removed by reverting the commit that added it.
220
+
221
+ API_EXECUTION_MIDDLEWARE = LLM_EXECUTION_MIDDLEWARE
222
+
223
+ API_REQUEST_MIDDLEWARE = LLM_REQUEST_MIDDLEWARE
224
+
225
+ def apply_api_request_middleware(
226
+ request: Dict[str, Any],
227
+ **context: Any,
228
+ ) -> RequestMiddlewareResult:
229
+ """Compatibility wrapper for older ``api_request`` naming."""
230
+ return apply_llm_request_middleware(request, **context)
231
+
232
+ def run_api_execution_middleware(
233
+ request: Dict[str, Any],
234
+ next_call: Callable[[Dict[str, Any]], Any],
235
+ **context: Any,
236
+ ) -> Any:
237
+ """Compatibility wrapper for older ``api_execution`` naming."""
238
+ return run_llm_execution_middleware(request, next_call, **context)
239
+ # ---- END PLUGIN-COMPAT ----
hermes_cli/models_pricing.py ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Live model pricing.
2
+
3
+ OpenRouter-compatible ``/v1/models`` pricing fetch with a per-endpoint/per-credential cache,
4
+ Nous Portal sale chrome and org-policy filtering, and the Vercel AI Gateway / Novita / Fireworks /
5
+ DeepInfra pricing adapters. Split out of ``hermes_cli.models``; helpers still defined there are
6
+ looked up on ``hermes_cli.models`` at call time so ``patch("hermes_cli.models.<name>")`` mocks keep
7
+ intercepting.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import time
15
+ import urllib.request
16
+ from typing import Any, Optional
17
+ from hermes_cli.models_reasoning_caps import _seed_reasoning_caps
18
+
19
+
20
+ # Cache: maps model_id → {"prompt": str, "completion": str} per endpoint
21
+ _pricing_cache: dict[str, dict[str, dict[str, str]]] = {}
22
+ # (profile key, provider) → endpoint cache key last fetched, so cached_only reads find the right entry.
23
+ _pricing_provider_cache_keys: dict[tuple[str, str], str] = {}
24
+
25
+ # A failed fetch caches its empty result too, so an unreachable endpoint isn't re-dialed on every
26
+ # call — but only until this deadline. Cached forever, one blip at startup would mean no live model
27
+ # discovery for the life of a process that runs for weeks (gateway, desktop backend), silently:
28
+ # every caller falls back to a curated list meanwhile.
29
+ _FAILED_CATALOG_TTL_SECONDS = 120.0
30
+
31
+ _pricing_cache_retry_after: dict[str, float] = {}
32
+
33
+
34
+ def _cached_catalog(cache_key: str) -> Optional[dict[str, dict[str, Any]]]:
35
+ """The cached catalog for *cache_key*, or None to go fetch it."""
36
+ cached = _pricing_cache.get(cache_key)
37
+ if cached is None:
38
+ return None
39
+ retry_after = _pricing_cache_retry_after.get(cache_key)
40
+ if retry_after is not None and time.monotonic() >= retry_after:
41
+ _pricing_cache.pop(cache_key, None)
42
+ _pricing_cache_retry_after.pop(cache_key, None)
43
+ return None
44
+ return cached
45
+
46
+
47
+ def _cache_catalog(
48
+ cache_key: str,
49
+ result: dict[str, dict[str, Any]],
50
+ ttl_seconds: Optional[float] = None,
51
+ ) -> dict[str, dict[str, Any]]:
52
+ """Cache a catalog result, giving an empty one an expiry. *ttl_seconds* expires a non-empty
53
+ result too — only for a catalog whose contents depend on server-side state the client cannot
54
+ observe (an org's model policy can change while a long-lived process holds the entry)."""
55
+ _pricing_cache[cache_key] = result
56
+ if not result:
57
+ _pricing_cache_retry_after[cache_key] = time.monotonic() + _FAILED_CATALOG_TTL_SECONDS
58
+ elif ttl_seconds:
59
+ _pricing_cache_retry_after[cache_key] = time.monotonic() + ttl_seconds
60
+ else:
61
+ _pricing_cache_retry_after.pop(cache_key, None)
62
+ return result
63
+
64
+
65
+ # NUL cannot appear in a URL, so this cannot collide with a real base URL.
66
+ _PRICING_AUTH_KEY_PREFIX = "\x00auth:"
67
+
68
+
69
+ def _pricing_auth_fingerprint(api_key: str | None) -> str:
70
+ """Cache-key suffix identifying the credential a catalog was read with: a governed endpoint
71
+ answers each token with the catalog its org may reach, so two credentials cannot share an
72
+ entry. blake2b for fingerprinting only (same rationale as ``_custom_endpoint_fingerprint``)."""
73
+ if not api_key:
74
+ return ""
75
+ import hashlib
76
+
77
+ digest = hashlib.blake2b(api_key.encode("utf-8", errors="replace"), digest_size=8)
78
+ return _PRICING_AUTH_KEY_PREFIX + digest.hexdigest()
79
+
80
+
81
+ def peek_cached_pricing(base_url: str) -> dict[str, dict[str, Any]]:
82
+ """Pricing already cached for *base_url* (with or without ``/v1``), or ``{}``; never fetches.
83
+ Prefers an authenticated catalog, scanning newest first (callers hold no credential) and
84
+ skipping expired entries so a rotated credential does not answer from its predecessor's."""
85
+ root = _strip_v1((base_url or "").rstrip("/"))
86
+ authed_prefix = root + _PRICING_AUTH_KEY_PREFIX
87
+ for key in reversed(list(_pricing_cache)):
88
+ if key.startswith(authed_prefix):
89
+ cached = _cached_catalog(key)
90
+ if cached:
91
+ return cached
92
+ return _cached_catalog(root) or {}
93
+
94
+
95
+ def _strip_v1(url: str) -> str:
96
+ return url[:-3].rstrip("/") if url.endswith("/v1") else url
97
+
98
+
99
+ def _format_price_per_mtok(per_token_str: str) -> str:
100
+ """Per-token price string → $/Mtok string. Always 2 decimals so right-justified prices align;
101
+ sub-cent prices (deep-discount cache-hit promos) widen precision until the value shows, keep
102
+ one extra digit and trim trailing zeros instead of collapsing to "$0.00"."""
103
+ try:
104
+ val = float(per_token_str)
105
+ except (TypeError, ValueError):
106
+ return "?"
107
+ if val == 0:
108
+ return "free"
109
+ per_m = val * 1_000_000
110
+ text = f"{per_m:.2f}"
111
+ if per_m < 0.01:
112
+ prec = 3
113
+ while prec < 12 and round(per_m, prec) == 0:
114
+ prec += 1
115
+ text = f"{per_m:.{min(prec + 1, 12)}f}".rstrip("0").rstrip(".")
116
+ return f"${text}"
117
+
118
+
119
+ def _price_float(raw: Any, *, positive: bool) -> float | None:
120
+ """*raw* as a finite float (> 0, or >= 0 when not *positive*); None when unset/invalid/NaN."""
121
+ if raw in (None, ""):
122
+ return None
123
+ try:
124
+ n = float(raw)
125
+ except (TypeError, ValueError):
126
+ return None
127
+ if n != n or (n <= 0 if positive else n < 0):
128
+ return None
129
+ return n
130
+
131
+
132
+ def _sale_pct(current: Any, original: Any) -> int | None:
133
+ """Percent discount when *current* is strictly below *original* (both positive finite)."""
134
+ cur, orig = _price_float(current, positive=True), _price_float(original, positive=True)
135
+ if cur is None or orig is None or cur >= orig:
136
+ return None
137
+ return int(round((1.0 - (cur / orig)) * 100))
138
+
139
+
140
+ def compute_sale_discount(prompt: str, completion: str, original: Any) -> tuple[int, str, str] | None:
141
+ """Sale chrome from gateway ``pricing.original`` (Nous Portal only; callers gate on the provider
142
+ and opted in via ``include_sale_original=True``): ``(discount_percent, was_prompt_raw,
143
+ was_completion_raw)`` when ``original`` is a dict and the current prompt (fallback: completion)
144
+ rate is strictly below the original. Free / $0 models get a flat 100% off, with "was" prices
145
+ only when the gateway served an original (a natively-free stealth model gets bare "-100%")."""
146
+ orig_dict = original if isinstance(original, dict) else {}
147
+ was_prompt = orig_dict.get("prompt")
148
+ was_completion = orig_dict.get("completion")
149
+ was_prompt_str = str(was_prompt) if was_prompt not in (None, "") else ""
150
+ was_completion_str = str(was_completion) if was_completion not in (None, "") else ""
151
+
152
+ if _price_float(prompt, positive=False) == 0 and _price_float(completion, positive=False) in (0, None):
153
+ return (100, was_prompt_str, was_completion_str)
154
+
155
+ if not isinstance(original, dict) or (not was_prompt_str and not was_completion_str):
156
+ return None
157
+
158
+ pct = _sale_pct(prompt, was_prompt)
159
+ if pct is not None:
160
+ return (pct, was_prompt_str, was_completion_str) if pct >= 1 else None
161
+ pct = _sale_pct(completion, was_completion)
162
+ if pct is not None:
163
+ return (pct, was_prompt_str, was_completion_str) if pct >= 1 else None
164
+ return None
165
+
166
+
167
+ def _get_json(url: str, headers: dict[str, str], timeout: float, opener=None) -> Optional[dict]:
168
+ """GET *url* as JSON via the origin's catalog opener (or *opener*); None on any failure."""
169
+ from hermes_cli.models import _urlopen_model_catalog_request
170
+
171
+ try:
172
+ req = urllib.request.Request(url, headers=headers)
173
+ with (opener or _urlopen_model_catalog_request)(req, timeout=timeout) as resp:
174
+ return json.loads(resp.read().decode())
175
+ except Exception:
176
+ return None
177
+
178
+
179
+ def _pricing_entry(pricing: dict, prompt_key: str = "prompt", completion_key: str = "completion") -> dict[str, Any]:
180
+ """Picker-shape ``{prompt, completion[, input_cache_read, input_cache_write]}`` from a catalog
181
+ ``pricing`` block whose cache fields already use the hermes names."""
182
+ entry: dict[str, Any] = {
183
+ "prompt": str(pricing.get(prompt_key, "")),
184
+ "completion": str(pricing.get(completion_key, "")),
185
+ }
186
+ for key in ("input_cache_read", "input_cache_write"):
187
+ if pricing.get(key):
188
+ entry[key] = str(pricing[key])
189
+ return entry
190
+
191
+
192
+ def _per_token(per_mtok: Any) -> str:
193
+ """$/MTok → the per-token price string the picker expects."""
194
+ return str(float(per_mtok) / 1_000_000)
195
+
196
+
197
+ def _catalog_items(payload: dict) -> list[dict]:
198
+ return [item for item in payload.get("data", []) if isinstance(item, dict)]
199
+
200
+
201
+ def fetch_models_with_pricing(
202
+ api_key: str | None = None,
203
+ base_url: str = "https://openrouter.ai/api",
204
+ timeout: float = 8.0,
205
+ *,
206
+ force_refresh: bool = False,
207
+ include_sale_original: bool = False,
208
+ cache_ttl_seconds: Optional[float] = None,
209
+ ) -> dict[str, dict[str, Any]]:
210
+ """Fetch ``/v1/models`` (any OpenRouter-compatible endpoint) → ``{model_id: {prompt, completion,
211
+ ...}}``, cached per *base_url* and per credential so one caller's catalog never answers
212
+ another's read. *include_sale_original* (Nous Portal only) copies the gateway's pre-discount
213
+ ``pricing.original`` rates through as a nested ``original`` dict for sale chrome."""
214
+ from hermes_cli.models import _HERMES_USER_AGENT
215
+ url_root = (base_url or "").rstrip("/")
216
+ cache_key = url_root + _pricing_auth_fingerprint(api_key)
217
+ if not force_refresh:
218
+ cached = _cached_catalog(cache_key)
219
+ if cached is not None:
220
+ return cached
221
+
222
+ url = url_root + "/v1/models"
223
+ headers = {"Accept": "application/json", "User-Agent": _HERMES_USER_AGENT}
224
+ if api_key:
225
+ headers["Authorization"] = f"Bearer {api_key}"
226
+ payload = _get_json(url, headers, timeout)
227
+ if payload is None:
228
+ return _cache_catalog(cache_key, {})
229
+
230
+ # Same document the reasoning-capability fetch would pull — mirror it so a later hot-path
231
+ # lookup (and the next process) has an answer without its own round-trip.
232
+ _seed_reasoning_caps(url, payload.get("data"))
233
+
234
+ result: dict[str, dict[str, Any]] = {}
235
+ for item in payload.get("data", []):
236
+ mid, pricing = item.get("id"), item.get("pricing")
237
+ if mid and isinstance(pricing, dict):
238
+ entry = _pricing_entry(pricing)
239
+ # Sale chrome is Nous Portal-only; never copy pricing.original for other catalogs.
240
+ original = pricing.get("original") if include_sale_original else None
241
+ if isinstance(original, dict):
242
+ orig_entry = {key: str(original[key]) for key in ("prompt", "completion", "input_cache_read", "input_cache_write")
243
+ if original.get(key) not in (None, "")}
244
+ if orig_entry.get("prompt") or orig_entry.get("completion"):
245
+ entry["original"] = orig_entry
246
+ result[mid] = entry
247
+
248
+ return _cache_catalog(cache_key, result, cache_ttl_seconds)
249
+
250
+
251
+ def fetch_ai_gateway_pricing(timeout: float = 8.0, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
252
+ """Vercel AI Gateway /v1/models pricing, translating its ``input`` / ``output`` field names to
253
+ the picker's ``prompt`` / ``completion`` (cache read/write names already match)."""
254
+ from hermes_constants import AI_GATEWAY_BASE_URL
255
+
256
+ cache_key = AI_GATEWAY_BASE_URL.rstrip("/")
257
+ if not force_refresh:
258
+ cached = _cached_catalog(cache_key)
259
+ if cached is not None:
260
+ return cached
261
+
262
+ payload = _get_json(f"{cache_key}/models", {"Accept": "application/json"}, timeout, opener=urllib.request.urlopen)
263
+ if payload is None:
264
+ return _cache_catalog(cache_key, {})
265
+
266
+ result: dict[str, dict[str, str]] = {}
267
+ for item in _catalog_items(payload):
268
+ mid, pricing = item.get("id"), item.get("pricing")
269
+ if mid and isinstance(pricing, dict):
270
+ result[mid] = _pricing_entry(pricing, "input", "output")
271
+ return _cache_catalog(cache_key, result)
272
+
273
+
274
+ def _resolve_openrouter_api_key() -> str:
275
+ """Best-effort OpenRouter API key for pricing fetch."""
276
+ return os.getenv("OPENROUTER_API_KEY", "").strip()
277
+
278
+
279
+ _DEFAULT_NOUS_INFERENCE_BASE = "https://inference-api.nousresearch.com"
280
+
281
+
282
+ def _resolve_nous_pricing_credentials() -> tuple[str, str]:
283
+ """``(api_key, base_url)`` for Nous Portal pricing; base_url is the bare origin (no ``/v1``).
284
+ Precedence mirrors runtime credential resolution: ``NOUS_INFERENCE_BASE_URL`` (staging /
285
+ preview) → resolved credential ``base_url`` → production default. Without the override a
286
+ staging profile's sale ``pricing.original`` would never reach the pickers."""
287
+ try:
288
+ from hermes_cli.auth import _nous_inference_env_override
289
+
290
+ env_base = _nous_inference_env_override()
291
+ except Exception:
292
+ env_base = None
293
+ api_key = creds_base = ""
294
+ try:
295
+ from hermes_cli.auth import resolve_nous_runtime_credentials
296
+
297
+ creds = resolve_nous_runtime_credentials()
298
+ if creds:
299
+ api_key = creds.get("api_key", "") or ""
300
+ creds_base = (creds.get("base_url", "") or "").strip()
301
+ except Exception:
302
+ pass
303
+ base_url = (env_base or creds_base or _DEFAULT_NOUS_INFERENCE_BASE).rstrip("/")
304
+ if base_url.endswith("/v1"):
305
+ base_url = base_url[:-3]
306
+ return (api_key, base_url)
307
+
308
+
309
+ # How long a Nous catalog stays trusted. Its contents depend on the org's policy, which an admin
310
+ # can change at any time and the client cannot observe, so a long-lived process must re-ask.
311
+ # Other providers' catalogs carry no such state and keep the default no-expiry caching.
312
+ _NOUS_CATALOG_TTL_SECONDS = 300.0
313
+
314
+
315
+ def _fetch_nous_pricing(api_key: str, base_url: str, *, force_refresh: bool) -> dict[str, dict[str, Any]]:
316
+ """Shared by pricing and policy lookups so both read one cache entry."""
317
+ return fetch_models_with_pricing(
318
+ api_key=api_key,
319
+ base_url=base_url,
320
+ force_refresh=force_refresh,
321
+ include_sale_original=True, # Sale chrome (pricing.original) is Nous Portal-only.
322
+ cache_ttl_seconds=_NOUS_CATALOG_TTL_SECONDS,
323
+ )
324
+
325
+
326
+ def nous_policy_allowed_ids(*, force_refresh: bool = False) -> Optional[set[str]]:
327
+ """The Nous model ids the caller's org may reach (keys of an authenticated ``GET /v1/models``,
328
+ which omits policy-blocked rows), or ``None`` to not filter: no policy (or a token too old to
329
+ say), an anonymous read (unfiltered catalog), or an empty read (a fetch failure, not an org
330
+ that may reach nothing)."""
331
+ try:
332
+ from hermes_cli.nous_account import nous_policy_present
333
+
334
+ if nous_policy_present() is not True:
335
+ return None
336
+ except Exception:
337
+ return None
338
+
339
+ api_key, base_url = _resolve_nous_pricing_credentials()
340
+ if not api_key or not base_url:
341
+ return None
342
+ return set(_fetch_nous_pricing(api_key, base_url, force_refresh=force_refresh)) or None
343
+
344
+
345
+ # Past this size an allowed set reads as a whole catalog rather than an allowlist, and is not
346
+ # worth showing in place of an empty picker.
347
+ _NOUS_POLICY_APPEND_MAX = 64
348
+
349
+
350
+ def restrict_to_nous_policy(
351
+ model_ids: list[str],
352
+ allowed: Optional[set[str]],
353
+ *,
354
+ rescue_empty: bool = False,
355
+ ) -> list[str]:
356
+ """*model_ids* narrowed to *allowed*, preserving order. A ``:free`` sibling is kept when its
357
+ base model is reachable (the gateway admits a row when any requestable id passes); over-listing
358
+ costs a 403 from the authoritative gate, hiding a servable row is unrecoverable client-side.
359
+ *rescue_empty*: an allowlist naming only models the curated manifest lacks would leave an empty
360
+ picker — worse than no filter — so return the allowlist itself. Opt-in per list: an already-
361
+ empty list (a paid tier's gated models) means "nothing to gate", not "nothing survived"."""
362
+ if not allowed:
363
+ return list(model_ids)
364
+ kept = [mid for mid in model_ids if mid in allowed or mid.split(":", 1)[0] in allowed]
365
+ if rescue_empty and not kept and len(allowed) <= _NOUS_POLICY_APPEND_MAX:
366
+ return sorted(allowed)
367
+ return kept
368
+
369
+
370
+ def _remember_provider_cache_key(provider: str, cache_key: str) -> None:
371
+ from hermes_cli.models import _pricing_profile_key
372
+ _pricing_provider_cache_keys[(_pricing_profile_key(), provider)] = cache_key
373
+
374
+
375
+ def _fetch_openrouter_pricing(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]:
376
+ _remember_provider_cache_key("openrouter", _OPENROUTER_PRICING_BASE)
377
+ return fetch_models_with_pricing(
378
+ api_key=_resolve_openrouter_api_key(),
379
+ base_url=_OPENROUTER_PRICING_BASE,
380
+ force_refresh=force_refresh,
381
+ )
382
+
383
+
384
+ def _fetch_ai_gateway_pricing_for_provider(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]:
385
+ _remember_provider_cache_key("ai-gateway", _ai_gateway_pricing_scope())
386
+ return fetch_ai_gateway_pricing(force_refresh=force_refresh)
387
+
388
+
389
+ def _fetch_novita_pricing_for_provider(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]:
390
+ _remember_provider_cache_key("novita", _novita_pricing_scope())
391
+ return _fetch_novita_pricing(force_refresh=force_refresh)
392
+
393
+
394
+ def _fetch_fireworks_pricing_for_provider(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]:
395
+ _remember_provider_cache_key("fireworks", _FIREWORKS_PRICING_KEY)
396
+ return _fireworks_pricing_from_models_dev(force_refresh=force_refresh)
397
+
398
+
399
+ def _fetch_nous_pricing_for_provider(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]:
400
+ api_key, base_url = _resolve_nous_pricing_credentials()
401
+ if not base_url:
402
+ return {}
403
+ _remember_provider_cache_key("nous", base_url.rstrip("/"))
404
+ return _fetch_nous_pricing(api_key, base_url, force_refresh=force_refresh)
405
+
406
+
407
+ _OPENROUTER_PRICING_BASE = "https://openrouter.ai/api"
408
+ _FIREWORKS_PRICING_KEY = "models.dev/fireworks"
409
+
410
+
411
+ def _ai_gateway_pricing_scope() -> str:
412
+ from hermes_constants import AI_GATEWAY_BASE_URL
413
+ return AI_GATEWAY_BASE_URL.rstrip("/")
414
+
415
+
416
+ def _novita_pricing_scope() -> str:
417
+ return (os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1").rstrip("/")
418
+
419
+
420
+ def get_cached_nous_inference_base_url() -> str:
421
+ """The profile's persisted Nous endpoint (bare origin, no ``/v1``) without refreshing auth."""
422
+ try:
423
+ from hermes_cli.auth import (
424
+ _load_auth_store, _load_provider_state, _optional_base_url, _validate_nous_inference_url_from_network,
425
+ )
426
+
427
+ state = _load_provider_state(_load_auth_store(), "nous") or {}
428
+ url = _validate_nous_inference_url_from_network(_optional_base_url(state.get("inference_base_url"))) or ""
429
+ return url.rstrip("/").removesuffix("/v1")
430
+ except Exception:
431
+ return ""
432
+
433
+
434
+ # Static endpoint identity per provider; dynamic ones (deepinfra, nous) are resolved in pricing_cache_scope.
435
+ _STATIC_PRICING_SCOPES = {
436
+ "openrouter": lambda: _OPENROUTER_PRICING_BASE,
437
+ "ai-gateway": _ai_gateway_pricing_scope,
438
+ "novita": _novita_pricing_scope,
439
+ "fireworks": lambda: _FIREWORKS_PRICING_KEY,
440
+ }
441
+
442
+
443
+ def pricing_cache_scope(provider: str, *, current_provider: str = "", current_base_url: str = "") -> str:
444
+ """The current endpoint identity a provider's pricing cache is keyed on. Resolves local configuration
445
+ only, never fetches: picker prewarm single-flight uses it so an endpoint rotation can start a new
446
+ worker while the previous endpoint is still slow or unreachable."""
447
+ from hermes_cli.models import _deepinfra_catalog_url, _pricing_profile_key, normalize_provider
448
+ normalized = normalize_provider(provider)
449
+ static = _STATIC_PRICING_SCOPES.get(normalized)
450
+ if static:
451
+ return static()
452
+ if normalized == "deepinfra":
453
+ return _deepinfra_catalog_url()[0]
454
+ if normalized == "nous":
455
+ try:
456
+ from hermes_cli.auth import _nous_inference_env_override
457
+
458
+ env_base = _nous_inference_env_override()
459
+ except Exception:
460
+ env_base = None
461
+ if env_base:
462
+ return env_base.rstrip("/").removesuffix("/v1")
463
+ if normalize_provider(current_provider) == "nous" and current_base_url:
464
+ return current_base_url.rstrip("/").removesuffix("/v1")
465
+ persisted_base = get_cached_nous_inference_base_url()
466
+ if persisted_base:
467
+ return persisted_base
468
+ return _pricing_provider_cache_keys.get((_pricing_profile_key(), normalized), _DEFAULT_NOUS_INFERENCE_BASE)
469
+ return ""
470
+
471
+
472
+ def _cached_only_pricing(normalized: str) -> dict[str, dict[str, str]]:
473
+ """Process-resident pricing for *normalized* without any provider I/O."""
474
+ from hermes_cli.models import _deepinfra_catalog_cache, _deepinfra_catalog_url, _pricing_profile_key
475
+ if normalized == "deepinfra":
476
+ cache_key, _url = _deepinfra_catalog_url()
477
+ return _fetch_deepinfra_pricing() if cache_key in _deepinfra_catalog_cache else {}
478
+ cache_key = _pricing_provider_cache_keys.get((_pricing_profile_key(), normalized))
479
+ if cache_key is None and normalized in ("openrouter", "ai-gateway", "fireworks"):
480
+ cache_key = _STATIC_PRICING_SCOPES[normalized]()
481
+ return (_cached_catalog(cache_key) or {}) if cache_key else {}
482
+
483
+
484
+ def get_pricing_for_provider(
485
+ provider: str, *, force_refresh: bool = False, cached_only: bool = False
486
+ ) -> dict[str, dict[str, str]]:
487
+ """Return live pricing for providers that support it (openrouter, nous, ai-gateway, novita,
488
+ deepinfra, fireworks); ``{}`` for everything else. ``cached_only`` never starts provider I/O:
489
+ normal picker opens use it so cold endpoints cannot hold the response path, while a background
490
+ prewarm fills the same caches for later opens."""
491
+ from hermes_cli.models import normalize_provider
492
+ normalized = normalize_provider(provider)
493
+ if cached_only:
494
+ return _cached_only_pricing(normalized)
495
+ fetcher = _PRICING_FETCHERS.get(normalized)
496
+ return fetcher(force_refresh=force_refresh) if fetcher else {}
497
+
498
+
499
+ def _fireworks_pricing_from_models_dev(*, force_refresh: bool = False) -> dict[str, dict[str, str]]:
500
+ """Fireworks picker pricing from the models.dev registry cache (``fetch_models_dev()`` keeps a
501
+ shared in-memory + disk cache, 1h TTL) — a pure dict transform, no per-render network call."""
502
+ cache_key = "models.dev/fireworks"
503
+ if not force_refresh:
504
+ cached = _cached_catalog(cache_key)
505
+ if cached is not None:
506
+ return cached
507
+
508
+ result: dict[str, dict[str, str]] = {}
509
+ try:
510
+ from agent.models_dev import _get_provider_models
511
+
512
+ for mid, entry in (_get_provider_models("fireworks") or {}).items():
513
+ cost = entry.get("cost") if isinstance(entry, dict) else None
514
+ if not isinstance(cost, dict):
515
+ continue
516
+ inp, out = cost.get("input"), cost.get("output")
517
+ if inp is None and out is None:
518
+ continue
519
+ row = {"prompt": _per_token(inp or 0), "completion": _per_token(out or 0)}
520
+ if cost.get("cache_read"):
521
+ row["input_cache_read"] = _per_token(cost["cache_read"])
522
+ result[str(mid)] = row
523
+ except Exception:
524
+ result = {}
525
+
526
+ return _cache_catalog(cache_key, result)
527
+
528
+
529
+ def _fetch_novita_pricing(timeout: float = 8.0, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
530
+ """NovitaAI /v1/models pricing (per-million prices in units of 0.0001 USD → per-token strings),
531
+ cached on the resolved base URL so menu renders don't re-hit the network."""
532
+ from hermes_cli.models import _HERMES_USER_AGENT
533
+ api_key = os.getenv("NOVITA_API_KEY", "").strip()
534
+ if not api_key:
535
+ return {}
536
+
537
+ cache_key = (os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1").rstrip("/")
538
+ if not force_refresh:
539
+ cached = _cached_catalog(cache_key)
540
+ if cached is not None:
541
+ return cached
542
+
543
+ headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json", "User-Agent": _HERMES_USER_AGENT}
544
+ payload = _get_json(cache_key + "/models", headers, timeout)
545
+ if payload is None:
546
+ return _cache_catalog(cache_key, {})
547
+
548
+ result: dict[str, dict[str, str]] = {}
549
+ for item in _catalog_items(payload):
550
+ mid = item.get("id")
551
+ inp, out = item.get("input_token_price_per_m"), item.get("output_token_price_per_m")
552
+ if not mid or (inp is None and out is None):
553
+ continue
554
+ result[str(mid)] = {
555
+ "prompt": str(float(inp or 0) / 10_000 / 1_000_000),
556
+ "completion": str(float(out or 0) / 10_000 / 1_000_000),
557
+ }
558
+
559
+ return _cache_catalog(cache_key, result)
560
+
561
+
562
+ def _fetch_deepinfra_pricing(timeout: float = 5.0, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
563
+ """DeepInfra chat-model pricing: ``input_tokens`` / ``output_tokens`` / ``cache_read_tokens`` in
564
+ $/MTok → per-token ``prompt`` / ``completion`` / ``input_cache_read`` (cached by the by-tag
565
+ helper)."""
566
+ from hermes_cli.models import _fetch_deepinfra_models_by_tag
567
+ items = _fetch_deepinfra_models_by_tag("chat", timeout=timeout, force_refresh=force_refresh)
568
+ result: dict[str, dict[str, str]] = {}
569
+ for item in items or []:
570
+ metadata = item.get("metadata") or {}
571
+ pricing = metadata.get("pricing") if isinstance(metadata, dict) else None
572
+ if not isinstance(pricing, dict):
573
+ continue
574
+ entry = {
575
+ ours: _per_token(pricing[theirs])
576
+ for theirs, ours in (("input_tokens", "prompt"), ("output_tokens", "completion"), ("cache_read_tokens", "input_cache_read"))
577
+ if pricing.get(theirs) is not None
578
+ }
579
+ if entry:
580
+ result[item["id"]] = entry
581
+ return result
582
+
583
+
584
+ _PRICING_FETCHERS = {
585
+ "openrouter": _fetch_openrouter_pricing,
586
+ "ai-gateway": _fetch_ai_gateway_pricing_for_provider,
587
+ "novita": _fetch_novita_pricing_for_provider,
588
+ "deepinfra": _fetch_deepinfra_pricing,
589
+ "fireworks": _fetch_fireworks_pricing_for_provider,
590
+ "nous": _fetch_nous_pricing_for_provider,
591
+ }
hermes_cli/provider_catalog.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unified provider catalog — one source of truth for the provider universe.
2
+
3
+ The provider list shown by ``hermes model`` (CLI/TUI) and the desktop Settings → Providers tabs
4
+ (Accounts + API keys) **must be the same set**; providers added after those lists were written
5
+ silently went missing from the GUI. ``auth_type`` / ``api_key_env_vars`` / ``base_url_env_var``
6
+ come from :data:`hermes_cli.auth.PROVIDER_REGISTRY` (credential truth); ``display_name`` /
7
+ ``description`` / ``signup_url`` from the provider's :class:`providers.base.ProviderProfile`, falling
8
+ back to the ``CANONICAL_PROVIDERS`` entry's ``label`` / ``tui_desc`` and the ``OPTIONAL_ENV_VARS``
9
+ signup URL (many profiles leave these blank, and lmstudio, openai-api, tencent-tokenhub, xai-oauth
10
+ have no profile at all — the fallbacks are load-bearing).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ # Auth types that authenticate via an account / sign-in flow rather than a pasted API key; these
18
+ # route to the desktop "Accounts" tab, everything else (api_key, and aws_sdk configured via
19
+ # AWS_REGION/AWS_PROFILE) to "API keys". Mirrors the auth_type strings in PROVIDER_REGISTRY and
20
+ # ProviderProfile: external_process = copilot-acp (spawns `copilot --acp --stdio`), copilot = GitHub
21
+ # Copilot token / gh auth.
22
+ _ACCOUNTS_AUTH_TYPES: frozenset[str] = frozenset(
23
+ {"oauth_device_code", "oauth_external", "oauth_minimax", "external_process", "copilot"}
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ProviderDescriptor:
29
+ """One provider, as seen by every surface (CLI picker + both GUI tabs)."""
30
+
31
+ slug: str # canonical id, e.g. "openai-codex"
32
+ label: str # human display name
33
+ description: str # one-line description
34
+ auth_type: str # api_key | oauth_* | external_process | copilot | aws_sdk
35
+ tab: str # "keys" | "accounts"
36
+ api_key_env_vars: tuple[str, ...] # credential env vars (may be empty)
37
+ base_url_env_var: str # base-URL override env var (may be "")
38
+ signup_url: str # signup / console URL (may be "")
39
+ order: int # CANONICAL_PROVIDERS index — mirrors `hermes model`
40
+ keyless: bool = False # served anonymously — no credential exists to configure
41
+
42
+
43
+ def tab_for_auth_type(auth_type: str) -> str:
44
+ """Return the desktop tab ("keys"|"accounts") a provider's auth maps to."""
45
+ return "accounts" if auth_type in _ACCOUNTS_AUTH_TYPES else "keys"
46
+
47
+
48
+ def _is_url_var(name: str) -> bool:
49
+ return name.endswith("_BASE_URL") or name.endswith("_URL")
50
+
51
+
52
+ def _split_env_vars(env_vars: tuple[str, ...]) -> tuple[tuple[str, ...], str]:
53
+ """Split a profile's ``env_vars`` into (api_key_vars, base_url_var)."""
54
+ 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)), "")
55
+
56
+
57
+ def _safe_import(module: str, attr: str, default):
58
+ """Import ``attr`` from ``module``; ``default`` on ANY failure — this module is on the import
59
+ path of the web server and the CLI, and a provider-plugin import error must never blank the
60
+ whole catalog."""
61
+ try:
62
+ return getattr(__import__(module, fromlist=[attr]), attr)
63
+ except Exception:
64
+ return default
65
+
66
+
67
+ def provider_catalog() -> list[ProviderDescriptor]:
68
+ """One descriptor per provider in the ``hermes model`` universe (:data:`CANONICAL_PROVIDERS`,
69
+ auto-extended by provider plugins). Auth/env from ``PROVIDER_REGISTRY``; display metadata from
70
+ ``ProviderProfile`` with canonical/env fallbacks so profile-less providers still resolve."""
71
+ from hermes_cli.models import CANONICAL_PROVIDERS
72
+ PROVIDER_REGISTRY = _safe_import("hermes_cli.auth", "PROVIDER_REGISTRY", {})
73
+ OPTIONAL_ENV_VARS = _safe_import("hermes_cli.config", "OPTIONAL_ENV_VARS", {})
74
+ # Overlays carry auth_type for providers with no registry/profile entry — notably the ``moa``
75
+ # virtual provider (auth_type "virtual"), which has no credential and no network endpoint.
76
+ HERMES_OVERLAYS = _safe_import("hermes_cli.providers", "HERMES_OVERLAYS", {})
77
+ try:
78
+ from providers import list_providers
79
+ profiles = {p.name: p for p in list_providers()}
80
+ except Exception:
81
+ profiles = {}
82
+ out: list[ProviderDescriptor] = []
83
+ for order, entry in enumerate(CANONICAL_PROVIDERS):
84
+ slug = entry.slug
85
+ cfg = PROVIDER_REGISTRY.get(slug)
86
+ prof = profiles.get(slug)
87
+ overlay = HERMES_OVERLAYS.get(slug)
88
+ # auth_type: registry is authoritative; then profile, then overlay (moa → "virtual"), then api_key.
89
+ auth_type = ((cfg.auth_type if cfg else "") or (prof.auth_type if prof else "")
90
+ or (overlay.auth_type if overlay else "") or "api_key")
91
+ # Credential env vars: registry first (already normalized), else derived from the profile.
92
+ if cfg and cfg.api_key_env_vars:
93
+ api_key_vars, base_url_var = tuple(cfg.api_key_env_vars), cfg.base_url_env_var or ""
94
+ elif prof and prof.env_vars:
95
+ api_key_vars, base_url_var = _split_env_vars(tuple(prof.env_vars))
96
+ else:
97
+ api_key_vars, base_url_var = (), ""
98
+ label = (prof.display_name if prof else "") or entry.label or slug
99
+ signup_url = (prof.signup_url if prof else "") or ""
100
+ if not signup_url and api_key_vars:
101
+ signup_url = (OPTIONAL_ENV_VARS.get(api_key_vars[0]) or {}).get("url") or ""
102
+ out.append(
103
+ ProviderDescriptor(
104
+ slug=slug, label=label, description=(prof.description if prof else "") or entry.tui_desc or label,
105
+ auth_type=auth_type, tab=tab_for_auth_type(auth_type), api_key_env_vars=api_key_vars,
106
+ base_url_env_var=base_url_var, signup_url=signup_url, order=order,
107
+ # Keyless providers (opencode-free) are served anonymously: no key card in the GUI,
108
+ # and contract tests exempt them.
109
+ keyless=bool(overlay.keyless) if overlay else False,
110
+ )
111
+ )
112
+ return out
113
+
114
+
115
+ def provider_catalog_by_slug() -> dict[str, ProviderDescriptor]:
116
+ """Convenience: the catalog keyed by slug."""
117
+ return {d.slug: d for d in provider_catalog()}
hermes_cli/session_export_md.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Markdown/QMD export helpers for Hermes sessions.
2
+
3
+ Filesystem-only: formats already-exported SessionDB dicts and writes them to user-selected export
4
+ directories. Must not mutate state.db or call delete/prune/archive APIs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import re
12
+ import time
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from hermes_cli.timefmt import coerce_epoch
18
+
19
+ EXPORTER_VERSION = "hermes sessions export (md/qmd) v1"
20
+ _SHA_LINE_RE = re.compile(r"- SHA256 of exported body: `([0-9a-f]{64})`")
21
+ _SHA_PLACEHOLDER = "__SHA256_PLACEHOLDER__"
22
+ _VERIFICATION_HEADING = "## Export verification"
23
+
24
+
25
+ def _iso_timestamp(value: Any) -> str:
26
+ if value is None or value == "":
27
+ return ""
28
+ if (ts := coerce_epoch(value)) is None:
29
+ return str(value) # corrupt cell: odd-looking date, not an aborted export
30
+ return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
31
+
32
+
33
+ def _frontmatter_line(key: str, value: Any) -> str:
34
+ if value is None or isinstance(value, bool):
35
+ shown = {None: "null", True: "true", False: "false"}[value]
36
+ else:
37
+ shown = json.dumps(value if isinstance(value, (int, float, list)) else str(value), ensure_ascii=False)
38
+ return f"{key}: {shown}"
39
+
40
+
41
+ def _json_block(value: Any) -> str:
42
+ return "```json\n" + json.dumps(value, ensure_ascii=False, indent=2) + "\n```"
43
+
44
+
45
+ def _message_heading(message: dict[str, Any]) -> str:
46
+ role = str(message.get("role") or "message")
47
+ name = message.get("name") or message.get("tool_name")
48
+ label = f"Tool — {name}" if role == "tool" and name else role.capitalize()
49
+ timestamp = _iso_timestamp(message.get("created_at") or message.get("timestamp"))
50
+ return f"### {label}{' — ' + timestamp if timestamp else ''}"
51
+
52
+
53
+ def _session_id(session: dict[str, Any]) -> str:
54
+ return str(session.get("id") or session.get("session_id") or "unknown-session")
55
+
56
+
57
+ def _segments(session: dict[str, Any]) -> list[dict[str, Any]]:
58
+ segments = session.get("segments")
59
+ return [s for s in segments if isinstance(s, dict)] if isinstance(segments, list) and segments else [session]
60
+
61
+
62
+ def _message_count(session: dict[str, Any]) -> int:
63
+ return sum(len(seg.get("messages") or []) for seg in _segments(session))
64
+
65
+
66
+ def _render_messages(session: dict[str, Any]) -> str:
67
+ segments = _segments(session)
68
+ if _message_count(session) == 0:
69
+ return "## Messages\n\n_No messages in this session._\n"
70
+ parts: list[str] = ["## Messages\n"]
71
+ for segment in segments:
72
+ if len(segments) > 1:
73
+ parts.append(f"## Compression segment: {_session_id(segment)}\n")
74
+ for message in list(segment.get("messages") or []):
75
+ parts.append(_message_heading(message) + "\n")
76
+ content = message.get("content")
77
+ rendered = "" if content is None else content.rstrip() if isinstance(content, str) else _json_block(content)
78
+ if rendered:
79
+ parts.append(rendered + "\n")
80
+ if tool_calls := message.get("tool_calls"):
81
+ parts.append("\n\n## Tool calls\n\n" + _json_block(tool_calls) + "\n")
82
+ parts.append("")
83
+ return "\n".join(parts).rstrip() + "\n"
84
+
85
+
86
+ def _export_body_without_hash(session: dict[str, Any], *, fmt: str, exported_at: float) -> str:
87
+ session_id = _session_id(session)
88
+ exported_iso = _iso_timestamp(exported_at)
89
+ message_count = _message_count(session)
90
+ fields = [
91
+ ("session_id", session_id),
92
+ ("title", session.get("title")),
93
+ ("source", session.get("source")),
94
+ ("created_at", _iso_timestamp(session.get("started_at") or session.get("created_at"))),
95
+ ("updated_at", _iso_timestamp(session.get("last_active") or session.get("updated_at"))),
96
+ ("ended_at", _iso_timestamp(session.get("ended_at"))),
97
+ ("model", session.get("model")),
98
+ ("provider", session.get("billing_provider") or session.get("provider")),
99
+ ("cwd", session.get("cwd")),
100
+ ("archived", bool(session.get("archived"))),
101
+ ("message_count", message_count),
102
+ ("tool_call_count", session.get("tool_call_count") or 0),
103
+ *([("lineage_session_ids", session["lineage_session_ids"])] if session.get("lineage_session_ids") else []),
104
+ ("format", fmt), ("exported_at", exported_iso), ("exporter", EXPORTER_VERSION),
105
+ ]
106
+ parts = [
107
+ "\n".join(["---", *(_frontmatter_line(k, v) for k, v in fields), "---", ""]),
108
+ f"# {session.get('title') or session_id}\n", f"Session ID: `{session_id}`\n",
109
+ *([f"Source: `{session.get('source')}`\n"] if session.get("source") else []),
110
+ *([f"Working directory: `{session.get('cwd')}`\n"] if session.get("cwd") else []),
111
+ _render_messages(session),
112
+ f"{_VERIFICATION_HEADING}\n",
113
+ f"- Session id: `{session_id}`",
114
+ f"- Exported messages: `{message_count}`",
115
+ f"- Source DB message count at export: `{session.get('message_count', message_count)}`",
116
+ f"- Exported at: `{exported_iso}`",
117
+ f"- SHA256 of exported body: `{_SHA_PLACEHOLDER}`",
118
+ ]
119
+ return "\n".join(parts).rstrip() + "\n"
120
+
121
+
122
+ def _check_fmt(fmt: str) -> None:
123
+ if fmt not in {"md", "qmd"}:
124
+ raise ValueError("fmt must be 'md' or 'qmd'")
125
+
126
+
127
+ def render_session_markdown(
128
+ session: dict[str, Any], *, fmt: str = "md", include_verification: bool = True
129
+ ) -> str:
130
+ """Render a SessionDB export dictionary as Markdown/QMD text."""
131
+ _check_fmt(fmt)
132
+ body = _export_body_without_hash(session, fmt=fmt, exported_at=time.time())
133
+ if not include_verification:
134
+ return body.split(f"\n{_VERIFICATION_HEADING}\n", 1)[0].rstrip() + "\n"
135
+ # The digest covers the body with the SHA line set to `pending`, which is what verify recomputes.
136
+ digest_body = body.replace(f"`{_SHA_PLACEHOLDER}`", "`pending`")
137
+ return body.replace(_SHA_PLACEHOLDER, hashlib.sha256(digest_body.encode("utf-8")).hexdigest())
138
+
139
+
140
+ def safe_session_filename(session: dict[str, Any], *, fmt: str = "md") -> str:
141
+ """Return a deterministic, path-safe filename for a session export."""
142
+ _check_fmt(fmt)
143
+ title = str(session.get("title") or "session")
144
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", title).strip(".-_").lower() or "session"
145
+ return f"{_session_id(session)}-{slug[:60]}.{fmt}"
146
+
147
+
148
+ def file_sha256(path: Path | str) -> str:
149
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
150
+
151
+
152
+ def verify_export_file(path: Path | str, session: dict[str, Any]) -> tuple[bool, str]:
153
+ if not Path(path).exists():
154
+ return False, "file missing"
155
+ text = Path(path).read_text(encoding="utf-8")
156
+ match = _SHA_LINE_RE.search(text)
157
+ if not match:
158
+ return False, "sha256 marker missing"
159
+ digest_body = _SHA_LINE_RE.sub("- SHA256 of exported body: `pending`", text)
160
+ if hashlib.sha256(digest_body.encode("utf-8")).hexdigest() != match.group(1):
161
+ return False, "sha256 mismatch"
162
+ if f"- Exported messages: `{_message_count(session)}`" not in text:
163
+ return False, "message count mismatch"
164
+ if f"- Session id: `{_session_id(session)}`" not in text:
165
+ return False, "session id mismatch"
166
+ return True, "ok"
167
+
168
+
169
+ def redact_session_data(session: dict[str, Any]) -> dict[str, Any]:
170
+ """Return a copy of a session export dict with secrets redacted.
171
+
172
+ Every message's content and tool-call arguments go through the force-mode redaction pass
173
+ (``agent.redact.redact_sensitive_text``) so credentials in tool output never land in exports.
174
+ """
175
+ from agent.redact import redact_sensitive_text
176
+
177
+ def _clean(value: Any) -> Any:
178
+ if isinstance(value, str):
179
+ return redact_sensitive_text(value, force=True)
180
+ if isinstance(value, list):
181
+ return [_clean(v) for v in value]
182
+ if isinstance(value, dict):
183
+ return {k: _clean(v) for k, v in value.items()}
184
+ return value
185
+
186
+ return {**session, **{k: _clean(session[k]) for k in ("messages", "segments") if session.get(k) is not None}}
187
+
188
+
189
+ def _export_dir(output_dir: Path | str) -> Path:
190
+ out_dir = Path(output_dir).expanduser()
191
+ out_dir.mkdir(parents=True, exist_ok=True)
192
+ return out_dir
193
+
194
+
195
+ def write_session_markdown(
196
+ session: dict[str, Any], output_dir: Path | str, *, fmt: str = "md", force: bool = False
197
+ ) -> Path:
198
+ """Write a Markdown/QMD export file and return its path."""
199
+ path = _export_dir(output_dir) / safe_session_filename(session, fmt=fmt)
200
+ if path.exists() and not force:
201
+ raise FileExistsError(str(path))
202
+ path.write_text(render_session_markdown(session, fmt=fmt), encoding="utf-8")
203
+ return path
204
+
205
+
206
+ def append_manifest_entry(output_dir: Path | str, session: dict[str, Any], path: Path | str, *, fmt: str) -> Path:
207
+ entry = {
208
+ "session_id": _session_id(session),
209
+ "lineage_session_ids": session.get("lineage_session_ids") or [_session_id(session)],
210
+ "path": str(Path(path)),
211
+ "format": fmt,
212
+ "message_count": _message_count(session),
213
+ "sha256": file_sha256(path),
214
+ "exported_at": time.time(),
215
+ }
216
+ manifest = _export_dir(output_dir) / "manifest.jsonl"
217
+ with manifest.open("a", encoding="utf-8") as fh:
218
+ fh.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n")
219
+ return manifest
hermes_cli/shared_session_attach.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Discover a cooperative local runtime without taking its session lease.
2
+
3
+ The owner's handshake supplies the existing authenticated WebSocket URL. A
4
+ registry entry is discovery information, not authority to mint a credential.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import ipaddress
9
+ import json
10
+ from pathlib import Path
11
+ from urllib.parse import urlencode, urlsplit
12
+
13
+ import httpx
14
+
15
+ from hermes_constants import get_hermes_home
16
+ from hermes_cli.active_sessions import active_session_registry_snapshot, session_already_owned_message
17
+
18
+
19
+ def _local_origin(url: str, scheme: str) -> tuple[str, int]:
20
+ parts = urlsplit(url)
21
+ host = parts.hostname or ""
22
+ try:
23
+ local = ipaddress.ip_address(host).is_loopback
24
+ except ValueError:
25
+ local = False
26
+ if (parts.scheme != scheme or not local or parts.username is not None
27
+ or parts.password is not None or parts.fragment or not parts.port):
28
+ raise ValueError("Shared runtime endpoint must be an explicit loopback address and port.")
29
+ return host, parts.port
30
+
31
+
32
+ def discover_attach_url(session_id: str, *, registry_home: str | Path | None = None) -> str | None:
33
+ """Return a fenced authenticated URL, None for no owner, or refuse safely.
34
+
35
+ This deliberately does not scan ports or read another profile. The runtime
36
+ must advertise ``metadata.shared_runtime_url`` and implement the local
37
+ ``/api/session-attach`` handshake. Unsupported owners keep their lease.
38
+ """
39
+ home = Path(registry_home if registry_home is not None else get_hermes_home()).resolve()
40
+ owners = [entry for entry in active_session_registry_snapshot(home, strict=True)
41
+ if entry.get("session_id") == session_id]
42
+ if not owners:
43
+ return None
44
+ if len(owners) != 1:
45
+ raise ValueError("Session owner identity is ambiguous; no attachment was attempted.")
46
+ owner = owners[0]
47
+ endpoint = (owner.get("metadata") or {}).get("shared_runtime_url")
48
+ if not isinstance(endpoint, str) or not endpoint:
49
+ raise ValueError("The live owner does not advertise cooperative attachment. "
50
+ + session_already_owned_message(session_id, owner))
51
+ origin = _local_origin(endpoint, "http")
52
+ parts = urlsplit(endpoint)
53
+ if parts.path not in ("", "/") or parts.query:
54
+ raise ValueError("Shared runtime endpoint must be an origin without a path or query.")
55
+ query = urlencode({"session_id": session_id, "lease_id": owner["lease_id"],
56
+ "profile_home": str(home)})
57
+ try:
58
+ # Ignore proxy env and redirects: local discovery must stay on the
59
+ # advertised endpoint, including on machines with corporate proxies.
60
+ with httpx.Client(trust_env=False, follow_redirects=False, timeout=3.0) as client:
61
+ with client.stream("GET", endpoint.rstrip("/") + "/api/session-attach?" + query) as response:
62
+ response.raise_for_status()
63
+ body = bytearray()
64
+ for chunk in response.iter_bytes():
65
+ body.extend(chunk)
66
+ if len(body) > 65536:
67
+ raise ValueError("Shared runtime handshake response is too large.")
68
+ reply = json.loads(body)
69
+ except (httpx.HTTPError, json.JSONDecodeError) as exc:
70
+ # Never include a remote body or authenticated URL in diagnostics.
71
+ raise ValueError("The live owner could not authorize cooperative attachment; "
72
+ "its lease was left intact.") from exc
73
+ if not isinstance(reply, dict) or any(reply.get(key) != value for key, value in {
74
+ "session_id": session_id, "lease_id": owner["lease_id"], "profile_home": str(home),
75
+ }.items()):
76
+ raise ValueError("Shared runtime handshake identity does not match the requested owner.")
77
+ websocket_url = reply.get("websocket_url")
78
+ if (not isinstance(websocket_url, str) or _local_origin(websocket_url, "ws") != origin
79
+ or urlsplit(websocket_url).path != "/api/ws"):
80
+ raise ValueError("Shared runtime handshake returned a different endpoint.")
81
+ return websocket_url
82
+
83
+
84
+ def configure_tui_attachment(env: dict[str, str], session_id: str | None, *,
85
+ registry_home: str | Path | None = None) -> None:
86
+ """Retain an explicit transport, otherwise attach a resumed owner's runtime."""
87
+ if not session_id or env.get("HERMES_TUI_GATEWAY_URL", "").strip():
88
+ return
89
+ url = discover_attach_url(session_id, registry_home=registry_home)
90
+ if url is not None:
91
+ env["HERMES_TUI_GATEWAY_URL"] = url
hermes_cli/sizefmt.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small shared size-formatting helpers for CLI/agent output (sibling of ``hermes_cli.timefmt``:
2
+ a tiny purpose-named module lightweight consumers can import without the CLI surface)."""
3
+
4
+ from __future__ import annotations
5
+
6
+
7
+ def format_bytes(n) -> str:
8
+ """1234567 -> '1.2 MB' (B/KB/MB/GB/TB; integer bytes, one decimal above). Returns ``"?"`` for
9
+ None or unparseable input so display call sites never raise."""
10
+ try:
11
+ size = float(n)
12
+ except (TypeError, ValueError):
13
+ return "?"
14
+ if size < 1024:
15
+ return f"{int(size)} B"
16
+ for unit in ("KB", "MB", "GB"):
17
+ size /= 1024.0
18
+ if size < 1024:
19
+ return f"{size:.1f} {unit}"
20
+ return f"{size / 1024.0:.1f} TB"
hermes_cli/skin_engine.py ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hermes skin/theme engine — the theme SDK for every surface."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ from hermes_constants import get_hermes_home
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @dataclass
14
+ class SkinConfig:
15
+ """Complete skin configuration."""
16
+ name: str
17
+ description: str = ""
18
+ colors: Dict[str, str] = field(default_factory=dict)
19
+ # Paired palettes for the opposite background polarity (mirrors the desktop app's
20
+ # colors/darkColors pairing): a light terminal prefers `light_colors` (falling back to
21
+ # `colors`), and vice versa for `dark_colors`.
22
+ light_colors: Dict[str, str] = field(default_factory=dict)
23
+ dark_colors: Dict[str, str] = field(default_factory=dict)
24
+ spinner: Dict[str, Any] = field(default_factory=dict)
25
+ branding: Dict[str, str] = field(default_factory=dict)
26
+ tool_prefix: str = "┊"
27
+ tool_emojis: Dict[str, str] = field(default_factory=dict) # per-tool emoji overrides
28
+ banner_logo: str = "" # Rich-markup ASCII art logo (replaces HERMES_AGENT_LOGO)
29
+ banner_hero: str = "" # Rich-markup hero art (replaces HERMES_CADUCEUS)
30
+
31
+ def get_color(self, key: str, fallback: str = "") -> str:
32
+ return self.colors.get(key, fallback)
33
+
34
+ def get_branding(self, key: str, fallback: str = "") -> str:
35
+ return self.branding.get(key, fallback)
36
+
37
+ def get_spinner_wings(self) -> List[Tuple[str, str]]:
38
+ """Spinner wing pairs, or empty list if none."""
39
+ return [(str(pair[0]), str(pair[1])) for pair in self.spinner.get("wings", [])
40
+ if isinstance(pair, (list, tuple)) and len(pair) == 2]
41
+
42
+
43
+ def _branding(who: str, symbol: str, goodbye: str, prompt: str = "", help_header: str = "") -> Dict[str, str]:
44
+ """Branding block for a "<who> Agent" persona keyed by its glyph."""
45
+ return {
46
+ "agent_name": f"{who} Agent",
47
+ "welcome": f"Welcome to {who} Agent! Type your message or /help for commands.",
48
+ "goodbye": goodbye, "response_label": f" {symbol} {who} ", "prompt_symbol": prompt or symbol,
49
+ "help_header": help_header or f"({symbol}) Available Commands"}
50
+
51
+
52
+ def _wings(*glyphs) -> List[List[str]]:
53
+ """Spinner wing pairs `⟪g` / `g⟫`; a (left, right) tuple gives asymmetric glyphs."""
54
+ return [[f"⟪{g[0] if isinstance(g, tuple) else g}", f"{g[1] if isinstance(g, tuple) else g}⟫"]
55
+ for g in glyphs]
56
+
57
+
58
+ # Branding shared by every Hermes-named built-in (mono/daylight override help_header).
59
+ _HERMES_BRANDING: Dict[str, str] = _branding(
60
+ "Hermes", "☤", "Goodbye! ☤", prompt="❯", help_header="(^_^)? Available Commands")
61
+
62
+ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
63
+ "default": {
64
+ "name": "default", "description": "Classic Hermes — gold and kawaii",
65
+ # Dark-authored; values match the TUI's DARK_THEME so both render the same gold.
66
+ "colors": {
67
+ "banner_border": "#CD7F32", "banner_title": "#FFD700", "banner_accent": "#FFBF00",
68
+ "banner_dim": "#B8860B", "banner_text": "#FFF8DC", "ui_accent": "#FFBF00",
69
+ "ui_label": "#DAA520", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726",
70
+ "prompt": "#FFF8DC", "input_rule": "#CD7F32", "response_border": "#FFD700",
71
+ "status_bar_bg": "#1a1a2e", "status_bar_text": "#C0C0C0",
72
+ "status_bar_strong": "#FFD700", "status_bar_dim": "#8A7A4A",
73
+ "status_bar_good": "#8FBC8F", "status_bar_warn": "#FFD700", "status_bar_bad": "#FF8C00",
74
+ "status_bar_critical": "#FF6B6B", "session_label": "#DAA520",
75
+ "session_border": "#8B8682", "completion_menu_bg": "#1a1a2e",
76
+ "completion_menu_current_bg": "#333355", "selection_bg": "#3a3a55",
77
+ "shell_dollar": "#4dabf7", "voice_status_bg": "#1a1a2e"},
78
+ # Light overlay (merged onto `colors`). Goldenrod ladder: on white the vivid
79
+ # #FFD700/#FFBF00 read as glare and WCAG-darkened mustard (#867000) as mud; the
80
+ # statusbar's goldenrod family (#B8860B/#DAA520) keeps the hue, tames saturation.
81
+ # Hierarchy on white: ink body 8.9:1 > fade 5.2 > label 3.7 > muted 3.3 > title 2.7 >
82
+ # headers 2.4. Fills (*_bg) flip the dark navy surfaces to light polarity.
83
+ "light_colors": {
84
+ "banner_title": "#C8961E", "banner_accent": "#D89B04", "banner_dim": "#B8860B",
85
+ "banner_text": "#5C4718", "ui_accent": "#D89B04", "ui_label": "#A97E10",
86
+ "ui_ok": "#2E7D32", "ui_error": "#C62828", "ui_warn": "#D97706", "prompt": "#5C4718",
87
+ "response_border": "#C8961E", "session_label": "#A97E10", "status_bar_text": "#6F6F6F",
88
+ "status_bar_strong": "#C8961E", "status_bar_dim": "#9A8A5A",
89
+ "status_bar_good": "#2E7D32", "status_bar_warn": "#C8961E", "status_bar_bad": "#C2410C",
90
+ "status_bar_critical": "#B91C1C", "shell_dollar": "#1E6FC0",
91
+ "completion_menu_bg": "#F5F5F5", "completion_menu_current_bg": "#E0D1BF",
92
+ "selection_bg": "#D4E4F7", "status_bar_bg": "#F5F5F5", "voice_status_bg": "#F5F5F5"},
93
+ "spinner": {}, # empty = hardcoded defaults in display.py
94
+ "branding": _HERMES_BRANDING,
95
+ "tool_prefix": "┊"},
96
+ "ares": {
97
+ "name": "ares", "description": "War-god theme — crimson and bronze",
98
+ "colors": {
99
+ "banner_border": "#A93333", "banner_title": "#C7A96B", "banner_accent": "#DD4A3A",
100
+ "banner_dim": "#905151", "banner_text": "#F1E6CF", "ui_accent": "#DD4A3A",
101
+ "ui_label": "#C7A96B", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726",
102
+ "prompt": "#F1E6CF", "input_rule": "#A93333", "response_border": "#C7A96B",
103
+ "status_bar_bg": "#2A1212", "status_bar_text": "#F1E6CF",
104
+ "status_bar_strong": "#C7A96B", "status_bar_dim": "#756054",
105
+ "status_bar_good": "#7BC96F", "status_bar_warn": "#C7A96B", "status_bar_bad": "#DD4A3A",
106
+ "status_bar_critical": "#EF5350", "session_label": "#C7A96B",
107
+ "session_border": "#6E584B", "completion_menu_bg": "#2A1212",
108
+ "completion_menu_current_bg": "#5C221D", "selection_bg": "#692620",
109
+ "shell_dollar": "#DD4A3A", "voice_status_bg": "#2A1212"},
110
+ "spinner": {
111
+ "waiting_faces": ["(⚔)", "(⛨)", "(▲)", "(<>)", "(/)"],
112
+ "thinking_faces": ["(⚔)", "(⛨)", "(▲)", "(⌁)", "(<>)"],
113
+ "thinking_verbs": [
114
+ "forging", "marching", "sizing the field", "holding the line",
115
+ "hammering plans", "tempering steel", "plotting impact", "raising the shield"],
116
+ "wings": _wings("⚔", "▲", ("╸", "╺"), "⛨")},
117
+ "branding": _branding("Ares", "⚔", "Farewell, warrior! ⚔"),
118
+ "tool_prefix": "╎",
119
+ "banner_logo": """[bold #A3261F] █████╗ ██████╗ ███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
120
+ [bold #B73122]██╔══██╗██╔══██╗██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
121
+ [#C93C24]███████║██████╔╝█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
122
+ [#D84A28]██╔══██║██╔══██╗██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
123
+ [#E15A2D]██║ ██║██║ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
124
+ [#EB6C32]╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
125
+ "banner_hero": """[#9F1C1C]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤⣤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
126
+ [#9F1C1C]⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⠟⠻⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
127
+ [#C7A96B]⠀⠀⠀⠀⠀⠀⠀⣠⣾⡿⠋⠀⠀⠀⠙⢿⣷⣄⠀⠀⠀⠀⠀⠀⠀[/]
128
+ [#C7A96B]⠀⠀⠀⠀⠀⢀⣾⡿⠋⠀⠀⢠⡄⠀⠀⠙⢿⣷⡀⠀⠀⠀⠀⠀[/]
129
+ [#DD4A3A]⠀⠀⠀⠀⣰⣿⠟⠀⠀⠀⣰⣿⣿⣆⠀⠀⠀⠻⣿⣆⠀⠀⠀⠀[/]
130
+ [#DD4A3A]⠀⠀⠀⢰⣿⠏⠀⠀⢀⣾⡿⠉⢿⣷⡀⠀⠀⠹⣿⡆⠀⠀⠀[/]
131
+ [#9F1C1C]⠀⠀⠀⣿⡟⠀⠀⣠⣿⠟⠀⠀⠀⠻⣿⣄⠀⠀⢻⣿⠀⠀⠀[/]
132
+ [#9F1C1C]⠀⠀⠀⣿⡇⠀⠀⠙⠋⠀⠀⚔⠀⠀⠙⠋⠀⠀⢸⣿⠀⠀⠀[/]
133
+ [#6B1717]⠀⠀⠀⢿⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⡿⠀⠀⠀[/]
134
+ [#6B1717]⠀⠀⠀⠘⢿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⡿⠃⠀⠀⠀[/]
135
+ [#C7A96B]⠀⠀⠀⠀⠈⠻⣿⣷⣦⣤⣀⣀⣤⣤⣶⣿⠿⠋⠀⠀⠀⠀[/]
136
+ [#C7A96B]⠀⠀⠀⠀⠀⠀⠀⠉⠛⠿⠿⠿⠿⠛⠉⠀⠀⠀⠀⠀⠀⠀[/]
137
+ [#DD4A3A]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⚔⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
138
+ [dim #6B1717]⠀⠀⠀⠀⠀⠀⠀⠀war god online⠀⠀⠀⠀⠀⠀⠀⠀[/]""",
139
+ },
140
+ "mono": {
141
+ "name": "mono", "description": "Monochrome — clean grayscale",
142
+ "colors": {
143
+ "banner_border": "#5E5E5E", "banner_title": "#e6edf3", "banner_accent": "#aaaaaa",
144
+ "banner_dim": "#606060", "banner_text": "#c9d1d9", "ui_accent": "#aaaaaa",
145
+ "ui_label": "#888888", "ui_ok": "#888888", "ui_error": "#cccccc", "ui_warn": "#999999",
146
+ "prompt": "#c9d1d9", "input_rule": "#606060", "response_border": "#aaaaaa",
147
+ "status_bar_bg": "#1F1F1F", "status_bar_text": "#C9D1D9",
148
+ "status_bar_strong": "#E6EDF3", "status_bar_dim": "#777777",
149
+ "status_bar_good": "#B5B5B5", "status_bar_warn": "#AAAAAA", "status_bar_bad": "#D0D0D0",
150
+ "status_bar_critical": "#F0F0F0", "session_label": "#888888",
151
+ "session_border": "#5E5E5E", "completion_menu_bg": "#1F1F1F",
152
+ "completion_menu_current_bg": "#464646", "selection_bg": "#505050",
153
+ "shell_dollar": "#aaaaaa", "voice_status_bg": "#1F1F1F"},
154
+ "spinner": {},
155
+ "branding": {**_HERMES_BRANDING, "help_header": "[?] Available Commands"},
156
+ "tool_prefix": "┊"},
157
+ "slate": {
158
+ "name": "slate", "description": "Cool blue — developer-focused",
159
+ "colors": {
160
+ "banner_border": "#4169e1", "banner_title": "#7eb8f6", "banner_accent": "#8EA8FF",
161
+ "banner_dim": "#545E6B", "banner_text": "#c9d1d9", "ui_accent": "#7eb8f6",
162
+ "ui_label": "#8EA8FF", "ui_ok": "#63D0A6", "ui_error": "#F7A072", "ui_warn": "#e6a855",
163
+ "prompt": "#c9d1d9", "input_rule": "#4169e1", "response_border": "#7eb8f6",
164
+ "status_bar_bg": "#151C2F", "status_bar_text": "#C9D1D9",
165
+ "status_bar_strong": "#7EB8F6", "status_bar_dim": "#5D6672",
166
+ "status_bar_good": "#63D0A6", "status_bar_warn": "#E6A855", "status_bar_bad": "#F7A072",
167
+ "status_bar_critical": "#FF7A7A", "session_label": "#7eb8f6",
168
+ "session_border": "#545E6B", "completion_menu_bg": "#151C2F",
169
+ "completion_menu_current_bg": "#324867", "selection_bg": "#3A5375",
170
+ "shell_dollar": "#7eb8f6", "voice_status_bg": "#151C2F"},
171
+ "spinner": {}, "branding": _HERMES_BRANDING, "tool_prefix": "┊"},
172
+ "daylight": {
173
+ "name": "daylight",
174
+ "description": "Light theme for bright terminals with dark text and cool blue accents",
175
+ "colors": {
176
+ "banner_border": "#2563EB", "banner_title": "#0F172A", "banner_accent": "#1D4ED8",
177
+ "banner_dim": "#475569", "banner_text": "#111827", "ui_accent": "#2563EB",
178
+ "ui_label": "#0F766E", "ui_ok": "#15803D", "ui_error": "#B91C1C", "ui_warn": "#B45309",
179
+ "prompt": "#111827", "input_rule": "#6E94BE", "response_border": "#2563EB",
180
+ "status_bar_bg": "#E5EDF8", "status_bar_text": "#111827",
181
+ "status_bar_strong": "#2563EB", "status_bar_dim": "#838890",
182
+ "status_bar_good": "#15803D", "status_bar_warn": "#B45309", "status_bar_bad": "#B45309",
183
+ "status_bar_critical": "#B91C1C", "session_label": "#1D4ED8",
184
+ "session_border": "#64748B", "completion_menu_bg": "#F8FAFC",
185
+ "completion_menu_current_bg": "#DBEAFE", "completion_menu_meta_bg": "#EEF2FF",
186
+ "completion_menu_meta_current_bg": "#BFDBFE", "selection_bg": "#D3E0FB",
187
+ "shell_dollar": "#2563EB", "voice_status_bg": "#E5EDF8"},
188
+ "spinner": {},
189
+ "branding": {**_HERMES_BRANDING, "help_header": "[?] Available Commands"},
190
+ "tool_prefix": "│"},
191
+ "warm-lightmode": {
192
+ "name": "warm-lightmode",
193
+ "description": "Warm light mode — dark brown/gold text for light terminal backgrounds",
194
+ "colors": {
195
+ "banner_border": "#8B6914", "banner_title": "#5C3D11", "banner_accent": "#8B4513",
196
+ "banner_dim": "#8B7355", "banner_text": "#2C1810", "ui_accent": "#8B4513",
197
+ "ui_label": "#5C3D11", "ui_ok": "#2E7D32", "ui_error": "#C62828", "ui_warn": "#E65100",
198
+ "prompt": "#2C1810", "input_rule": "#8B6914", "response_border": "#8B6914",
199
+ "status_bar_bg": "#F5F0E8", "status_bar_text": "#2C1810",
200
+ "status_bar_strong": "#8B4513", "status_bar_dim": "#8A8F98",
201
+ "status_bar_good": "#2E7D32", "status_bar_warn": "#E65100", "status_bar_bad": "#DA4D00",
202
+ "status_bar_critical": "#C62828", "session_label": "#5C3D11",
203
+ "session_border": "#A0845C", "completion_menu_bg": "#F5EFE0",
204
+ "completion_menu_current_bg": "#E8DCC8", "completion_menu_meta_bg": "#F0E8D8",
205
+ "completion_menu_meta_current_bg": "#DFCFB0", "selection_bg": "#E8DAD0",
206
+ "shell_dollar": "#8B4513", "voice_status_bg": "#F5F0E8"},
207
+ "spinner": {}, "branding": _HERMES_BRANDING, "tool_prefix": "┊"},
208
+ "poseidon": {
209
+ "name": "poseidon", "description": "Ocean-god theme — deep blue and seafoam",
210
+ "colors": {
211
+ "banner_border": "#2A6FB9", "banner_title": "#A9DFFF", "banner_accent": "#5DB8F5",
212
+ "banner_dim": "#44638F", "banner_text": "#EAF7FF", "ui_accent": "#5DB8F5",
213
+ "ui_label": "#A9DFFF", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726",
214
+ "prompt": "#EAF7FF", "input_rule": "#2A6FB9", "response_border": "#5DB8F5",
215
+ "status_bar_bg": "#0F2440", "status_bar_text": "#EAF7FF",
216
+ "status_bar_strong": "#A9DFFF", "status_bar_dim": "#52708A",
217
+ "status_bar_good": "#6ED7B0", "status_bar_warn": "#5DB8F5", "status_bar_bad": "#3576BC",
218
+ "status_bar_critical": "#D94F4F", "session_label": "#A9DFFF",
219
+ "session_border": "#496884", "completion_menu_bg": "#0F2440",
220
+ "completion_menu_current_bg": "#254D73", "selection_bg": "#2A587F",
221
+ "shell_dollar": "#5DB8F5", "voice_status_bg": "#0F2440"},
222
+ "spinner": {
223
+ "waiting_faces": ["(≈)", "(Ψ)", "(∿)", "(◌)", "(◠)"],
224
+ "thinking_faces": ["(Ψ)", "(∿)", "(≈)", "(⌁)", "(◌)"],
225
+ "thinking_verbs": [
226
+ "charting currents", "sounding the depth", "reading foam lines",
227
+ "steering the trident", "tracking undertow", "plotting sea lanes",
228
+ "calling the swell", "measuring pressure"],
229
+ "wings": _wings("≈", "Ψ", "∿", "◌")},
230
+ "branding": _branding("Poseidon", "Ψ", "Fair winds! Ψ"),
231
+ "tool_prefix": "│",
232
+ "banner_logo": """[bold #B8E8FF]██████╗ ██████╗ ███████╗███████╗██╗██████╗ ██████╗ ███╗ ██╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
233
+ [bold #97D6FF]██╔══██╗██╔═══██╗██╔════╝██╔════╝██║██╔══██╗██╔═══██╗████╗ ██║ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
234
+ [#75C1F6]██████╔╝██║ ██║███████╗█████╗ ██║██║ ██║██║ ██║██╔██╗ ██║█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
235
+ [#4FA2E0]██╔═══╝ ██║ ██║╚════██║██╔══╝ ██║██║ ██║██║ ██║██║╚██╗██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
236
+ [#2E7CC7]██║ ╚██████╔╝███████║███████╗██║██████╔╝╚██████╔╝██║ ╚████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
237
+ [#1B4F95]╚═╝ ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
238
+ "banner_hero": """[#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
239
+ [#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
240
+ [#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⢠⣿⠏⠀Ψ⠀⠹⣿⡄⠀⠀⠀⠀⠀⠀⠀[/]
241
+ [#A9DFFF]⠀⠀⠀⠀⠀⠀⠀⣿⡟⠀⠀⠀⠀⠀⢻⣿⠀⠀⠀⠀⠀⠀⠀[/]
242
+ [#A9DFFF]⠀⠀⠀≈≈≈≈≈⣿⡇⠀⠀⠀⠀⠀⢸⣿≈≈≈≈≈⠀⠀⠀[/]
243
+ [#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⣿⡇⠀⠀⠀⠀⠀⢸⣿⠀⠀⠀⠀⠀⠀⠀[/]
244
+ [#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⢿⣧⠀⠀⠀⠀⠀⣼⡿⠀⠀⠀⠀⠀⠀⠀[/]
245
+ [#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⠘⢿⣷⣄⣀⣠⣾⡿⠃⠀⠀⠀⠀⠀⠀⠀[/]
246
+ [#153C73]⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣿⣿⡿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀[/]
247
+ [#153C73]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
248
+ [#5DB8F5]⠀⠀⠀⠀⠀≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈⠀⠀⠀⠀⠀[/]
249
+ [#A9DFFF]⠀⠀⠀⠀⠀⠀≈≈≈≈≈≈≈≈≈≈≈≈≈⠀⠀⠀⠀⠀⠀[/]
250
+ [dim #153C73]⠀⠀⠀⠀⠀⠀⠀deep waters hold⠀⠀⠀⠀⠀⠀⠀[/]""",
251
+ },
252
+ "sisyphus": {
253
+ "name": "sisyphus", "description": "Sisyphean theme — austere grayscale with persistence",
254
+ "colors": {
255
+ "banner_border": "#B7B7B7", "banner_title": "#F5F5F5", "banner_accent": "#E7E7E7",
256
+ "banner_dim": "#5C5C5C", "banner_text": "#D3D3D3", "ui_accent": "#E7E7E7",
257
+ "ui_label": "#D3D3D3", "ui_ok": "#919191", "ui_error": "#E7E7E7", "ui_warn": "#B7B7B7",
258
+ "prompt": "#F5F5F5", "input_rule": "#656565", "response_border": "#B7B7B7",
259
+ "status_bar_bg": "#202020", "status_bar_text": "#D3D3D3",
260
+ "status_bar_strong": "#F5F5F5", "status_bar_dim": "#6D6D6D",
261
+ "status_bar_good": "#B7B7B7", "status_bar_warn": "#D3D3D3", "status_bar_bad": "#E7E7E7",
262
+ "status_bar_critical": "#F5F5F5", "session_label": "#919191",
263
+ "session_border": "#656565", "completion_menu_bg": "#202020",
264
+ "completion_menu_current_bg": "#585858", "selection_bg": "#666666",
265
+ "shell_dollar": "#E7E7E7", "voice_status_bg": "#202020"},
266
+ "spinner": {
267
+ "waiting_faces": ["(◉)", "(◌)", "(◬)", "(⬤)", "(::)"],
268
+ "thinking_faces": ["(◉)", "(◬)", "(◌)", "(○)", "(●)"],
269
+ "thinking_verbs": [
270
+ "finding traction", "measuring the grade", "resetting the boulder",
271
+ "counting the ascent", "testing leverage", "setting the shoulder",
272
+ "pushing uphill", "enduring the loop"],
273
+ "wings": _wings("◉", "◬", "◌", "⬤")},
274
+ "branding": _branding("Sisyphus", "◉", "The boulder waits. ◉"),
275
+ "tool_prefix": "│",
276
+ "banner_logo": """[bold #F5F5F5]███████╗██╗███████╗██╗ ██╗██████╗ ██╗ ██╗██╗ ██╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
277
+ [bold #E7E7E7]██╔════╝██║██╔════╝╚██╗ ██╔╝██╔══██╗██║ ██║██║ ██║██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
278
+ [#D7D7D7]███████╗██║███████╗ ╚████╔╝ ██████╔╝███████║██║ ██║███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
279
+ [#BFBFBF]╚════██║██║╚════██║ ╚██╔╝ ██╔═══╝ ██╔══██║██║ ██║╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
280
+ [#8F8F8F]███████║██║███████║ ██║ ██║ ██║ ██║╚██████╔╝███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
281
+ [#626262]╚══════╝╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
282
+ "banner_hero": """[#B7B7B7]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
283
+ [#D3D3D3]⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀[/]
284
+ [#E7E7E7]⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀[/]
285
+ [#F5F5F5]⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀[/]
286
+ [#E7E7E7]⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀[/]
287
+ [#D3D3D3]⠀⠀⠀⠀⠀⠀⠘⢿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀[/]
288
+ [#B7B7B7]⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⣿⠿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
289
+ [#919191]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
290
+ [#656565]⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
291
+ [#656565]⠀⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
292
+ [#4A4A4A]⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
293
+ [#4A4A4A]⠀⠀⠀⠀⠀⣀⣴⣿⣿⣿⣿⣿⣿⣦⣀⠀⠀⠀⠀⠀⠀[/]
294
+ [#656565]⠀⠀⠀━━━━━━━━━━━━━━━━━━━━━━━⠀⠀⠀[/]
295
+ [dim #4A4A4A]⠀⠀⠀⠀⠀⠀⠀⠀⠀the boulder⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]""",
296
+ },
297
+ "charizard": {
298
+ "name": "charizard", "description": "Volcanic theme — burnt orange and ember",
299
+ "colors": {
300
+ "banner_border": "#C75B1D", "banner_title": "#FFD39A", "banner_accent": "#F29C38",
301
+ "banner_dim": "#C58A45", "banner_text": "#FFF0D4", "ui_accent": "#F29C38",
302
+ "ui_label": "#FFD39A", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726",
303
+ "prompt": "#FFF0D4", "input_rule": "#C75B1D", "response_border": "#F29C38",
304
+ "status_bar_bg": "#2B160E", "status_bar_text": "#FFF0D4",
305
+ "status_bar_strong": "#FFD39A", "status_bar_dim": "#826144",
306
+ "status_bar_good": "#6BCB77", "status_bar_warn": "#F29C38", "status_bar_bad": "#E2832B",
307
+ "status_bar_critical": "#EF5350", "session_label": "#FFD39A",
308
+ "session_border": "#7B593A", "completion_menu_bg": "#0B0503",
309
+ "completion_menu_current_bg": "#4A1B07", "completion_menu_meta_bg": "#120806",
310
+ "completion_menu_meta_current_bg": "#5A260D", "selection_bg": "#5A260D",
311
+ "shell_dollar": "#F29C38", "voice_status_bg": "#2B160E"},
312
+ "spinner": {
313
+ "waiting_faces": ["(✦)", "(▲)", "(◇)", "(<>)", "(🔥)"],
314
+ "thinking_faces": ["(✦)", "(▲)", "(◇)", "(⌁)", "(🔥)"],
315
+ "thinking_verbs": [
316
+ "banking into the draft", "measuring burn", "reading the updraft",
317
+ "tracking ember fall", "setting wing angle", "holding the flame core",
318
+ "plotting a hot landing", "coiling for lift"],
319
+ "wings": _wings("✦", "▲", "◌", "◇")},
320
+ "branding": _branding("Charizard", "✦", "Flame out! ✦"),
321
+ "tool_prefix": "│",
322
+ "banner_logo": """[bold #FFF0D4] ██████╗██╗ ██╗ █████╗ ██████╗ ██╗███████╗ █████╗ ██████╗ ██████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
323
+ [bold #FFD39A]██╔════╝██║ ██║██╔══██╗██╔══██╗██║╚══███╔╝██╔══██╗██╔══██╗██╔══██╗ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
324
+ [#F29C38]██║ ███████║███████║██████╔╝██║ ███╔╝ ███████║██████╔╝██║ ██║█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
325
+ [#E2832B]██║ ██╔══██║██╔══██║██╔══██╗██║ ███╔╝ ██╔══██║██╔══██╗██║ ██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
326
+ [#C75B1D]╚██████╗██║ ██║██║ ██║██║ ██║██║███████╗██║ ██║██║ ██║██████╔╝ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
327
+ [#7A3511] ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
328
+ "banner_hero": """[#FFD39A]⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⠶⠶⠶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀[/]
329
+ [#F29C38]⠀⠀⠀⠀⠀⠀⣴⠟⠁⠀⠀⠀⠀⠈⠻⣦⠀⠀⠀⠀⠀⠀[/]
330
+ [#F29C38]⠀⠀⠀⠀⠀⣼⠏⠀⠀⠀✦⠀⠀⠀⠀⠹⣧⠀⠀⠀⠀⠀[/]
331
+ [#E2832B]⠀⠀⠀⠀⢰⡟⠀⠀⣀⣤⣤⣤⣀⠀⠀⠀⢻⡆⠀⠀⠀⠀[/]
332
+ [#E2832B]⠀⠀⣠⡾⠛⠁⣠⣾⠟⠉⠀⠉⠻⣷⣄⠀⠈⠛⢷⣄⠀⠀[/]
333
+ [#C75B1D]⠀⣼⠟⠀⢀⣾⠟⠁⠀⠀⠀⠀⠀⠈⠻⣷⡀⠀⠻⣧⠀[/]
334
+ [#C75B1D]⢸⡟⠀⠀⣿⡟⠀⠀⠀🔥⠀⠀⠀⠀⢻⣿⠀⠀⢻⡇[/]
335
+ [#7A3511]⠀⠻⣦⡀⠘⢿⣧⡀⠀⠀⠀⠀⠀⢀⣼⡿⠃⢀⣴⠟⠀[/]
336
+ [#7A3511]⠀⠀⠈⠻⣦⣀⠙⢿⣷⣤⣤⣤⣾⡿⠋⣀⣴⠟⠁⠀⠀[/]
337
+ [#C75B1D]⠀⠀⠀⠀⠈⠙⠛⠶⠤⠭⠭⠤⠶⠛⠋⠁⠀⠀⠀⠀[/]
338
+ [#F29C38]⠀⠀⠀⠀⠀⠀⠀⠀⣰⡿⢿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
339
+ [#F29C38]⠀⠀⠀⠀⠀⠀⠀⣼⡟⠀⠀⢻⣧⠀⠀⠀⠀⠀⠀⠀⠀[/]
340
+ [dim #7A3511]⠀⠀⠀⠀⠀⠀⠀tail flame lit⠀⠀⠀⠀⠀⠀⠀⠀[/]""",
341
+ }}
342
+
343
+ _active_skin: Optional[SkinConfig] = None
344
+ _active_skin_name: str = "default"
345
+ # Routed multiplex profiles: (name, skin) per home key. ``display.skin`` and ``<home>/skins/*.yaml``
346
+ # are per profile, and the relay display name / TUI skin payload are read under each profile's
347
+ # override — one module slot would be last-writer-wins across profiles. Unscoped keeps the module slot.
348
+ _active_skin_by_home: Dict[str, Tuple[str, SkinConfig]] = {}
349
+
350
+
351
+ def _routed_home_key() -> Optional[str]:
352
+ from hermes_constants import get_hermes_home_override, hermes_home_key
353
+ return None if get_hermes_home_override() is None else hermes_home_key()
354
+
355
+
356
+ def _profile_config() -> dict:
357
+ try:
358
+ from hermes_cli.config import load_config_readonly
359
+ return load_config_readonly() or {}
360
+ except Exception:
361
+ return {}
362
+
363
+
364
+ def _skins_dir() -> Path:
365
+ return get_hermes_home() / "skins"
366
+
367
+
368
+ def _load_skin_from_yaml(path: Path) -> Optional[Dict[str, Any]]:
369
+ """Load a skin definition from a YAML file; None on any failure."""
370
+ try:
371
+ import yaml
372
+ with open(path, "r", encoding="utf-8") as f:
373
+ data = yaml.safe_load(f)
374
+ if isinstance(data, dict) and "name" in data:
375
+ return data
376
+ except Exception as e:
377
+ logger.debug("Failed to load skin from %s: %s", path, e)
378
+ return None
379
+
380
+
381
+ def _build_skin_config(data: Dict[str, Any]) -> SkinConfig:
382
+ """Build a SkinConfig from a raw dict (built-in or loaded from YAML)."""
383
+ default = _BUILTIN_SKINS["default"]
384
+ skin_name = str(data.get("name", "unknown"))
385
+
386
+ def section(key: str) -> Dict[str, Any]:
387
+ value = data.get(key)
388
+ if isinstance(value, dict):
389
+ return value
390
+ if value is not None:
391
+ logger.warning("Skin '%s' has invalid '%s' section type (%s); ignoring section",
392
+ skin_name, key, type(value).__name__)
393
+ return {}
394
+
395
+ def merged(key: str) -> Dict[str, Any]:
396
+ return {**default.get(key, {}), **section(key)}
397
+ # Paired palettes are NOT merged over the default skin's blocks: an empty block means
398
+ # "no hand-tuned variant for that polarity" and consumers (the TUI) fall back to `colors`
399
+ # + automatic adaptation, which beats the default's gold light palette under a crimson skin.
400
+ return SkinConfig(
401
+ name=skin_name, description=data.get("description", ""), colors=merged("colors"),
402
+ light_colors=section("light_colors"), dark_colors=section("dark_colors"),
403
+ spinner=merged("spinner"), branding=merged("branding"),
404
+ tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "┊")),
405
+ tool_emojis=section("tool_emojis"), banner_logo=data.get("banner_logo", ""),
406
+ banner_hero=data.get("banner_hero", ""))
407
+
408
+
409
+ def list_skins() -> List[Dict[str, str]]:
410
+ """List all available skins (built-in + user-installed); user skins never shadow built-ins."""
411
+ result = [{"name": name, "description": data.get("description", ""), "source": "builtin"}
412
+ for name, data in _BUILTIN_SKINS.items()]
413
+ skins_path = _skins_dir()
414
+ for f in sorted(skins_path.glob("*.yaml")) if skins_path.is_dir() else ():
415
+ data = _load_skin_from_yaml(f)
416
+ if data and not any(s["name"] == data.get("name", f.stem) for s in result):
417
+ result.append({"name": data.get("name", f.stem), "description": data.get("description", ""),
418
+ "source": "user"})
419
+ return result
420
+
421
+
422
+ def load_skin(name: str) -> SkinConfig:
423
+ """Load a skin by name: user skins first, then built-in, then default."""
424
+ user_file = _skins_dir() / f"{name}.yaml"
425
+ data = _load_skin_from_yaml(user_file) if user_file.is_file() else None
426
+ if not data and name not in _BUILTIN_SKINS:
427
+ logger.warning("Skin '%s' not found, using default", name)
428
+ return _build_skin_config(data or _BUILTIN_SKINS.get(name) or _BUILTIN_SKINS["default"])
429
+
430
+
431
+ def get_active_skin() -> SkinConfig:
432
+ """Currently active skin config (cached)."""
433
+ global _active_skin
434
+ home_key = _routed_home_key()
435
+ if home_key is not None:
436
+ entry = _active_skin_by_home.get(home_key)
437
+ if entry is None:
438
+ # Cold routed profile: its own ``display.skin`` (nobody ran init_skin_from_config for it).
439
+ init_skin_from_config(_profile_config())
440
+ entry = _active_skin_by_home[home_key]
441
+ return entry[1]
442
+ if _active_skin is None:
443
+ _active_skin = load_skin(_active_skin_name)
444
+ return _active_skin
445
+
446
+
447
+ def set_active_skin(name: str) -> SkinConfig:
448
+ """Switch the active skin. Returns the new SkinConfig."""
449
+ global _active_skin, _active_skin_name
450
+ skin = load_skin(name)
451
+ home_key = _routed_home_key()
452
+ if home_key is not None:
453
+ _active_skin_by_home[home_key] = (name, skin)
454
+ return skin
455
+ _active_skin_name = name
456
+ _active_skin = skin
457
+ return _active_skin
458
+
459
+
460
+ def get_active_skin_name() -> str:
461
+ home_key = _routed_home_key()
462
+ if home_key is not None:
463
+ entry = _active_skin_by_home.get(home_key)
464
+ return entry[0] if entry else "default"
465
+ return _active_skin_name
466
+
467
+
468
+ def init_skin_from_config(config: dict) -> None:
469
+ """Initialize the active skin from CLI config at startup."""
470
+ display = config.get("display") or {}
471
+ skin_name = display.get("skin", "default") if isinstance(display, dict) else "default"
472
+ set_active_skin(skin_name.strip() if isinstance(skin_name, str) and skin_name.strip() else "default")
473
+
474
+
475
+ def _active_branding(key: str, fallback: str) -> str:
476
+ try:
477
+ return get_active_skin().get_branding(key, fallback)
478
+ except Exception:
479
+ return fallback
480
+
481
+
482
+ def get_active_prompt_symbol(fallback: str = "❯") -> str:
483
+ """Interactive prompt symbol (skins store a bare token) plus a single trailing space."""
484
+ cleaned = (_active_branding("prompt_symbol", fallback) or fallback).strip()
485
+ return f"{cleaned or fallback.strip()} "
486
+
487
+
488
+ def get_active_help_header(fallback: str = "(^_^)? Available Commands") -> str:
489
+ return _active_branding("help_header", fallback)
490
+
491
+
492
+ def get_active_goodbye(fallback: str = "Goodbye! ☤") -> str:
493
+ return _active_branding("goodbye", fallback)
494
+
495
+
496
+ # Palette resolution order for prompt_toolkit styles: (name, skin color key, fallback). A
497
+ # fallback starting with "@" names an earlier entry (so a missing key inherits its remapped value).
498
+ _STYLE_PALETTE = (
499
+ ("prompt", "prompt", ""), ("input_rule", "input_rule", "#CD7F32"),
500
+ ("title", "banner_title", "#FFD700"), ("text", "banner_text", "#FFF8DC"),
501
+ ("dim", "banner_dim", "#555555"), ("label", "ui_label", "@title"), ("warn", "ui_warn", "#FF8C00"),
502
+ ("error", "ui_error", "#FF6B6B"), ("status_bg", "status_bar_bg", "#1a1a2e"),
503
+ ("status_text", "status_bar_text", "@text"), ("status_strong", "status_bar_strong", "@title"),
504
+ ("status_dim", "status_bar_dim", "@dim"), ("ok", "ui_ok", "#8FBC8F"),
505
+ ("status_good", "status_bar_good", "@ok"), ("status_warn", "status_bar_warn", "@warn"),
506
+ ("accent", "banner_accent", "@warn"), ("status_bad", "status_bar_bad", "@accent"),
507
+ ("status_critical", "status_bar_critical", "@error"), ("voice_bg", "voice_status_bg", "@status_bg"),
508
+ ("menu_bg", "completion_menu_bg", "#1a1a2e"), ("menu_current_bg", "completion_menu_current_bg", "#333355"),
509
+ ("menu_meta_bg", "completion_menu_meta_bg", "@menu_bg"),
510
+ ("menu_meta_current_bg", "completion_menu_meta_current_bg", "@menu_current_bg"))
511
+
512
+ # prompt_toolkit style class -> format template over the resolved palette names.
513
+ _STYLE_TEMPLATES = {
514
+ "input-area": "", # terminal default fg/bg — `prompt` styles the symbol, NOT typed text
515
+ "placeholder": "{dim} italic", "prompt": "{prompt}", "prompt-working": "{dim} italic",
516
+ "hint": "{dim} italic",
517
+ "status-bar": "bg:{status_bg} {status_text}", "status-bar-strong": "bg:{status_bg} {status_strong} bold",
518
+ "status-bar-session-title": "bg:{badge_bg} {badge_fg} bold",
519
+ "status-bar-dim": "bg:{status_bg} {status_dim}", "status-bar-good": "bg:{status_bg} {status_good} bold",
520
+ "status-bar-warn": "bg:{status_bg} {status_warn} bold", "status-bar-bad": "bg:{status_bg} {status_bad} bold",
521
+ "status-bar-critical": "bg:{status_bg} {status_critical} bold",
522
+ "subagent-dock": "bg:{status_bg} {status_text}",
523
+ "subagent-dock.heading": "bg:{status_bg} {status_strong} bold",
524
+ "subagent-dock.selected": "bg:{menu_current_bg} {text} bold",
525
+ "input-rule": "{input_rule}", "image-badge": "{label} bold",
526
+ "completion-menu": "bg:{menu_bg} {text}", "completion-menu.completion": "bg:{menu_bg} {text}",
527
+ "completion-menu.completion.current": "bg:{menu_current_bg} {title}",
528
+ "completion-menu.meta.completion": "bg:{menu_meta_bg} {dim}",
529
+ "completion-menu.meta.completion.current": "bg:{menu_meta_current_bg} {label}",
530
+ "clarify-border": "{input_rule}", "clarify-title": "{title} bold", "clarify-question": "{text} bold",
531
+ "clarify-choice": "{dim}", "clarify-selected": "{title} bold", "clarify-active-other": "{title} italic",
532
+ "clarify-countdown": "{input_rule}",
533
+ "sudo-prompt": "{error} bold", "sudo-border": "{input_rule}", "sudo-title": "{error} bold",
534
+ "sudo-text": "{text}",
535
+ "approval-border": "{input_rule}", "approval-title": "{warn} bold", "approval-desc": "{text} bold",
536
+ "approval-cmd": "{dim} italic", "approval-choice": "{dim}", "approval-selected": "{title} bold",
537
+ "voice-status": "bg:{voice_bg} {label}", "voice-status-recording": "bg:{voice_bg} {error} bold"}
538
+
539
+
540
+ def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
541
+ """Return prompt_toolkit style overrides derived from the active skin."""
542
+ try:
543
+ skin = get_active_skin()
544
+ except Exception:
545
+ return {}
546
+ # `prompt` is unset by default so typed text inherits the terminal's foreground (readable
547
+ # on light and dark schemes); skins opt into a colored prompt symbol via `prompt` in YAML.
548
+ # Every read goes through skin.get_color (cli.py wraps it for light-mode remapping).
549
+ palette: Dict[str, str] = {}
550
+ for name, key, fallback in _STYLE_PALETTE:
551
+ palette[name] = skin.get_color(key, palette[fallback[1:]] if fallback.startswith("@") else fallback)
552
+ # This badge paints both sides; foreground-only light remapping destroys its contrast.
553
+ palette["badge_bg"] = skin.colors.get(
554
+ "status_bar_strong", skin.colors.get("banner_title", "#FFD700"))
555
+ palette["badge_fg"] = skin.colors.get("status_bar_bg", "#1a1a2e")
556
+ return {cls: tpl.format(**palette) for cls, tpl in _STYLE_TEMPLATES.items()}
hermes_cli/slack_cli.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``hermes slack manifest`` — generate the Slack app manifest JSON that registers every gateway
2
+ command as a native Slack slash (``/btw``, ``/stop``, ``/model``, …)."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ SLACK_LONG_DESCRIPTION_MIN_CHARACTERS = 175
10
+ SLACK_LONG_DESCRIPTION_MAX_CHARACTERS = 4000
11
+
12
+
13
+ def _build_full_manifest(
14
+ bot_name: str, bot_description: str, include_assistant: bool = True,
15
+ messaging_experience: str | None = None, long_description: str | None = None) -> dict:
16
+ """Build a full Slack manifest: display info + slash list from ``COMMAND_REGISTRY``.
17
+
18
+ Other sections (OAuth scopes, socket mode) are sensible Hermes defaults, tweakable in the Slack
19
+ UI after pasting.
20
+ """
21
+ from hermes_cli.commands_platforms import slack_app_manifest
22
+ if messaging_experience is None:
23
+ messaging_experience = "assistant" if include_assistant else "none"
24
+ messaging_experience = str(messaging_experience).strip().lower()
25
+ if messaging_experience not in {"assistant", "agent", "none"}:
26
+ raise ValueError("messaging_experience must be one of: assistant, agent, none")
27
+
28
+ features = {
29
+ "app_home": {
30
+ "home_tab_enabled": False,
31
+ "messages_tab_enabled": True,
32
+ "messages_tab_read_only_enabled": False},
33
+ "bot_user": {"display_name": bot_name[:80], "always_online": True},
34
+ "slash_commands": slack_app_manifest()["features"]["slash_commands"]}
35
+
36
+ bot_scopes = [
37
+ "app_mentions:read", "channels:history", "channels:read", "chat:write", "commands",
38
+ "files:read", "files:write", "groups:history", "groups:read", "im:history", "im:read",
39
+ "im:write", "mpim:history", "mpim:read", "reactions:read", "users:read"]
40
+ bot_events = [
41
+ "app_mention", "message.channels", "message.groups", "message.im", "message.mpim",
42
+ "reaction_added", "reaction_removed"]
43
+
44
+ if messaging_experience == "assistant":
45
+ features["assistant_view"] = {
46
+ "assistant_description": "Chat with Hermes in threads and DMs."}
47
+ bot_scopes.append("assistant:write")
48
+ bot_events.extend(["assistant_thread_context_changed", "assistant_thread_started"])
49
+ elif messaging_experience == "agent":
50
+ features["agent_view"] = {"agent_description": "Chat with Hermes in Slack Messages."}
51
+ bot_scopes.append("assistant:write")
52
+ # Slack includes current viewing context in Agent DM events only after this subscription
53
+ # is enabled; the adapter uses it to preserve the referred channel across the agent turn.
54
+ bot_events.extend(["app_context_changed", "app_home_opened"])
55
+
56
+ bot_scopes.sort()
57
+ bot_events.sort()
58
+
59
+ display_information = {
60
+ "name": bot_name[:35],
61
+ "description": (bot_description or "Your Hermes agent on Slack")[:140],
62
+ "background_color": "#1a1a2e"}
63
+ if long_description is not None:
64
+ display_information["long_description"] = long_description
65
+
66
+ return {
67
+ "_metadata": {"major_version": 1, "minor_version": 1},
68
+ "display_information": display_information,
69
+ "features": features,
70
+ "oauth_config": {"scopes": {"bot": bot_scopes}},
71
+ "settings": {
72
+ "event_subscriptions": {"bot_events": bot_events},
73
+ "interactivity": {"is_enabled": True},
74
+ "org_deploy_enabled": False,
75
+ "socket_mode_enabled": True,
76
+ "token_rotation_enabled": False}}
77
+
78
+
79
+ def slack_manifest_command(args) -> int:
80
+ """Print or write a Slack app manifest JSON (flags documented in ``hermes_cli/main.py``)."""
81
+ name = getattr(args, "name", None) or "Hermes"
82
+ description = getattr(args, "description", None) or "Your Hermes agent on Slack"
83
+ long_description = getattr(args, "long_description", None)
84
+ long_description_file = getattr(args, "long_description_file", None)
85
+ slashes_only = getattr(args, "slashes_only", False)
86
+
87
+ def fail(msg: str) -> int:
88
+ print(f"hermes slack manifest: {msg}", file=sys.stderr)
89
+ return 2
90
+
91
+ if slashes_only and (long_description is not None or long_description_file is not None):
92
+ return fail("long description options cannot be used with --slashes-only")
93
+ if long_description_file is not None:
94
+ source_arg = str(long_description_file)
95
+ try:
96
+ with Path(source_arg).expanduser().open("r", encoding="utf-8", newline="") as handle:
97
+ long_description = handle.read()
98
+ except (OSError, UnicodeError, RuntimeError) as exc:
99
+ return fail(f"cannot read long description from {source_arg}: {exc}")
100
+ if long_description is not None:
101
+ n = len(long_description)
102
+ if n < SLACK_LONG_DESCRIPTION_MIN_CHARACTERS:
103
+ return fail(f"long description must be at least "
104
+ f"{SLACK_LONG_DESCRIPTION_MIN_CHARACTERS} characters (got {n})")
105
+ if n > SLACK_LONG_DESCRIPTION_MAX_CHARACTERS:
106
+ return fail(f"long description must be at most "
107
+ f"{SLACK_LONG_DESCRIPTION_MAX_CHARACTERS} characters (got {n})")
108
+ if getattr(args, "agent_view", False):
109
+ messaging_experience = "agent"
110
+ elif getattr(args, "no_assistant", False):
111
+ messaging_experience = "none"
112
+ else:
113
+ messaging_experience = "assistant"
114
+
115
+ if slashes_only:
116
+ from hermes_cli.commands_platforms import slack_app_manifest
117
+ manifest = slack_app_manifest()["features"]["slash_commands"]
118
+ else:
119
+ manifest = _build_full_manifest(
120
+ name, description, messaging_experience=messaging_experience,
121
+ long_description=long_description)
122
+ payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
123
+
124
+ write_target = getattr(args, "write", None)
125
+ if write_target is None:
126
+ sys.stdout.write(payload)
127
+ return 0
128
+ if isinstance(write_target, bool) and write_target: # bare --write → default location
129
+ from hermes_constants import get_hermes_home
130
+ target = Path(get_hermes_home()) / "slack-manifest.json"
131
+ else:
132
+ target = Path(write_target).expanduser()
133
+ target.parent.mkdir(parents=True, exist_ok=True)
134
+ target.write_text(payload, encoding="utf-8")
135
+ print(f"Slack manifest written to: {target}", file=sys.stderr)
136
+ print(
137
+ "\nNext steps:\n"
138
+ " 1. Open https://api.slack.com/apps and pick your Hermes app\n"
139
+ " (or create a new one: Create New App → From an app manifest).\n"
140
+ f" 2. Features → App Manifest → paste the contents of\n"
141
+ f" {target}\n"
142
+ " 3. Save; Slack will prompt to reinstall the app if scopes or\n"
143
+ " slash commands changed.\n"
144
+ " 4. Make sure Socket Mode is enabled and you have a bot token\n"
145
+ " (xoxb-...) and app token (xapp-...) configured via\n"
146
+ " `hermes setup`.\n", file=sys.stderr)
147
+ return 0
148
+
149
+
150
+ # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
151
+ # Names external plugins imported from this module before the Sep 2026 decomposition.
152
+ # Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
153
+ # The whole block is removed by reverting the commit that added it.
154
+ import os # noqa: F401,E402
155
+ # ---- END PLUGIN-COMPAT ----
hermes_cli/status.py ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Status command for hermes CLI."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import time
7
+ import importlib.util
8
+ from pathlib import Path
9
+ from types import SimpleNamespace
10
+
11
+ PROJECT_ROOT = Path(__file__).parent.parent.resolve()
12
+
13
+ from hermes_cli.auth import AuthError, resolve_provider
14
+ from hermes_cli.colors import Colors, color
15
+ from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config
16
+ from hermes_cli.models import provider_label
17
+ from hermes_cli.runtime_provider import resolve_requested_provider
18
+ from hermes_cli.vercel_auth import describe_vercel_auth
19
+ from hermes_cli.status_auth import ( # renderers wired into _SECTIONS below
20
+ _render_api_keys, _render_apikey_providers, _render_auth_providers, _render_nous_gateway)
21
+ from hermes_constants import OPENROUTER_MODELS_URL
22
+ from hermes_constants import is_termux as _is_termux
23
+
24
+
25
+ def check_mark(ok: bool) -> str:
26
+ return color("✓", Colors.GREEN) if ok else color("✗", Colors.RED)
27
+
28
+
29
+ def _section(title: str) -> None:
30
+ """Print a blank line followed by a bold cyan ``◆`` section heading."""
31
+ print()
32
+ print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD))
33
+
34
+
35
+ def _row(name: str, ok: bool, text: str, width: int = 12, sep: str = " ") -> None:
36
+ """Print one ``name ✓/✗ text`` status row."""
37
+ print(f" {name:<{width}}{sep}{check_mark(ok)} {text}")
38
+
39
+
40
+ def _detail(label: str, value) -> None:
41
+ """Print an indented ``label: value`` detail line under a status row."""
42
+ _kv(label, value, " ", 12)
43
+
44
+
45
+ def _kv(label: str, value, indent: str = " ", width: int = 14) -> None:
46
+ """Print a `` Label: value`` line (label padded to the 14-col status layout)."""
47
+ print(f"{indent}{label:<{width}}{value}")
48
+
49
+
50
+ def _kv_flag(label: str, ok, on: str, off: str) -> None:
51
+ """``_kv`` with a ✓/✗ mark followed by ``on`` or ``off`` text."""
52
+ _kv(label, f"{check_mark(bool(ok))} {on if ok else off}")
53
+
54
+
55
+ def _configured(ok) -> str:
56
+ return "configured" if ok else "not configured"
57
+
58
+
59
+ def _first_env_value(names) -> str:
60
+ """Return the first non-empty env value among ``names`` (a str or tuple of names)."""
61
+ return next((v for v in (get_env_value(n) or "" for n in ((names,) if isinstance(names, str) else names)) if v), "")
62
+
63
+
64
+ def _configured_model_label(config: dict) -> str:
65
+ """Return the configured default model from config.yaml."""
66
+ model_cfg = config.get("model")
67
+ if isinstance(model_cfg, dict):
68
+ model_cfg = model_cfg.get("default") or model_cfg.get("name") or ""
69
+ return (model_cfg.strip() if isinstance(model_cfg, str) else "") or "(not set)"
70
+
71
+
72
+ def _effective_provider_label() -> str:
73
+ """Return the provider label matching current CLI runtime resolution."""
74
+ requested = resolve_requested_provider()
75
+ try:
76
+ effective = resolve_provider(requested)
77
+ except AuthError:
78
+ effective = requested or "auto"
79
+
80
+ if effective == "openrouter":
81
+ # A custom endpoint may live in config.yaml (model.base_url, the canonical location) or
82
+ # the legacy OPENAI_BASE_URL env var; either way labeling it "OpenRouter" is misleading.
83
+ try:
84
+ model_cfg = load_config().get("model")
85
+ except Exception:
86
+ model_cfg = None
87
+ config_base_url = (model_cfg.get("base_url") or "").strip() if isinstance(model_cfg, dict) else ""
88
+ if config_base_url or get_env_value("OPENAI_BASE_URL"):
89
+ effective = "custom"
90
+ return provider_label(effective)
91
+
92
+
93
+ def _estop_status_line():
94
+ """One-line pause banner for `hermes status`, or None when not paused."""
95
+ try:
96
+ from agent.estop import get_state
97
+ except ImportError:
98
+ return None
99
+ state = get_state()
100
+ if state is None:
101
+ return None
102
+ reason = state.get("reason")
103
+ return f"⏸️ PAUSED (global emergency stop{f' — reason: {reason}' if reason else ''}; `hermes resume` to lift)"
104
+
105
+
106
+ # --- Data tables driving the per-section renderers -------------------------
107
+
108
+ # Simple env-driven terminal backends: (label, env var, default, empty-counts-as-unset).
109
+ _TERMINAL_ENV_ROWS = {
110
+ "ssh": (("SSH Host:", "TERMINAL_SSH_HOST", "(not set)", True), ("SSH User:", "TERMINAL_SSH_USER", "(not set)", True)),
111
+ "docker": (("Docker Image:", "TERMINAL_DOCKER_IMAGE", "python:3.11-slim", False),),
112
+ "daytona": (("Daytona Image:", "TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20", False),),
113
+ }
114
+
115
+ _PLATFORMS = { # name -> (token env var, home-channel env var or None)
116
+ "Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"),
117
+ "Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"), "WhatsApp": ("WHATSAPP_ENABLED", None),
118
+ "Signal": ("SIGNAL_HTTP_URL", "SIGNAL_HOME_CHANNEL"),
119
+ "Slack": ("SLACK_BOT_TOKEN", None), "Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"),
120
+ "SMS": ("TWILIO_ACCOUNT_SID", "SMS_HOME_CHANNEL"), "DingTalk": ("DINGTALK_CLIENT_ID", None),
121
+ "Feishu": ("FEISHU_APP_ID", "FEISHU_HOME_CHANNEL"), "WeCom": ("WECOM_BOT_ID", "WECOM_HOME_CHANNEL"),
122
+ "WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None), "Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"),
123
+ "BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"), "QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
124
+ "Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL")}
125
+
126
+ # Gateway manager label when the runtime snapshot is unavailable, keyed by platform.
127
+ _GATEWAY_FALLBACK = {"termux": ("unknown", "Termux / manual process"), "linux": ("unknown", "systemd/manual"),
128
+ "darwin": ("unknown", "launchd")}
129
+
130
+
131
+ def _banner(lines, *styles) -> None:
132
+ """Blank line, then each line in ``styles``."""
133
+ print()
134
+ for line in lines:
135
+ print(color(line, *styles))
136
+
137
+
138
+ def _render_header(ctx):
139
+ _banner(("┌─────────────────────────────────────────────────────────┐",
140
+ "│ ☤ Hermes Agent Status │",
141
+ "└─────────────────────────────────────────────────────────┘"), Colors.CYAN)
142
+ paused = _estop_status_line()
143
+ if paused:
144
+ _banner((paused,), Colors.YELLOW, Colors.BOLD)
145
+
146
+
147
+ def _render_environment(ctx):
148
+ _section("Environment")
149
+ _kv("Project:", PROJECT_ROOT)
150
+ _kv("Python:", sys.version.split()[0])
151
+ _kv_flag(".env file:", get_env_path().exists(), "exists", "not found")
152
+ try:
153
+ ctx.config = load_config()
154
+ except Exception:
155
+ ctx.config = {}
156
+ _kv("Model:", _configured_model_label(ctx.config))
157
+ _kv("Provider:", _effective_provider_label())
158
+
159
+
160
+ def _render_terminal(ctx):
161
+ _section("Terminal Backend")
162
+ terminal_cfg = ctx.config.get("terminal", {}) if isinstance(ctx.config.get("terminal"), dict) else {}
163
+ terminal_env = os.getenv("TERMINAL_ENV", "") or terminal_cfg.get("backend", "local")
164
+ _kv("Backend:", terminal_env)
165
+ if terminal_env in _TERMINAL_ENV_ROWS:
166
+ for label, var, default, empty_is_unset in _TERMINAL_ENV_ROWS[terminal_env]:
167
+ value = (os.getenv(var, "") or default) if empty_is_unset else os.getenv(var, default)
168
+ print(f" {label:<13} {value}")
169
+ elif terminal_env == "vercel_sandbox":
170
+ persist = os.getenv("TERMINAL_CONTAINER_PERSISTENT")
171
+ persist_enabled = (bool(terminal_cfg.get("container_persistent", True)) if persist is None
172
+ else persist.lower() in {"1", "true", "yes", "on"})
173
+ auth_status = describe_vercel_auth()
174
+ _kv("Runtime:", os.getenv('TERMINAL_VERCEL_RUNTIME') or terminal_cfg.get('vercel_runtime') or 'node24')
175
+ _kv_flag("SDK:", importlib.util.find_spec("vercel") is not None, "installed",
176
+ "missing (install: pip install 'hermes-agent[vercel]')")
177
+ _kv("Auth:", f"{check_mark(auth_status.ok)} {auth_status.label}")
178
+ for line in auth_status.detail_lines:
179
+ _kv("Auth detail:", line)
180
+ _kv("Persistence:", 'snapshot filesystem' if persist_enabled else 'ephemeral filesystem')
181
+ _kv("Processes:", "live processes do not survive cleanup, snapshots, or sandbox recreation")
182
+ else:
183
+ # Plugin-registered terminal backends: show availability via the provider's doctor rows
184
+ # (fail-soft — never break `hermes status`).
185
+ try:
186
+ from hermes_cli.plugins import discover_plugins
187
+ discover_plugins()
188
+ from agent.terminal_env_registry import get_provider
189
+ provider = get_provider(terminal_env)
190
+ if provider is not None:
191
+ for ok, label, text in provider.doctor_checks():
192
+ print(f" {label}: {check_mark(bool(ok))} {text}")
193
+ except Exception:
194
+ pass
195
+ _kv_flag("Sudo:", os.getenv("SUDO_PASSWORD", ""), "enabled", "disabled")
196
+
197
+
198
+ def _render_platforms(ctx):
199
+ _section("Messaging Platforms")
200
+ for name, (token_var, home_var) in _PLATFORMS.items():
201
+ has_token = bool(os.getenv(token_var, ""))
202
+ home_channel = os.getenv(home_var, "") if home_var else ""
203
+ _row(name, has_token, _configured(has_token) + (f" (home: {home_channel})" if home_channel else ""))
204
+
205
+ try: # Plugin-registered platforms
206
+ from gateway.platform_registry import platform_registry
207
+ for entry in platform_registry.plugin_entries():
208
+ # Per-entry guard: one raising probe must not abort the listing of every remaining
209
+ # plugin platform (matches the other check_fn sites).
210
+ try:
211
+ configured = bool(entry.check_fn())
212
+ except Exception:
213
+ configured = False
214
+ _row(entry.label, configured, f"{_configured(configured)} (plugin)")
215
+ except Exception:
216
+ pass
217
+
218
+
219
+ def _render_gateway(ctx):
220
+ _section("Gateway Service")
221
+ try:
222
+ from hermes_cli.gateway import (
223
+ get_gateway_runtime_snapshot, _format_gateway_pids, named_profile_served_by_running_multiplexer)
224
+ from hermes_cli.gateway_multiplex_served import multiplexer_served_secondaries
225
+ snapshot = get_gateway_runtime_snapshot()
226
+ # A satellite profile has no gateway.pid of its own; the default multiplexer is its live process.
227
+ if not snapshot.running and named_profile_served_by_running_multiplexer():
228
+ _kv_flag("Status:", True, "running (via the default-profile multiplexer)", "stopped")
229
+ _kv("Manage with:", "hermes gateway status # from the default profile")
230
+ return
231
+ _kv_flag("Status:", snapshot.running, "running", "stopped")
232
+ _kv("Manager:", snapshot.manager)
233
+ if snapshot.gateway_pids:
234
+ _kv("PID(s):", _format_gateway_pids(snapshot.gateway_pids))
235
+ if snapshot.running and (served := multiplexer_served_secondaries()):
236
+ _kv("Serves:", ", ".join(served))
237
+ from hermes_cli.gateway_multiplex_served import served_profile_ingress_urls
238
+ for name, per_platform in sorted(served_profile_ingress_urls().items()):
239
+ for platform, url in sorted(per_platform.items()):
240
+ _kv(f" {name}/{platform}:", url)
241
+ if snapshot.has_process_service_mismatch:
242
+ _kv("Service:", "installed but not managing the current running gateway")
243
+ elif _is_termux() and not snapshot.gateway_pids:
244
+ _kv("Start with:", "hermes gateway")
245
+ _kv("Note:", "Android may stop background jobs when Termux is suspended")
246
+ elif snapshot.service_installed and not snapshot.service_running:
247
+ _kv("Service:", "installed but stopped")
248
+ except Exception:
249
+ platform = "termux" if _is_termux() else "linux" if sys.platform.startswith("linux") else sys.platform
250
+ status_text, manager = _GATEWAY_FALLBACK.get(platform, ("N/A", "(not supported on this platform)"))
251
+ _kv("Status:", color(status_text, Colors.DIM))
252
+ _kv("Manager:", manager)
253
+
254
+
255
+ def _load_json(path: Path, encoding: str = "utf-8"):
256
+ with open(path, encoding=encoding) as f:
257
+ return json.load(f)
258
+
259
+
260
+ def _render_cron(ctx):
261
+ _section("Scheduled Jobs")
262
+ jobs_file = get_hermes_home() / "cron" / "jobs.json"
263
+ if not jobs_file.exists():
264
+ _kv("Jobs:", 0)
265
+ return
266
+ try:
267
+ # utf-8-sig: same dialect as cron/jobs.load_jobs — Windows editors may leave a UTF-8 BOM
268
+ # that plain utf-8 json.load rejects.
269
+ jobs = _load_json(jobs_file, "utf-8-sig").get("jobs", [])
270
+ _kv("Jobs:", f"{sum(1 for j in jobs if j.get('enabled', True))} active, {len(jobs)} total")
271
+ except Exception:
272
+ _kv("Jobs:", "(error reading jobs file)")
273
+
274
+
275
+ def _render_sessions(ctx):
276
+ _section("Sessions")
277
+ # Gateway session count: state.db is the source of truth; fall back to sessions.json for
278
+ # pre-migration installs.
279
+ try:
280
+ from hermes_state import SessionDB
281
+ db = SessionDB(read_only=True) # status only reads; never a writer beside a running gateway
282
+ try:
283
+ gateway_rows = db.list_gateway_sessions(active_only=True) or []
284
+ finally:
285
+ db.close()
286
+ except Exception:
287
+ gateway_rows = []
288
+
289
+ if gateway_rows:
290
+ _kv("Active:", f"{len(gateway_rows)} session(s)")
291
+ freshest = max((float(r.get("last_active") or 0) for r in gateway_rows), default=0.0)
292
+ if freshest > 0:
293
+ from hermes_cli.timefmt import relative_time
294
+ print(f" Last activity:{relative_time(freshest):>13}")
295
+ elif not (sessions_file := get_hermes_home() / "sessions" / "sessions.json").exists():
296
+ _kv("Active:", 0)
297
+ else:
298
+ try:
299
+ data = _load_json(sessions_file)
300
+ entries = [k for k in data if not str(k).startswith("_")] if isinstance(data, dict) else []
301
+ _kv("Active:", f"{len(entries)} session(s)")
302
+ except Exception:
303
+ _kv("Active:", "(error reading sessions file)")
304
+
305
+ # Slot usage, only when max_concurrent_sessions is set. The cap is shared across CLI,
306
+ # desktop/TUI and the messaging gateway, so the surface that gets rejected is rarely the one
307
+ # holding the slots — without this the only way to find out is reading
308
+ # runtime/active_sessions.json by hand.
309
+ try:
310
+ from hermes_cli.active_sessions import (
311
+ active_session_registry_snapshot, format_age, resolve_max_concurrent_sessions)
312
+ cap = resolve_max_concurrent_sessions(ctx.config)
313
+ except Exception:
314
+ cap = None
315
+ if cap:
316
+ try:
317
+ held = active_session_registry_snapshot()
318
+ except Exception:
319
+ held = []
320
+ _kv("Slots:", color(f"{len(held)}/{cap} in use", Colors.YELLOW if len(held) >= cap else Colors.GREEN))
321
+ now = time.time()
322
+ for entry in sorted(held, key=lambda e: e.get("started_at") or 0):
323
+ age = format_age(now - float(entry.get("started_at") or now))
324
+ print(f" {entry.get('surface') or 'unknown':<17} {entry.get('session_id') or '?':<24} {age}")
325
+
326
+
327
+ def _render_deep(ctx):
328
+ if not ctx.deep:
329
+ return
330
+ _section("Deep Checks")
331
+ openrouter_key = os.getenv("OPENROUTER_API_KEY", "")
332
+ if openrouter_key:
333
+ try:
334
+ import httpx
335
+ response = httpx.get(OPENROUTER_MODELS_URL, headers={"Authorization": f"Bearer {openrouter_key}"}, timeout=10)
336
+ _kv_flag("OpenRouter:", response.status_code == 200, "reachable", f"error ({response.status_code})")
337
+ except Exception as e:
338
+ _kv("OpenRouter:", f"{check_mark(False)} error: {e}")
339
+ try: # gateway port, informational: in use == gateway likely running
340
+ import socket
341
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
342
+ sock.settimeout(1)
343
+ port_in_use = sock.connect_ex(('127.0.0.1', 18789)) == 0
344
+ sock.close()
345
+ _kv("Port 18789:", 'in use' if port_in_use else 'available')
346
+ except OSError:
347
+ pass
348
+
349
+
350
+ def _render_footer(ctx):
351
+ _banner(("─" * 60, " Run 'hermes doctor' for detailed diagnostics", " Run 'hermes setup' to configure"),
352
+ Colors.DIM)
353
+ print()
354
+
355
+
356
+ # Print order of `hermes status`; each renderer takes the shared _StatusContext.
357
+ _SECTIONS = (
358
+ _render_header, _render_environment, _render_api_keys, _render_auth_providers, _render_nous_gateway,
359
+ _render_apikey_providers, _render_terminal, _render_platforms, _render_gateway, _render_cron,
360
+ _render_sessions, _render_deep, _render_footer)
361
+
362
+
363
+ def show_status(args):
364
+ """Show status of all Hermes Agent components."""
365
+ # Shared by section renderers: config, --deep, and the Nous login facts Auth Providers derives
366
+ # for the later Nous Tool Gateway section.
367
+ ctx = SimpleNamespace(deep=getattr(args, 'deep', False), config={}, nous_logged_in=False,
368
+ nous_inference_present=False, nous_account_info=None)
369
+ for render in _SECTIONS:
370
+ render(ctx)
371
+
372
+
373
+ # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
374
+ # Names external plugins imported from this module before the Sep 2026 decomposition.
375
+ # Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
376
+ # The whole block is removed by reverting the commit that added it.
377
+ import subprocess # noqa: F401,E402
378
+
379
+
380
+ _PLUGIN_COMPAT_LAZY = {
381
+ 'format_nous_portal_entitlement_message': ('hermes_cli.nous_account', 'format_nous_portal_entitlement_message'),
382
+ 'get_nous_portal_account_info': ('hermes_cli.nous_account', 'get_nous_portal_account_info'),
383
+ 'get_nous_subscription_features': ('hermes_cli.nous_subscription', 'get_nous_subscription_features'),
384
+ 'managed_nous_tools_enabled': ('tools.tool_backend_helpers', 'managed_nous_tools_enabled'),
385
+ 'redact_key': ('hermes_cli.config', 'redact_key'),
386
+ }
387
+
388
+
389
+ def __getattr__(name): # PEP 562 — lazy so no import cycles
390
+ target = _PLUGIN_COMPAT_LAZY.get(name)
391
+ if target is None:
392
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
393
+ import importlib
394
+ from hermes_cli.plugin_compat import warn_once
395
+ warn_once(__name__, name, *target)
396
+ return getattr(importlib.import_module(target[0]), target[1])
397
+ # ---- END PLUGIN-COMPAT ----
hermes_cli/update_cmd_git.py ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Git plumbing for ``hermes update``: fork/upstream sync, trampoline-git detection, lockfile/EOL churn cleanup, orphan rescue refs, parked-branch assessment, fetch-failure classification.
2
+
3
+ Split out of ``update_cmd.py``, which re-imports every name so ``hermes_cli.update_cmd.<name>``
4
+ still resolves/monkeypatches. Origin helpers are imported lazily per function (no cycle;
5
+ test patches on ``update_cmd`` stay effective).
6
+ """
7
+
8
+ import logging
9
+ from contextlib import suppress
10
+ import subprocess
11
+ import sys
12
+ from datetime import datetime, timedelta, timezone
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ logger = logging.getLogger("hermes_cli.update_cmd") # log-record parity with the origin module
17
+
18
+ _ORPHAN_RESCUE_REFS_TO_KEEP = 10
19
+ _ORPHAN_RESCUE_REF_MAX_AGE_DAYS = 30
20
+
21
+ _GIT_TEXT_KW = dict(capture_output=True, text=True, encoding="utf-8", errors="replace")
22
+ _BAR = "=" * 68
23
+ _UPSTREAM_ADD_CMD = "git remote add upstream https://github.com/NousResearch/hermes-agent.git"
24
+
25
+
26
+ def _git_ok(git_cmd, args, cwd, **kw) -> bool:
27
+ """True when ``_git_run`` exits 0; any exception counts as failure."""
28
+ return _git_stdout(git_cmd, args, cwd, **kw) is not None
29
+
30
+
31
+ def _git_stdout(git_cmd, args, cwd, **kw) -> Optional[str]:
32
+ """Stripped stdout of a successful ``_git_run``; ``None`` on non-zero exit or any exception."""
33
+ from hermes_cli.update_cmd import _git_run
34
+ with suppress(Exception):
35
+ result = _git_run(git_cmd, args, cwd, **kw)
36
+ if result.returncode == 0:
37
+ return result.stdout.strip()
38
+ return None
39
+
40
+
41
+ def _prune_orphan_rescue_refs(
42
+ git_cmd, cwd, branch, keep=_ORPHAN_RESCUE_REFS_TO_KEEP, max_age_days=_ORPHAN_RESCUE_REF_MAX_AGE_DAYS
43
+ ) -> None:
44
+ """Expire old orphan rescue refs (``refs/hermes-update-backups/orphan-<branch>-<ts>-<sha>``).
45
+
46
+ Each ref pins a possibly multi-GB snapshot against ``git gc``, so a repeatedly corrupted install would
47
+ grow ``.git`` unbounded. Keep the ``keep`` newest AND drop any older than ``max_age_days`` by the
48
+ ``YYYYMMDD-HHMMSS`` stamp (unparseable names left alone); names sort chronologically so
49
+ ``for-each-ref`` order is creation order. Best-effort, never blocks.
50
+
51
+ A rescue ref pins every object reachable from that commit against ``git gc`` — and in the incident shape
52
+ those objects include a full working-tree snapshot (the autostash orphan commit), which can be multi-GB
53
+ when the tree holds large stray files. See #87694.
54
+ """
55
+ from hermes_cli.update_cmd import _git_run
56
+ with suppress(OSError):
57
+ prefix = f"refs/hermes-update-backups/orphan-{branch}-"
58
+ list_result = _git_run(git_cmd, ["for-each-ref", "--format=%(refname)", "--sort=refname", f"{prefix}*"], cwd)
59
+ if list_result.returncode != 0:
60
+ return
61
+ refs = [line.strip() for line in list_result.stdout.splitlines() if line.strip()]
62
+ stale = set(refs[:-keep] if keep > 0 else refs)
63
+ if max_age_days > 0:
64
+ cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
65
+ for ref in refs:
66
+ with suppress(ValueError):
67
+ if datetime.strptime(ref[len(prefix):][:15], "%Y%m%d-%H%M%S").replace(tzinfo=timezone.utc) < cutoff:
68
+ stale.add(ref)
69
+ for ref in sorted(stale):
70
+ _git_run(git_cmd, ["update-ref", "-d", ref], cwd)
71
+
72
+
73
+ def _branch_head_label(git_cmd=None, cwd=None) -> str | None:
74
+ """``"<branch> @ <short-sha>"`` for the checkout (``detached`` when not on a branch), or None. Never raises.
75
+
76
+ Appended to summary lines so a checkout parked on a stale branch is visible."""
77
+ from hermes_cli.update_cmd import _m
78
+ try:
79
+ cmd = list(git_cmd) if git_cmd else ["git"]
80
+ root = cwd if cwd is not None else _m().PROJECT_ROOT
81
+
82
+ def _rev_parse(*args):
83
+ return subprocess.run(cmd + ["rev-parse", *args], cwd=root, **_GIT_TEXT_KW)
84
+
85
+ branch, sha = _rev_parse("--abbrev-ref", "HEAD"), _rev_parse("--short", "HEAD")
86
+ branch_name, sha_text = branch.stdout.strip(), sha.stdout.strip()
87
+ if branch.returncode != 0 or sha.returncode != 0 or not sha_text or not branch_name:
88
+ return None
89
+ return f"{'detached' if branch_name == 'HEAD' else branch_name} @ {sha_text}"
90
+ except Exception:
91
+ return None
92
+
93
+
94
+ def _branch_head_suffix(git_cmd=None, cwd=None) -> str:
95
+ """`` [<branch> @ <sha>]`` suffix for summary lines ("" when unknown)."""
96
+ label = _branch_head_label(git_cmd, cwd)
97
+ return f" [{label}]" if label else ""
98
+
99
+
100
+ def _assess_parked_branch_switch(git_cmd: list[str], cwd: Path, current_branch: str, target_branch: str) -> tuple[bool, str]:
101
+ """Decide whether a parked feature branch may be auto-switched back to the update target.
102
+
103
+ - (True, "") — tree clean and every parked commit is in ``origin/<target>`` (no ``git cherry +``).
104
+ - (True, "unmerged:<n>") — tree clean but commits not in target; switching is safe (checkout keeps
105
+ committed work) but caller must print a LOUD notice. Non-interactive callers (desktop, gateway
106
+ /update, cron) can't resolve a skip, so a clean checkout must reach target.
107
+ - (False, "disabled"|"dirty"|"unverifiable") — caller must NOT touch the branch. Dirty is the
108
+ genuinely unsafe case: uncommitted work riding an autostash across branches.
109
+ A config read failure must not disable the safety checks: fall through with the default."""
110
+ from hermes_cli.update_cmd import _git_run
111
+ try:
112
+ from hermes_cli.config import load_config
113
+ _update_cfg = (load_config() or {}).get("updates", {})
114
+ if isinstance(_update_cfg, dict) and not bool(_update_cfg.get("auto_switch_parked_branch", True)):
115
+ return False, "disabled"
116
+ except Exception as exc:
117
+ logger.debug("Could not read updates.auto_switch_parked_branch: %s", exc)
118
+ status = _git_run(git_cmd, ["status", "--porcelain"], cwd)
119
+ if status.returncode != 0:
120
+ return False, "unverifiable"
121
+ if status.stdout.strip():
122
+ return False, "dirty"
123
+ cherry = _git_run(git_cmd, ["cherry", f"origin/{target_branch}"], cwd)
124
+ if cherry.returncode != 0:
125
+ return False, "unverifiable"
126
+ unmerged = [line for line in cherry.stdout.splitlines() if line.startswith("+")]
127
+ return True, f"unmerged:{len(unmerged)}" if unmerged else ""
128
+
129
+
130
+ _PARKED_SKIP_WHY = {
131
+ "dirty": "the working tree has uncommitted changes",
132
+ "disabled": "updates.auto_switch_parked_branch is set to false in config.yaml",
133
+ }
134
+
135
+
136
+ def _print_parked_branch_skip_warning(git_cmd: list[str], cwd: Path, current_branch: str, target_branch: str, reason: str) -> None:
137
+ """LOUD block: why the update was skipped on a parked branch, behind-count, fix commands."""
138
+ behind = None
139
+ with suppress(Exception):
140
+ behind_text = _git_stdout(git_cmd, ["rev-list", f"HEAD..origin/{target_branch}", "--count"], cwd)
141
+ if behind_text:
142
+ behind = int(behind_text)
143
+ why = _PARKED_SKIP_WHY.get(reason, f"the branch state could not be verified against origin/{target_branch}")
144
+ print(f"\n{_BAR}\n⚠ CODE UPDATE SKIPPED — checkout is parked on '{current_branch}'")
145
+ print(f" Not auto-switching to {target_branch}: {why}.")
146
+ if behind is not None and behind > 0:
147
+ print(f" This checkout is {behind} commit(s) BEHIND origin/{target_branch} — the code you are running is stale.")
148
+ print(
149
+ f"\n To resolve, inspect the branch and switch back yourself:\n"
150
+ f" git -C {cwd} status\n"
151
+ f" git -C {cwd} checkout {target_branch} && hermes update\n"
152
+ f" (commit or stash your work on the branch first if you want to keep it)\n{_BAR}"
153
+ )
154
+
155
+
156
+ def _print_parked_branch_kept_notice(current_branch: str, target_branch: str, unmerged_count: str) -> None:
157
+ """LOUD notice when a clean parked branch with unmerged commits is auto-switched.
158
+
159
+ Non-interactive callers can't resolve a skip, so we proceed — but the unmerged work
160
+ (still safe on its branch) must be impossible to miss."""
161
+ print(
162
+ f"\n{_BAR}\n"
163
+ f"⚠ Checkout was parked on '{current_branch}' with "
164
+ f"{unmerged_count} commit(s) not merged into origin/{target_branch}.\n"
165
+ f" Switching to {target_branch} so the update can proceed — your "
166
+ f"commit(s) are safe on '{current_branch}'.\n\n"
167
+ f" To pick the work back up later:\n git checkout {current_branch}\n{_BAR}"
168
+ )
169
+
170
+
171
+ OFFICIAL_REPO_URLS = {
172
+ "https://github.com/NousResearch/hermes-agent.git",
173
+ "git@github.com:NousResearch/hermes-agent.git",
174
+ "https://github.com/NousResearch/hermes-agent",
175
+ "git@github.com:NousResearch/hermes-agent",
176
+ }
177
+ OFFICIAL_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
178
+ SKIP_UPSTREAM_PROMPT_FILE = ".skip_upstream_prompt"
179
+
180
+
181
+ def _get_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]:
182
+ """Get the URL of the origin remote, or None if not set."""
183
+ return _git_stdout(git_cmd, ["remote", "get-url", "origin"], cwd)
184
+
185
+
186
+ def _is_fork(origin_url: Optional[str]) -> bool:
187
+ """Check if the origin remote points to a fork (not the official repo)."""
188
+ if not origin_url:
189
+ return False
190
+
191
+ def _norm(url: str) -> str:
192
+ url = url.rstrip("/")
193
+ return url[:-4] if url.endswith(".git") else url
194
+
195
+ return _norm(origin_url) not in {_norm(official) for official in OFFICIAL_REPO_URLS}
196
+
197
+
198
+ def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
199
+ """Check if an 'upstream' remote already exists."""
200
+ return _git_ok(git_cmd, ["remote", "get-url", "upstream"], cwd)
201
+
202
+
203
+ def _add_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
204
+ """Add the official repo as the 'upstream' remote. Returns True on success."""
205
+ return _git_ok(git_cmd, ["remote", "add", "upstream", OFFICIAL_REPO_URL], cwd)
206
+
207
+
208
+ def _count_commits_between(git_cmd: list[str], cwd: Path, base: str, head: str) -> int:
209
+ """Count commits on `head` that are not on `base`. Returns -1 on error."""
210
+ with suppress(Exception):
211
+ count = _git_stdout(git_cmd, ["rev-list", "--count", f"{base}..{head}"], cwd)
212
+ if count is not None:
213
+ return int(count)
214
+ return -1
215
+
216
+
217
+ def _should_skip_upstream_prompt() -> bool:
218
+ """Check if user previously declined to add upstream."""
219
+ from hermes_constants import get_hermes_home
220
+ return (get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).exists()
221
+
222
+
223
+ def _mark_skip_upstream_prompt():
224
+ """Create marker file to skip future upstream prompts."""
225
+ with suppress(Exception):
226
+ from hermes_constants import get_hermes_home
227
+ (get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).touch()
228
+
229
+
230
+ def _sync_fork_with_upstream(git_cmd: list[str], cwd: Path) -> bool:
231
+ """Push updated main to origin (sync fork); True on success."""
232
+ return _git_ok(git_cmd, ["push", "origin", "main", "--force-with-lease"], cwd, network=True)
233
+
234
+
235
+ def _offer_upstream_remote(git_cmd: list[str], cwd: Path, *, assume_yes: bool, input_fn) -> bool:
236
+ """Prompt to add ``upstream`` and add it; False when the user declined, the run is non-interactive, or add failed.
237
+
238
+ ``--yes`` means "don't block", not "mutate my remotes", so a non-interactive skip is NOT persisted."""
239
+ from hermes_cli.update_cmd import _add_upstream_remote, _mark_skip_upstream_prompt
240
+ print(
241
+ "\nℹ Your fork is not tracking the official Hermes repository.\n"
242
+ " This means you may miss updates from NousResearch/hermes-agent.\n"
243
+ )
244
+ if assume_yes or (input_fn is None and not (sys.stdin.isatty() and sys.stdout.isatty())):
245
+ print(f" Skipping upstream setup (non-interactive run).\n Add it later with: {_UPSTREAM_ADD_CMD}")
246
+ return False
247
+ if input_fn is not None:
248
+ response = input_fn("Add official repo as 'upstream' remote? [y/N]", "n").strip().lower()
249
+ else:
250
+ try:
251
+ response = input("Add official repo as 'upstream' remote? [Y/n]: ").strip().lower()
252
+ except (EOFError, KeyboardInterrupt, UnicodeDecodeError):
253
+ print()
254
+ response = "n"
255
+ if response not in {"", "y", "yes"}:
256
+ print(f" Skipped. Run '{_UPSTREAM_ADD_CMD}' to add later.")
257
+ _mark_skip_upstream_prompt()
258
+ return False
259
+ print("→ Adding upstream remote...")
260
+ if not _add_upstream_remote(git_cmd, cwd):
261
+ print(" ✗ Failed to add upstream remote. Skipping upstream sync.")
262
+ return False
263
+ print(" ✓ Added upstream: https://github.com/NousResearch/hermes-agent.git")
264
+ return True
265
+
266
+
267
+ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path, *, assume_yes: bool = False, input_fn=None) -> bool:
268
+ """Offer to add ``upstream``, compare origin/main vs upstream/main, ff-pull when strictly behind, then push origin.
269
+
270
+ Returns True only when origin/main was actually verified against upstream/main; False when the check never
271
+ happened, so the caller never reports "up to date" on an origin-only compare. Fetches only upstream/main:
272
+ a bare fetch drags in thousands of auto-generated branches.
273
+
274
+ See #97052.
275
+ """
276
+ from hermes_cli.update_cmd import _count_commits_between, _has_upstream_remote, _no_prompt_git_kwargs, _should_skip_upstream_prompt
277
+ if not _has_upstream_remote(git_cmd, cwd) and (
278
+ _should_skip_upstream_prompt() or not _offer_upstream_remote(git_cmd, cwd, assume_yes=assume_yes, input_fn=input_fn)
279
+ ):
280
+ return False
281
+ print("\n→ Fetching upstream...")
282
+ try:
283
+ subprocess.run(git_cmd + ["fetch", "upstream", "main", "--quiet"], cwd=cwd, capture_output=True, check=True, **_no_prompt_git_kwargs())
284
+ except subprocess.CalledProcessError:
285
+ print(" ✗ Failed to fetch upstream. Skipping upstream sync.")
286
+ return False
287
+ origin_ahead = _count_commits_between(git_cmd, cwd, "upstream/main", "origin/main")
288
+ upstream_ahead = _count_commits_between(git_cmd, cwd, "origin/main", "upstream/main")
289
+ if origin_ahead < 0 or upstream_ahead < 0:
290
+ print(" ✗ Could not compare branches. Skipping upstream sync.")
291
+ return False
292
+ if origin_ahead > 0:
293
+ print(
294
+ f"\nℹ Your fork has {origin_ahead} commit(s) not on upstream.\n"
295
+ " Skipping upstream sync to preserve your changes.\n"
296
+ " If you want to merge upstream changes, run:\n git pull upstream main"
297
+ )
298
+ return True
299
+ if upstream_ahead == 0:
300
+ print(" ✓ Fork is up to date with upstream")
301
+ return True
302
+ print(f"\n→ Fork is {upstream_ahead} commit(s) behind upstream\n→ Pulling from upstream...")
303
+ try:
304
+ subprocess.run(git_cmd + ["pull", "--ff-only", "upstream", "main"], cwd=cwd, check=True, **_no_prompt_git_kwargs())
305
+ except subprocess.CalledProcessError:
306
+ print(" ✗ Failed to pull from upstream. You may need to resolve conflicts manually.")
307
+ return False
308
+ print(" ✓ Updated from upstream\n→ Syncing fork...")
309
+ if _sync_fork_with_upstream(git_cmd, cwd):
310
+ print(" ✓ Fork synced with upstream")
311
+ else:
312
+ print(
313
+ " ℹ Got updates from upstream but couldn't push to fork (no write access?)\n"
314
+ " Your local repo is updated, but your fork on GitHub may be behind."
315
+ )
316
+ return True
317
+
318
+
319
+ def _has_http_code(stderr: str, *codes: str) -> bool:
320
+ return any(f"HTTP {code}" in stderr or f"returned error: {code}" in stderr for code in codes)
321
+
322
+
323
+ # Ordered (predicate, diagnosis): curl reports HTTP errors as ``unable to access '<url>': ... error: 429``,
324
+ # so rate-limit/outage checks must run BEFORE the generic "unable to access" check. An anonymous fetch
325
+ # answered with HTTP 401 ("could not read Username") is GitHub during an outage (or a renamed/private
326
+ # repo), not a user credentials problem.
327
+ _FETCH_FAILURE_RULES = (
328
+ (lambda s: _has_http_code(s, "429") or "rate limit" in s.lower(),
329
+ "✗ GitHub is rate limiting requests or having an outage (HTTP 429) — try again in 5 minutes."),
330
+ (lambda s: _has_http_code(s, "500", "502", "503", "504"),
331
+ "✗ GitHub appears to be having an outage — try again in a few minutes (https://www.githubstatus.com)."),
332
+ (lambda s: "Could not resolve host" in s or "unable to access" in s,
333
+ "✗ Network error — cannot reach the remote repository."),
334
+ (lambda s: "could not read Username" in s or "terminal prompts disabled" in s,
335
+ "✗ GitHub rejected the anonymous fetch (asked for a login) — this usually means a GitHub outage;"
336
+ " try again in a few minutes (https://www.githubstatus.com). If it persists, check"
337
+ " `git remote -v` points at a public repo."),
338
+ (lambda s: "Authentication failed" in s,
339
+ "✗ Authentication failed — check your git credentials or SSH key."),
340
+ )
341
+
342
+
343
+ def _classify_fetch_failure(stderr: str) -> str:
344
+ """Map git-fetch stderr to a one-line diagnosis (caller also prints the raw first line)."""
345
+ return next((message for matches, message in _FETCH_FAILURE_RULES if matches(stderr)), "✗ Failed to fetch updates from origin.")
346
+
347
+
348
+ def _print_fetch_failure(stderr: str) -> None:
349
+ """Print the classified diagnosis plus the first raw stderr line."""
350
+ stderr = (stderr or "").strip()
351
+ print(_classify_fetch_failure(stderr))
352
+ if stderr:
353
+ print(f" {stderr.splitlines()[0]}")
354
+
355
+
356
+ def _probe_fork_bomb(argv: list) -> Optional[bool]:
357
+ """Run ``<argv> --version``; True/False = guard message seen/absent, None = probe itself failed."""
358
+ try:
359
+ result = subprocess.run(argv + ["--version"], timeout=15, **_GIT_TEXT_KW)
360
+ except Exception:
361
+ return None
362
+ return "fork bomb" in ((result.stdout or "") + (result.stderr or "")).lower()
363
+
364
+
365
+ def _git_is_trampoline(git_cmd: list) -> bool:
366
+ """Whether *git_cmd* is a broken Git-for-Windows trampoline shim.
367
+
368
+ The ~46KB ``bin\\git.exe``/``cmd\\git.exe`` shims re-exec git-core; when they can't find it every call
369
+ dies with the launcher's guard message (a PATH problem, not network). Never raises; unknown states
370
+ report False so a probe failure can't block an update.
371
+
372
+ Git for Windows ships two ~46KB shims (``bin\\git.exe``, ``cmd\\git.exe``) that re-exec the real
373
+ ``mingw64\\libexec\\git-core\\git.exe``. See #87876.
374
+ """
375
+ return _probe_fork_bomb(git_cmd) is True
376
+
377
+
378
+ def _portable_git_candidates() -> list:
379
+ """PortableGit candidates: shared root first (where the managed tree actually lives, not the
380
+ profile-scoped HERMES_HOME), then profile home as a fallback for custom layouts.
381
+
382
+ The Hermes-managed PortableGit tree lives under the SHARED root (``<root>/git/...``), not the
383
+ profile-scoped HERMES_HOME (``<root>/profiles/<name>``), so a profile-scoped ``hermes update`` must look
384
+ there (monerostar review, #87876).
385
+ """
386
+ from hermes_cli.update_cmd import get_default_hermes_root, get_hermes_home
387
+ candidates = []
388
+ with suppress(Exception):
389
+ candidates += [root / "git" / "mingw64" / "libexec" / "git-core" / "git.exe" for root in (get_default_hermes_root(), Path(get_hermes_home()))]
390
+ return candidates
391
+
392
+
393
+ def _locate_real_git() -> Optional[Path]:
394
+ """Find a real Git-for-Windows ``git-core/git.exe`` (standard locations + managed PortableGit) that runs
395
+ without the trampoline guard. None when nothing suits — callers keep the broken command and let the
396
+ fetch-failure ZIP fallback handle it. A failed probe (None) disqualifies a candidate like a guard hit.
397
+
398
+ The trampoline symptom is PATH-level: ``bin\\git.exe`` / ``cmd\\git.exe`` (both ~46KB shims) fail to
399
+ re-exec git-core, while the real binary at ``mingw64\\libexec\\git-core\\git.exe`` (≈4.4MB) works when
400
+ invoked directly (#87876).
401
+ """
402
+ candidates = [
403
+ Path(r"C:\Program Files\Git\mingw64\libexec\git-core\git.exe"),
404
+ Path(r"C:\Program Files (x86)\Git\mingw64\libexec\git-core\git.exe"),
405
+ ] + _portable_git_candidates()
406
+ return next((c for c in candidates if c.exists() and _probe_fork_bomb([str(c)]) is False), None)
407
+
408
+
409
+ def _ensure_non_trampoline_git(git_cmd: list) -> list:
410
+ """Swap a broken Git-for-Windows trampoline for a real git binary so fetch/pull/checkout keep working;
411
+ if none is found leave the command untouched (fetch-failure handler falls back to ZIP). No-op off
412
+ Windows and when git is healthy."""
413
+ from hermes_cli.update_cmd import _locate_real_git
414
+ if sys.platform != "win32" or not _git_is_trampoline(git_cmd):
415
+ return git_cmd
416
+ real_git = _locate_real_git()
417
+ if real_git is None:
418
+ print("⚠ Detected a broken git trampoline and could not locate a real git binary — the update will fall back to the ZIP path.")
419
+ return git_cmd
420
+ print(f"⚠ Detected a broken git trampoline; switching to real git at {real_git}")
421
+ return [str(real_git)] + list(git_cmd[1:])
422
+
423
+
424
+ def _discard_lockfile_churn(git_cmd, repo_root):
425
+ """Restore ``package-lock.json`` files npm rewrote non-deterministically, so the update sees a clean tree
426
+ instead of autostashing every run. Only touches lockfiles whose package.json is NOT also dirty. Best-effort."""
427
+ from hermes_cli.update_cmd import _git_run
428
+ with suppress(Exception):
429
+ diff = _git_run(git_cmd, ["diff", "--name-only"], repo_root)
430
+ if diff.returncode != 0:
431
+ return
432
+ changed = [line.strip() for line in diff.stdout.splitlines()]
433
+ dirty_package_dirs = {Path(p).parent for p in changed if p.endswith("package.json")}
434
+ dirty = [p for p in changed if p.endswith("package-lock.json") and Path(p).parent not in dirty_package_dirs]
435
+ if not dirty:
436
+ return
437
+ _git_run(git_cmd, ["checkout", "--", *dirty], repo_root)
438
+ print(f"→ Discarded npm lockfile churn ({len(dirty)} file(s))")
439
+
440
+
441
+ def _normalize_managed_eol(git_cmd, repo_root):
442
+ """Take a managed checkout off ``core.autocrlf=true`` without leaving it dirty.
443
+
444
+ Git for Windows sets ``autocrlf=true`` system-wide, turning LF files CRLF and breaking ``git checkout``
445
+ on update; install.ps1 pins ``false`` but older checkouts never got it and only ``hermes update`` can
446
+ fix them. Pin and cleanup are one operation: under ``autocrlf=true`` a CRLF tree reads clean, so pinning
447
+ alone would expose every file as modified (whole-tree autostash). Pin only after the tree verifies clean
448
+ under it; a checkout we can't fully normalize is left as-is. Only ``true`` rewrites LF->CRLF
449
+ (unset/false/input leave the tree alone). Best-effort.
450
+
451
+ Checkouts created before that landed never got the pin and cannot receive it — the bootstrap installer
452
+ reuses its build-pinned ``install.ps1`` forever — so ``hermes update``, which ships with the checkout
453
+ itself, is the only path left that can fix them. See #67730.
454
+ """
455
+ from hermes_cli.update_cmd import _git_run
456
+ # -c, not config: evaluate the tree as it WOULD look pinned, persisting nothing.
457
+ probe = git_cmd + ["-c", "core.autocrlf=false"]
458
+
459
+ def _probe_run(*args, **kw):
460
+ return subprocess.run(probe + list(args), cwd=repo_root, **_GIT_TEXT_KW, **kw)
461
+
462
+ def _eol_only():
463
+ """Dirty paths whose ONLY change is CRLF; None when either probe fails."""
464
+ all_dirty = _probe_run("diff", "-z", "--name-only")
465
+ # Files with a *content* change ignoring CRLF. ``--name-only --ignore-cr-at-eol`` still LISTS
466
+ # CR-only files; ``--numstat`` honors the filter (no record for them). Records are
467
+ # "<added>\\t<deleted>\\t<path>"; rename detection is off, so exactly one path.
468
+ real_dirty = _probe_run("-c", "core.quotepath=false", "diff", "--numstat", "--ignore-cr-at-eol")
469
+ if all_dirty.returncode != 0 or real_dirty.returncode != 0:
470
+ return None
471
+ return {p for p in all_dirty.stdout.split("\0") if p} - {
472
+ parts[2]
473
+ for parts in (line.split("\t", 2) for line in real_dirty.stdout.splitlines() if line.strip())
474
+ if len(parts) == 3 and parts[2]
475
+ }
476
+
477
+ with suppress(Exception):
478
+ if _git_run(git_cmd, ["config", "--get", "core.autocrlf"], repo_root).stdout.strip().lower() != "true":
479
+ return
480
+ eol_only = _eol_only()
481
+ if eol_only is None:
482
+ return
483
+ if eol_only:
484
+ # Pathspec via stdin: thousands of paths exceed the Windows argv limit.
485
+ _probe_run("checkout", "--pathspec-from-file=-", "--pathspec-file-nul", "--",
486
+ input="\0".join(sorted(eol_only)), check=False)
487
+ if _eol_only(): # still dirty: pinning would only surface churn we failed to clear
488
+ return
489
+ print(f"→ Normalized line-ending churn ({len(eol_only)} file(s))")
490
+ subprocess.run(git_cmd + ["config", "core.autocrlf", "false"], cwd=repo_root, capture_output=True, check=False)
hermes_cli/update_cmd_stash.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Autostash handling for ``hermes update``: stash before the pull, restore/park/discard afterwards, warn about orphans.
2
+
3
+ Split out of ``update_cmd.py``; names are re-imported there so ``hermes_cli.update_cmd.<name>`` still resolves/monkeypatches.
4
+ Origin helpers are imported lazily per function (no cycle; test patches on the origin stay effective).
5
+ """
6
+
7
+ import logging
8
+ import subprocess
9
+ from datetime import datetime, timedelta, timezone
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ # Log-record parity with the origin module.
14
+ logger = logging.getLogger("hermes_cli.update_cmd")
15
+
16
+ #: Autostash subject contract: this prefix + UTC YYYYMMDD-HHMMSS stamp
17
+ #: (producer _stash_local_changes_if_needed, consumer _warn_orphaned_update_autostashes).
18
+ _AUTOSTASH_NAME_PREFIX = "hermes-update-autostash-"
19
+
20
+ #: Age past which a leftover autostash is called out. Younger entries are normal
21
+ #: (recent --keep-stash park); older ones are almost always forgotten.
22
+ # Entries younger than this are normal (a parked stash from : the desktop updater's --keep-stash run minutes
23
+ # ago); older ones are almost : always forgotten (#63717 problem 6: an orphan persisted 9+ days unnoticed).
24
+ _AUTOSTASH_WARN_AGE_DAYS = 7
25
+
26
+ _STASH_LEFT_IN_PLACE = " The stash was left in place. You can remove it manually after checking the result."
27
+
28
+
29
+ def _git_quiet(git_cmd: list[str], args: list[str], cwd: Path, **kwargs):
30
+ """``subprocess.run`` of a git command with captured output; None when git cannot run."""
31
+ try:
32
+ return subprocess.run(git_cmd + args, cwd=cwd, capture_output=True, **kwargs)
33
+ except (OSError, subprocess.SubprocessError):
34
+ return None
35
+
36
+
37
+ def _git_paths_z(git_cmd: list[str], args: list[str], cwd: Path):
38
+ """NUL-separated path listing as a set, or None when git failed (surrogateescape keeps odd filenames)."""
39
+ result = _git_quiet(git_cmd, args, cwd, text=True, encoding="utf-8", errors="surrogateescape")
40
+ if result is None or result.returncode != 0:
41
+ return None
42
+ return {path for path in result.stdout.split("\0") if path}
43
+
44
+
45
+ def _reset_hard(git_cmd: list[str], cwd: Path) -> None:
46
+ subprocess.run(git_cmd + ["reset", "--hard", "HEAD"], cwd=cwd, capture_output=True)
47
+
48
+
49
+ def _print_nonempty(text: str, prefix: str = "") -> None:
50
+ if text.strip():
51
+ print(f"{prefix}{text.strip()}")
52
+
53
+
54
+ def _print_first_line(text: str) -> None:
55
+ if text.strip():
56
+ print(f" {text.strip().splitlines()[0]}")
57
+
58
+
59
+ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
60
+ from hermes_cli.update_cmd import _git_run
61
+ status = _git_run(git_cmd, ["status", "--porcelain"], cwd, check=True)
62
+ if not status.stdout.strip():
63
+ return None
64
+ # Unmerged index entries (interrupted merge/rebase) make `git stash` fail with
65
+ # "needs merge"; `git reset` drops only the index conflict state, not the tree.
66
+ if _git_run(git_cmd, ["ls-files", "--unmerged"], cwd).stdout.strip():
67
+ print("→ Clearing unmerged index entries from a previous conflict...")
68
+ subprocess.run(git_cmd + ["reset"], cwd=cwd, capture_output=True)
69
+
70
+ stash_name = datetime.now(timezone.utc).strftime(f"{_AUTOSTASH_NAME_PREFIX}%Y%m%d-%H%M%S")
71
+ print("→ Local changes detected — stashing before update...")
72
+ prev_stash = _git_run(git_cmd, ["rev-parse", "--verify", "refs/stash"], cwd).stdout.strip()
73
+ push = _git_run(git_cmd, ["stash", "push", "--include-untracked", "-m", stash_name], cwd)
74
+ _print_nonempty(push.stdout)
75
+ stash_probe = _git_run(git_cmd, ["rev-parse", "--verify", "refs/stash"], cwd)
76
+ stash_ref = stash_probe.stdout.strip()
77
+ stash_created = stash_probe.returncode == 0 and bool(stash_ref) and stash_ref != prev_stash
78
+ if push.returncode != 0:
79
+ if not stash_created:
80
+ # No entry created: changes NOT saved — bail before touching HEAD.
81
+ print("✗ Could not stash local changes — update aborted.")
82
+ _print_first_line(push.stderr)
83
+ print(" Commit, stash, or clean up your local changes manually, then re-run `hermes update`.")
84
+ raise subprocess.CalledProcessError(push.returncode, push.args, output=push.stdout, stderr=push.stderr)
85
+ # Non-zero but entry created: push saved everything yet couldn't delete some untracked files
86
+ # (e.g. root-owned dir). Not a failure — continue.
87
+ _print_nonempty(push.stderr)
88
+ print(" ⚠ Some untracked files could not be removed from the working tree (permission denied).")
89
+ print(" They were still saved to the stash and were left in place — the update will continue.")
90
+ # A partially-failed push also skips cleanup of TRACKED modifications; they'd break the following
91
+ # pull. Safe to reset: all is in the stash.
92
+ _reset_hard(git_cmd, cwd)
93
+ return stash_ref
94
+
95
+
96
+ def _resolve_stash_selector(git_cmd: list[str], cwd: Path, stash_ref: str) -> Optional[str]:
97
+ from hermes_cli.update_cmd import _git_run
98
+ stash_list = _git_run(git_cmd, ["stash", "list", "--format=%gd %H"], cwd, check=True)
99
+ for line in stash_list.stdout.splitlines():
100
+ selector, _, commit = line.partition(" ")
101
+ if commit.strip() == stash_ref:
102
+ return selector.strip()
103
+ return None
104
+
105
+
106
+ def _warn_orphaned_update_autostashes(git_cmd: list[str], cwd: Path) -> int:
107
+ """Print a notice for update autostashes older than the warn threshold; return the count (0 on any git failure).
108
+
109
+ Autostashes legitimately outlive a run (--keep-stash, failed restore) but nothing re-surfaces them.
110
+ Deliberately NOT a GC: a stash may be the only copy of the user's work, so Hermes never drops one.
111
+
112
+ Autostash entries legitimately outlive an update run (``--keep-stash`` parks them; a conflicted or
113
+ failed restore preserves them for safety), but nothing ever re-surfaces them afterwards — they sit in
114
+ ``git stash`` invisibly for weeks (#63717 problem 6). This prints a short notice naming the stale
115
+ entries with recovery/cleanup guidance.
116
+ """
117
+ from hermes_cli.update_cmd import _git_run
118
+ try:
119
+ stash_list = _git_run(git_cmd, ["stash", "list", "--format=%gd %s"], cwd)
120
+ if stash_list.returncode != 0:
121
+ return 0
122
+ cutoff = datetime.now(timezone.utc) - timedelta(days=_AUTOSTASH_WARN_AGE_DAYS)
123
+ stale: list[tuple[str, str]] = []
124
+ for line in stash_list.stdout.splitlines():
125
+ selector, _, subject = line.strip().partition(" ")
126
+ pos = subject.find(_AUTOSTASH_NAME_PREFIX)
127
+ if pos < 0:
128
+ continue
129
+ stamp = subject[pos + len(_AUTOSTASH_NAME_PREFIX):][:15] # "YYYYMMDD-HHMMSS"
130
+ try:
131
+ stash_time = datetime.strptime(stamp, "%Y%m%d-%H%M%S").replace(tzinfo=timezone.utc)
132
+ except ValueError:
133
+ continue # age unknown — leave it alone rather than guess
134
+ if stash_time < cutoff:
135
+ stale.append((selector, stamp))
136
+ if not stale:
137
+ return 0
138
+ print()
139
+ print(
140
+ f"⚠ {len(stale)} leftover update autostash entr"
141
+ f"{'y is' if len(stale) == 1 else 'ies are'} more than "
142
+ f"{_AUTOSTASH_WARN_AGE_DAYS} days old:"
143
+ )
144
+ for selector, stamp in stale:
145
+ print(f" {selector} ({_AUTOSTASH_NAME_PREFIX}{stamp})")
146
+ print(" These hold local changes stashed by earlier updates and never")
147
+ print(" restored. Review with: git stash show -p <entry>")
148
+ print(" Restore with: git stash apply <entry> Discard with: git stash drop <entry>")
149
+ return len(stale)
150
+ except Exception as exc:
151
+ logger.debug("Autostash age check failed: %s", exc)
152
+ return 0
153
+
154
+
155
+ def _print_stash_cleanup_guidance(stash_ref: str, stash_selector: Optional[str] = None) -> None:
156
+ print(" Check `git status` first so you don't accidentally reapply the same change twice.")
157
+ print(" Find the saved entry with: git stash list --format='%gd %H %s'")
158
+ if stash_selector:
159
+ print(f" Remove it with: git stash drop {stash_selector}")
160
+ else:
161
+ print(f" Look for commit {stash_ref}, then drop its selector with: git stash drop stash@{{N}}")
162
+
163
+
164
+ def _stash_apply_failed_only_on_existing_untracked(stderr: str) -> bool:
165
+ """True when a ``git stash apply`` failure is ONLY about untracked files that already exist in the tree.
166
+
167
+ Tail of the permission-denied class: push swept undeletable files into the stash but couldn't remove them;
168
+ apply restores tracked changes, then refuses to overwrite those files and exits non-zero though nothing was lost.
169
+ Any other error line (e.g. ``would be overwritten by merge``) means the tracked apply failed -> False.
170
+ """
171
+ saw_untracked_error = False
172
+ for ln in (ln.strip() for ln in (stderr or "").splitlines() if ln.strip()):
173
+ if "already exists, no checkout" in ln or "could not restore untracked files from stash" in ln:
174
+ saw_untracked_error = True
175
+ elif not ln.startswith(("warning:", "hint:")):
176
+ return False
177
+ return saw_untracked_error
178
+
179
+
180
+ def _park_stashed_changes(stash_ref: str) -> None:
181
+ """Leave a pre-update autostash parked (``--keep-stash``, the desktop updater's mode): local source
182
+ edits must never be silently re-applied onto updated code; the entry stays in ``git stash``."""
183
+ print()
184
+ print("ℹ️ Local changes were stashed before updating and were NOT re-applied (--keep-stash).")
185
+ print(f" Stash ref: {stash_ref}")
186
+ print(f" Restore manually with: git stash apply {stash_ref}")
187
+
188
+
189
+ def _git_untracked_paths(git_cmd: list[str], cwd: Path) -> set[str] | None:
190
+ """Return untracked paths, or ``None`` when Git cannot enumerate them."""
191
+ paths = _git_paths_z(git_cmd, ["ls-files", "--others", "--exclude-standard", "-z"], cwd)
192
+ if paths is None:
193
+ print(" ⚠ Could not enumerate untracked files while validating the restored stash.")
194
+ return paths
195
+
196
+
197
+ def _restored_python_paths(git_cmd: list[str], cwd: Path) -> tuple[str, ...] | None:
198
+ """Restored ``.py`` paths changed from ``HEAD``; deliberately Python-only (entry scripts stay outside the health check)."""
199
+ from hermes_cli.update_cmd import _git_untracked_paths
200
+ paths = _git_paths_z(git_cmd, ["diff", "--name-only", "-z", "HEAD", "--", "*.py"], cwd)
201
+ if paths is None:
202
+ print(" ⚠ Could not enumerate tracked Python files restored from the stash.")
203
+ return None
204
+ untracked = _git_untracked_paths(git_cmd, cwd)
205
+ if untracked is None:
206
+ return None
207
+ paths.update(path for path in untracked if path.endswith(".py"))
208
+ return tuple(sorted(paths))
209
+
210
+
211
+ def _reject_unsafe_stash_restore(
212
+ git_cmd: list[str], cwd: Path, stash_ref: str, preexisting_untracked: set[str], failing_target: str,
213
+ detail: str | None,
214
+ ) -> None:
215
+ """Restore the clean updated tree, preserve the stash, and abort the update."""
216
+ from hermes_cli.update_cmd import _git_untracked_paths
217
+ print()
218
+ print("✗ Restored local changes made the Hermes agent unexecutable.")
219
+ print(f" Health check failed: {failing_target}")
220
+ if detail:
221
+ for line in str(detail).splitlines()[:6]:
222
+ print(f" {line}")
223
+
224
+ def _ok(result) -> bool:
225
+ return result is not None and result.returncode == 0
226
+
227
+ current_untracked = _git_untracked_paths(git_cmd, cwd)
228
+ restored_untracked = current_untracked - preexisting_untracked if current_untracked is not None else set()
229
+ reset = _git_quiet(git_cmd, ["reset", "--hard", "HEAD"], cwd)
230
+ clean = _git_quiet(git_cmd, ["clean", "-fd", "--", *sorted(restored_untracked)], cwd) if restored_untracked else None
231
+ cleanup_ok = current_untracked is not None and _ok(reset) and (not restored_untracked or _ok(clean))
232
+ if cleanup_ok:
233
+ cleanup_ok = _ok(_git_quiet(git_cmd, ["diff", "--quiet", "HEAD", "--"], cwd))
234
+ if cleanup_ok:
235
+ print(" The clean updated tree has been restored; the gateway was not restarted.")
236
+ else:
237
+ print(" ⚠ The clean updated tree could not be fully restored automatically.")
238
+ print(" Inspect `git status` and run `git reset --hard HEAD` before retrying.")
239
+ print(" Platform connectivity alone does not mean the agent can execute turns.")
240
+ print(f" Your local changes remain preserved in stash: {stash_ref}")
241
+ print(f" Inspect them with: git stash show --stat {stash_ref}")
242
+ print(f" Restore manually after fixing them: git stash apply {stash_ref}")
243
+ raise SystemExit(1)
244
+
245
+
246
+ def _confirm_restore(stash_ref: str, input_fn) -> bool:
247
+ """Interactive gate; a remote ``input_fn`` defaults to No (``[y/N]``), the local prompt to Yes."""
248
+ remote_prompt = input_fn is not None
249
+ prompt_suffix = "[y/N]" if remote_prompt else "[Y/n]"
250
+ print()
251
+ print("⚠ Local changes were stashed before updating.")
252
+ print(" Restoring them may reapply local customizations onto the updated codebase.")
253
+ print(" Review the result afterward if Hermes behaves unexpectedly.")
254
+ print(f"Restore local changes now? {prompt_suffix}")
255
+ if remote_prompt:
256
+ response = input_fn(f"Restore local changes now? {prompt_suffix}", "n")
257
+ else:
258
+ try:
259
+ response = input().strip().lower()
260
+ except (EOFError, UnicodeDecodeError):
261
+ response = "n" # closed stdin/encoding error must not crash mid-restore
262
+ if response in {"y", "yes"} or (not remote_prompt and response == ""):
263
+ return True
264
+ print("Skipped restoring local changes.")
265
+ print("Your changes are still preserved in git stash.")
266
+ print(f"Restore manually with: git stash apply {stash_ref}")
267
+ return False
268
+
269
+
270
+ def _apply_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> bool:
271
+ """``git stash apply``; False (tree reset, stash kept) on conflicts or any failure other than the
272
+ undeletable-untracked class."""
273
+ from hermes_cli.update_cmd import _git_run
274
+ print("→ Restoring local changes...")
275
+ restore = _git_run(git_cmd, ["stash", "apply", stash_ref], cwd)
276
+ unmerged = _git_run(git_cmd, ["diff", "--name-only", "--diff-filter=U"], cwd) # conflicts can exist even on rc 0
277
+ conflicted_files = unmerged.stdout.strip()
278
+ if restore.returncode == 0 and not conflicted_files:
279
+ return True
280
+ if not conflicted_files and _stash_apply_failed_only_on_existing_untracked(restore.stderr):
281
+ # Tracked changes applied; only undeletable-at-stash-time untracked files were refused. Their
282
+ # content is untouched — treat as restored.
283
+ print(" ⚠ Some stashed untracked files already exist in the working tree and were kept as-is.")
284
+ return True
285
+ print("✗ Update pulled new code, but restoring local changes hit conflicts.")
286
+ _print_nonempty(restore.stdout)
287
+ _print_nonempty(restore.stderr)
288
+ if conflicted_files:
289
+ print("\nConflicted files:")
290
+ for f in conflicted_files.splitlines():
291
+ print(f" • {f}")
292
+ print("\nYour stashed changes are preserved — nothing is lost.")
293
+ print(f" Stash ref: {stash_ref}")
294
+ _reset_hard(git_cmd, cwd) # conflict markers make hermes unrunnable; changes stay in the stash
295
+ print("Working tree reset to clean state.")
296
+ print(f"Restore your changes later with: git stash apply {stash_ref}")
297
+ return False # code update succeeded; cmd_update continues (deps, skills, gateway)
298
+
299
+
300
+ def _drop_restored_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> None:
301
+ from hermes_cli.update_cmd import _git_run
302
+ stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
303
+ if stash_selector is None:
304
+ print("⚠ Local changes were restored, but Hermes couldn't find the stash entry to drop.")
305
+ print(_STASH_LEFT_IN_PLACE)
306
+ _print_stash_cleanup_guidance(stash_ref)
307
+ return
308
+ drop = _git_run(git_cmd, ["stash", "drop", stash_selector], cwd)
309
+ if drop.returncode != 0:
310
+ print("⚠ Local changes were restored, but Hermes couldn't drop the saved stash entry.")
311
+ _print_nonempty(drop.stdout)
312
+ _print_nonempty(drop.stderr)
313
+ print(_STASH_LEFT_IN_PLACE)
314
+ _print_stash_cleanup_guidance(stash_ref, stash_selector)
315
+
316
+
317
+ def _restore_stashed_changes(
318
+ git_cmd: list[str], cwd: Path, stash_ref: str, prompt_user: bool = False, input_fn=None,
319
+ ) -> bool:
320
+ from hermes_cli.update_cmd import (
321
+ _critical_module_import_failures, _git_untracked_paths, _restored_python_paths, _validate_python_files_syntax,
322
+ )
323
+ if prompt_user and not _confirm_restore(stash_ref, input_fn):
324
+ return False
325
+ preexisting_untracked = _git_untracked_paths(git_cmd, cwd)
326
+ if preexisting_untracked is None:
327
+ print(" The stash was not restored because its cleanup baseline is unknown.")
328
+ print(f" Restore manually with: git stash apply {stash_ref}")
329
+ return False
330
+ clean_import_failures = _critical_module_import_failures(cwd, report_runtime_errors=True)
331
+ if not _apply_stash(git_cmd, cwd, stash_ref):
332
+ return False
333
+
334
+ def reject(failing_target: str, detail) -> None:
335
+ _reject_unsafe_stash_restore(git_cmd, cwd, stash_ref, preexisting_untracked, failing_target, detail)
336
+
337
+ restored_python = _restored_python_paths(git_cmd, cwd)
338
+ if restored_python is None:
339
+ reject("restored Python source discovery", "could not determine which restored Python files require validation")
340
+ syntax_ok, failing_path, syntax_error = _validate_python_files_syntax(cwd, restored_python)
341
+ if not syntax_ok:
342
+ reject(failing_path or "restored Python source", syntax_error)
343
+ for module, error in _critical_module_import_failures(cwd, report_runtime_errors=True).items():
344
+ if clean_import_failures.get(module) != error:
345
+ reject(f"agent import {module or 'unknown'}", error[1])
346
+ break
347
+ _drop_restored_stash(git_cmd, cwd, stash_ref)
348
+ print("⚠ Local changes were restored on top of the updated codebase.")
349
+ print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.")
350
+ return True
351
+
352
+
353
+ def _discard_stashed_changes(git_cmd: list[str], cwd: Path, stash_ref: str) -> bool:
354
+ """Drop a pre-update stash without applying (non-interactive ``updates.non_interactive_local_changes: discard``).
355
+
356
+ Unlike reset --hard + clean -fd this touches only what was stashed; ignored paths are never affected.
357
+ Returns True if dropped, False on git failure (stash left in place).
358
+ """
359
+ from hermes_cli.update_cmd import _git_run
360
+ stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
361
+ if stash_selector is None:
362
+ print(
363
+ "⚠ Configured to discard local changes on non-interactive update, "
364
+ "but Hermes couldn't find the stash entry to drop."
365
+ )
366
+ _print_stash_cleanup_guidance(stash_ref)
367
+ return False
368
+ drop = _git_run(git_cmd, ["stash", "drop", stash_selector], cwd)
369
+ if drop.returncode != 0:
370
+ print("⚠ Configured to discard local changes, but Hermes couldn't drop the saved stash entry.")
371
+ _print_first_line(drop.stderr)
372
+ _print_stash_cleanup_guidance(stash_ref, stash_selector)
373
+ return False
374
+ print("→ Discarded local source changes (updates.non_interactive_local_changes=discard).")
375
+ return True
hermes_cli/web_read_coalescing.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Coalesce concurrent synchronous HTTP reads before threadpool admission."""
2
+ import asyncio
3
+ import inspect
4
+ from functools import partial, wraps
5
+ from weakref import WeakKeyDictionary
6
+ from typing import get_type_hints
7
+
8
+ from anyio.to_thread import run_sync
9
+ from hermes_constants import get_hermes_home
10
+
11
+
12
+ def _freeze(value):
13
+ if isinstance(value, dict):
14
+ return (type(value), frozenset((_freeze(k), _freeze(v)) for k, v in value.items()))
15
+ if isinstance(value, (list, tuple)):
16
+ return (type(value), tuple(map(_freeze, value)))
17
+ if isinstance(value, (set, frozenset)):
18
+ return (type(value), frozenset(map(_freeze, value)))
19
+ return (type(value), value)
20
+
21
+
22
+ def coalesced_read(func, *, thread_runner=run_sync):
23
+ """Return an async, in-flight-only wrapper around a synchronous read.
24
+
25
+ One worker per decorated function/event loop; identical home + bound
26
+ arguments share its result. Arguments must be hashable or built-in
27
+ containers of hashable values and must not be mutated during the read.
28
+ A custom async thread_runner must await the supplied zero-argument callable
29
+ through a context-preserving threadpool, not abandon running work.
30
+ """
31
+ signature = inspect.signature(func)
32
+ hints = get_type_hints(func, include_extras=True)
33
+ signature = signature.replace(
34
+ parameters=[p.replace(annotation=hints.get(p.name, p.annotation))
35
+ for p in signature.parameters.values()],
36
+ return_annotation=hints.get("return", signature.return_annotation),
37
+ )
38
+ states = WeakKeyDictionary()
39
+
40
+ @wraps(func)
41
+ async def wrapped(*args, **kwargs):
42
+ loop = asyncio.get_running_loop()
43
+ bound = signature.bind(*args, **kwargs)
44
+ bound.apply_defaults()
45
+ key = (str(get_hermes_home()), _freeze(bound.arguments))
46
+ pending, admission = states.setdefault(loop, ({}, asyncio.Semaphore(1)))
47
+ task = pending.get(key)
48
+ if task is None:
49
+ async def execute():
50
+ try:
51
+ async with admission:
52
+ return await thread_runner(partial(func, *args, **kwargs))
53
+ finally:
54
+ pending.pop(key, None)
55
+ if not pending:
56
+ states.pop(loop, None)
57
+ task = loop.create_task(execute())
58
+ pending[key] = task
59
+ # Observe failures even if every HTTP caller has disconnected.
60
+ task.add_done_callback(lambda done: None if done.cancelled() else done.exception())
61
+ # A disconnected caller must not release admission for a live worker.
62
+ return await asyncio.shield(task)
63
+
64
+ wrapped.__signature__ = signature
65
+ wrapped.__annotations__ = hints
66
+ return wrapped
hermes_cli/web_server_dashboard.py ADDED
@@ -0,0 +1,836 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dashboard UI assets: SPA mount, theme normalisation/bootstrap CSS, dashboard-plugin discovery and the plugins-hub merge.
2
+ """
3
+
4
+ import logging
5
+ import importlib.util
6
+ import json
7
+ import os
8
+ import sys
9
+ import threading
10
+ import time
11
+ import yaml
12
+ from fastapi import FastAPI, Request
13
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
14
+ from fastapi.staticfiles import StaticFiles
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+ from hermes_cli.config import cfg_get, get_process_hermes_home
18
+ from utils import env_var_enabled
19
+
20
+ # Same logger the code used before extraction (record parity).
21
+ _log = logging.getLogger("hermes_cli.web_server")
22
+
23
+
24
+ def _normalise_prefix(raw: Optional[str]) -> str:
25
+ """Normalise an X-Forwarded-Prefix header value (single source of truth lives in
26
+ ``hermes_cli.dashboard_auth.prefix`` so gate, OAuth, cookies and SPA mount agree)."""
27
+ from hermes_cli.dashboard_auth.prefix import normalise_prefix
28
+ return normalise_prefix(raw)
29
+
30
+
31
+ def _layer_hex(palette: Dict[str, Any], key: str, default: str) -> str:
32
+ layer = palette.get(key) or {}
33
+ return layer.get("hex", default) if isinstance(layer, dict) else default
34
+
35
+
36
+ def _render_active_theme_bootstrap_css() -> str:
37
+ """Critical-CSS ``<style>`` shim for the active *user* theme, so the first paint uses the
38
+ target palette instead of flashing the bundle's default Hermes Teal until
39
+ ``ThemeProvider.applyTheme()`` runs. Built-in themes return "" (the bundle owns them).
40
+
41
+ Variable names MUST match what the bundle consumes (``layerVars()`` /
42
+ ``typographyVars()`` in ``web/src/themes/context.tsx``). The ``html,body`` rule
43
+ references the variables rather than literals so runtime theme switches stay live:
44
+ ``applyTheme()`` writes inline styles on ``documentElement`` which outrank this block.
45
+ """
46
+ from hermes_cli.config import load_config
47
+ try:
48
+ active = cfg_get(load_config(), "dashboard", "theme", default="default")
49
+ if not active or not isinstance(active, str):
50
+ return ""
51
+ if any(b["name"] == active for b in _BUILTIN_DASHBOARD_THEMES):
52
+ return ""
53
+ for theme in _discover_user_themes():
54
+ if theme.get("name") != active:
55
+ continue
56
+ palette = theme.get("palette") or {}
57
+ typo = theme.get("typography") or {}
58
+ font_sans = typo.get("fontSans") or _THEME_DEFAULT_TYPOGRAPHY["fontSans"]
59
+ base_size = typo.get("baseSize") or _THEME_DEFAULT_TYPOGRAPHY["baseSize"]
60
+
61
+ def _esc(s: str) -> str: # defensive ``</style>`` escape
62
+ return str(s).replace("</", "<\\/")
63
+ return (
64
+ '<style id="hermes-theme-bootstrap">'
65
+ ":root{"
66
+ f"--background-base:{_esc(_layer_hex(palette, 'background', '#0a0a0a'))};"
67
+ f"--midground-base:{_esc(_layer_hex(palette, 'midground', '#e5e5e5'))};"
68
+ f"--theme-font-sans:{_esc(font_sans)};"
69
+ f"--theme-base-size:{_esc(base_size)};"
70
+ "}"
71
+ "html,body{background-color:var(--background-base);"
72
+ "color:var(--midground-base);"
73
+ "font-family:var(--theme-font-sans);"
74
+ "font-size:var(--theme-base-size);}"
75
+ "</style>"
76
+ )
77
+ return ""
78
+ except Exception:
79
+ _log.debug("theme bootstrap render failed", exc_info=True)
80
+ return ""
81
+
82
+
83
+ # Hashed bundle assets are immutable by construction (content hash in the filename; index.html
84
+ # is served ``no-store`` and always references the current hashes).
85
+ _IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
86
+ _NO_STORE = {"Cache-Control": "no-store, no-cache, must-revalidate"}
87
+ _HEADLESS_MSG = (
88
+ "Headless backend (hermes serve): web UI disabled — use "
89
+ "`hermes dashboard` for the browser UI."
90
+ )
91
+
92
+
93
+ def mount_spa(application: FastAPI):
94
+ """Mount the built SPA; unmatched paths fall back to index.html for client-side routing.
95
+
96
+ The session token is injected into index.html via a ``<script>`` tag so the SPA can
97
+ authenticate without a separate token-dispensing endpoint. Behind a path-prefix reverse
98
+ proxy (``X-Forwarded-Prefix: /hermes``) the served index.html is rewritten so absolute
99
+ asset URLs and the runtime ``__HERMES_BASE_PATH__`` honour that prefix without a rebuild.
100
+
101
+ A missing WEB_DIST is deliberately NOT a mount-time terminal state: every route copes
102
+ with a missing dist per-request (404 JSON / ``check_dir=False``), so a long-lived
103
+ ``--skip-build`` process recovers the moment a build appears on disk — no restart.
104
+ """
105
+ from hermes_cli.web_server import WEB_DIST, _DASHBOARD_EMBEDDED_CHAT_ENABLED, app
106
+ from hermes_cli.web_deps import _server
107
+
108
+ # `hermes serve` is the headless backend: it must NEVER serve the browser SPA, even if a
109
+ # dist is lying around, so only the JSON-RPC/WS/API surface is reachable.
110
+ if os.environ.get("HERMES_SERVE_HEADLESS") == "1":
111
+
112
+ @application.get("/{full_path:path}")
113
+ async def no_frontend(full_path: str):
114
+ # Desktop token handshake: the Electron shell boots by fetching `/` and reading
115
+ # ``window.__HERMES_SESSION_TOKEN__`` for /api/ws auth. When headless 404'd every
116
+ # path, a renderer whose spawn token no longer matched (e.g. after `hermes update`)
117
+ # white-screened. Serve a token-only page at the exact root, but ONLY when the auth
118
+ # gate is off: on a gated serve the token must never be readable without auth.
119
+ # See #94227, #95575.
120
+ gated = bool(getattr(application.state, "auth_required", False))
121
+ if full_path == "" and not gated:
122
+ return HTMLResponse(
123
+ "<!doctype html><html><head><script>"
124
+ f"window.__HERMES_SESSION_TOKEN__={json.dumps(_server()._SESSION_TOKEN)};"
125
+ "window.__HERMES_AUTH_REQUIRED__=false;"
126
+ f"</script></head><body>{_HEADLESS_MSG}</body></html>",
127
+ headers=_NO_STORE,
128
+ )
129
+ return JSONResponse({"error": _HEADLESS_MSG}, status_code=404)
130
+ return
131
+
132
+ # A missing WEB_DIST is deliberately NOT a mount-time terminal state (#82614): a long-lived `hermes
133
+ # dashboard --skip-build` process that survives a `git pull` (or starts before the first build) used to
134
+ # install a permanent no_frontend catch-all here and could never recover — every route answered 404
135
+ # "Frontend not built" until the process was restarted, even after `npm run build` completed. The SPA
136
+ # routes below all cope with a missing dist per-request (`_serve_index` returns the same 404 JSON when
137
+ # index.html is unreadable; the asset mounts use check_dir=False and 404 on missing files), so mounting
138
+ # them unconditionally makes the dashboard recover the moment a build appears on disk — no restart
139
+ # needed.
140
+ def _serve_index(prefix: str = ""):
141
+ """index.html with the session token + base-path injected.
142
+
143
+ When the OAuth auth gate is active (``app.state.auth_required``), the legacy
144
+ ``_SESSION_TOKEN`` is NOT injected — the SPA reads identity from ``/api/auth/me`` over
145
+ cookie auth; ``__HERMES_AUTH_REQUIRED__`` tells it which scheme to use for /api/pty
146
+ and /api/ws (ticket vs token).
147
+ """
148
+ try:
149
+ html = (WEB_DIST / "index.html").read_text(encoding="utf-8")
150
+ except OSError:
151
+ # Partial build / wiped dist / permissions: same JSON 404 as a fully-missing dist.
152
+ return JSONResponse({"error": "Frontend not built. Run: cd web && npm run build"}, status_code=404)
153
+ chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
154
+ gated = bool(getattr(app.state, "auth_required", False))
155
+ token_js = "" if gated else f'window.__HERMES_SESSION_TOKEN__="{_server()._SESSION_TOKEN}";'
156
+ bootstrap_script = (
157
+ f"<script>{token_js}"
158
+ f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
159
+ f'window.__HERMES_BASE_PATH__="{prefix}";'
160
+ f"window.__HERMES_AUTH_REQUIRED__={'true' if gated else 'false'};"
161
+ f"</script>"
162
+ )
163
+ if prefix:
164
+ # Rewrite absolute asset URLs baked into the Vite build to go through the proxy.
165
+ for attr in ('href="/assets/', 'src="/assets/', 'href="/favicon.ico"', 'href="/fonts/',
166
+ 'href="/ds-assets/', 'src="/ds-assets/'):
167
+ html = html.replace(attr, attr.replace('"/', f'"{prefix}/', 1))
168
+ theme_bootstrap = _render_active_theme_bootstrap_css()
169
+ if theme_bootstrap:
170
+ html = html.replace("</head>", f"{theme_bootstrap}</head>", 1)
171
+ html = html.replace("</head>", f"{bootstrap_script}</head>", 1)
172
+ return HTMLResponse(html, headers=_NO_STORE)
173
+
174
+ # Built CSS contains absolute ``url(/fonts/...)`` / ``url(/ds-assets/...)`` references that
175
+ # browsers resolve against the document origin — wrong under a proxy prefix. Intercept CSS
176
+ # BEFORE the StaticFiles mount and rewrite when a prefix is in play.
177
+ @application.get("/assets/{filename}.css")
178
+ async def serve_css(filename: str, request: Request):
179
+ css_path = WEB_DIST / "assets" / f"{filename}.css"
180
+ if not css_path.is_file() or not css_path.resolve().is_relative_to(WEB_DIST.resolve()):
181
+ return JSONResponse({"error": "not found"}, status_code=404)
182
+ prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
183
+ css = css_path.read_text(encoding="utf-8")
184
+ if prefix:
185
+ for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"):
186
+ for quote in ("", '"', "'"):
187
+ css = css.replace(f"url({quote}{asset_dir}", f"url({quote}{prefix}{asset_dir}")
188
+ return Response(
189
+ content=css, media_type="text/css", headers={"Cache-Control": _IMMUTABLE_ASSET_CACHE_CONTROL}
190
+ )
191
+
192
+ class _ImmutableAssetFiles(StaticFiles):
193
+ """StaticFiles that marks hashed bundle assets immutable so reloads skip revalidation."""
194
+
195
+ async def get_response(self, path: str, scope):
196
+ response = await super().get_response(path, scope)
197
+ if response.status_code == 200:
198
+ response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL
199
+ return response
200
+
201
+ # check_dir=False: the dist may not exist yet; StaticFiles 404s per-request until it does.
202
+ application.mount(
203
+ "/assets", _ImmutableAssetFiles(directory=WEB_DIST / "assets", check_dir=False), name="assets"
204
+ )
205
+
206
+ @application.get("/{full_path:path}")
207
+ async def serve_spa(full_path: str, request: Request):
208
+ prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
209
+ # An unmatched /api/* path is a missing endpoint, not a client-side route: return a
210
+ # real 404 JSON instead of index.html (which breaks JSON clients with a SyntaxError).
211
+ if full_path == "api" or full_path.startswith("api/"):
212
+ return JSONResponse({"detail": f"No such API endpoint: /{full_path}"}, status_code=404)
213
+ file_path = WEB_DIST / full_path
214
+ # Prevent path traversal via url-encoded sequences (%2e%2e/)
215
+ if (
216
+ full_path
217
+ and file_path.resolve().is_relative_to(WEB_DIST.resolve())
218
+ and file_path.exists()
219
+ and file_path.is_file()
220
+ ):
221
+ return FileResponse(file_path)
222
+ return _serve_index(prefix)
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Dashboard themes
227
+ # ---------------------------------------------------------------------------
228
+
229
+ # Built-in themes — label + description only; colors live in web/src/themes/presets.ts.
230
+ _BUILTIN_DASHBOARD_THEMES = [
231
+ {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"},
232
+ {"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"},
233
+ {"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"},
234
+ {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"},
235
+ {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"},
236
+ {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"},
237
+ {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"},
238
+ {"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"},
239
+ ]
240
+
241
+
242
+ def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[Dict[str, Any]]:
243
+ """Normalise a theme layer spec (bare hex shorthand or ``{hex, alpha}`` dict); ``None`` on
244
+ garbage so the caller falls back to a built-in default."""
245
+ if value is None:
246
+ return {"hex": default_hex, "alpha": default_alpha}
247
+ if isinstance(value, str):
248
+ return {"hex": value, "alpha": default_alpha}
249
+ if not isinstance(value, dict):
250
+ return None
251
+ hex_val = value.get("hex", default_hex)
252
+ if not isinstance(hex_val, str):
253
+ return None
254
+ try:
255
+ alpha_f = float(value.get("alpha", default_alpha))
256
+ except (TypeError, ValueError):
257
+ alpha_f = default_alpha
258
+ return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))}
259
+
260
+
261
+ _THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = {
262
+ "fontSans": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
263
+ "fontMono": 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace',
264
+ "baseSize": "15px",
265
+ "lineHeight": "1.55",
266
+ "letterSpacing": "0",
267
+ }
268
+ _THEME_DEFAULT_LAYOUT: Dict[str, str] = {
269
+ "radius": "0.5rem", "density": "comfortable"
270
+ }
271
+ _THEME_OVERRIDE_KEYS = {
272
+ "card", "cardForeground", "popover", "popoverForeground",
273
+ "primary", "primaryForeground", "secondary", "secondaryForeground",
274
+ "muted", "mutedForeground", "accent", "accentForeground",
275
+ "destructive", "destructiveForeground", "success", "warning",
276
+ "border", "input", "ring",
277
+ }
278
+
279
+ # Named asset slots; other keys under ``assets.custom`` become ``--theme-asset-custom-<key>``.
280
+ _THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"}
281
+
282
+ # Component-style buckets: each camelCase property under a bucket emits
283
+ # ``--component-<bucket>-<kebab-property>`` on :root, consumed by shell components.
284
+ _THEME_COMPONENT_BUCKETS = {
285
+ "card", "header", "footer", "sidebar", "tab", "progress", "badge", "backdrop", "page"
286
+ }
287
+ _THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"}
288
+
289
+ # customCSS cap so an oversized theme YAML can't blow up the payload or <style> tag.
290
+ _THEME_CUSTOM_CSS_MAX = 32 * 1024
291
+
292
+
293
+ def _dict_field(data: Dict[str, Any], key: str) -> Dict[str, Any]:
294
+ value = data.get(key)
295
+ return value if isinstance(value, dict) else {}
296
+
297
+
298
+ def _nonempty_str(value: Any) -> bool:
299
+ return isinstance(value, str) and bool(value.strip())
300
+
301
+
302
+ def _css_ident(key: Any) -> bool:
303
+ return isinstance(key, str) and key.replace("-", "").replace("_", "").isalnum()
304
+
305
+
306
+ def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
307
+ """Normalise a user theme YAML into the wire format ``ThemeProvider`` expects; ``None`` if
308
+ unusable. Accepts the full schema and a loose form (top-level ``colors``, bare hex).
309
+
310
+ customCSS is clipped but intentionally NOT sanitised — themes are user-authored YAML in
311
+ ~/.hermes/, the same trust level as config.yaml. Empty asset values are dropped so a
312
+ theme can explicitly clear a slot.
313
+ """
314
+ if not isinstance(data, dict):
315
+ return None
316
+ name = data.get("name")
317
+ if not _nonempty_str(name):
318
+ return None
319
+
320
+ palette_src = _dict_field(data, "palette")
321
+ colors_src = _dict_field(data, "colors")
322
+
323
+ def _layer(key: str, default_hex: str, default_alpha: float = 1.0) -> Dict[str, Any]:
324
+ parsed = _parse_theme_layer(palette_src.get(key, colors_src.get(key)), default_hex, default_alpha)
325
+ return parsed if parsed is not None else {"hex": default_hex, "alpha": default_alpha}
326
+
327
+ raw_noise = palette_src.get("noiseOpacity", data.get("noiseOpacity"))
328
+ try:
329
+ noise = float(raw_noise) if raw_noise is not None else 1.0
330
+ except (TypeError, ValueError):
331
+ noise = 1.0
332
+ palette = {
333
+ "background": _layer("background", "#041c1c", 1.0),
334
+ "midground": _layer("midground", "#ffe6cb", 1.0),
335
+ "foreground": _layer("foreground", "#ffffff", 0.0),
336
+ "warmGlow": palette_src.get("warmGlow") or data.get("warmGlow") or "rgba(255, 189, 56, 0.35)",
337
+ "noiseOpacity": noise,
338
+ }
339
+
340
+ typo_src = _dict_field(data, "typography")
341
+ typography = dict(_THEME_DEFAULT_TYPOGRAPHY)
342
+ for key in ("fontSans", "fontMono", "fontDisplay", "fontUrl", "baseSize", "lineHeight", "letterSpacing"):
343
+ if _nonempty_str(typo_src.get(key)):
344
+ typography[key] = typo_src[key]
345
+
346
+ layout_src = _dict_field(data, "layout")
347
+ layout = dict(_THEME_DEFAULT_LAYOUT)
348
+ if _nonempty_str(layout_src.get("radius")):
349
+ layout["radius"] = layout_src["radius"]
350
+ density = layout_src.get("density")
351
+ if isinstance(density, str) and density in {"compact", "comfortable", "spacious"}:
352
+ layout["density"] = density
353
+
354
+ color_overrides = {
355
+ k: v for k, v in _dict_field(data, "colorOverrides").items()
356
+ if k in _THEME_OVERRIDE_KEYS and _nonempty_str(v)
357
+ }
358
+
359
+ assets_src = _dict_field(data, "assets")
360
+ assets_out: Dict[str, Any] = {k: assets_src[k] for k in _THEME_NAMED_ASSET_KEYS if _nonempty_str(assets_src.get(k))}
361
+ custom_assets = {k: v for k, v in _dict_field(assets_src, "custom").items() if _css_ident(k) and _nonempty_str(v)}
362
+ if custom_assets:
363
+ assets_out["custom"] = custom_assets
364
+
365
+ custom_css_val = data.get("customCSS")
366
+ custom_css = custom_css_val[:_THEME_CUSTOM_CSS_MAX] if _nonempty_str(custom_css_val) else None
367
+
368
+ component_styles: Dict[str, Dict[str, str]] = {}
369
+ for bucket, props in _dict_field(data, "componentStyles").items():
370
+ if bucket not in _THEME_COMPONENT_BUCKETS or not isinstance(props, dict):
371
+ continue
372
+ clean = {
373
+ prop: str(value) for prop, value in props.items()
374
+ if _css_ident(prop) and isinstance(value, (str, int, float)) and str(value).strip()
375
+ }
376
+ if clean:
377
+ component_styles[bucket] = clean
378
+
379
+ layout_variant = data.get("layoutVariant")
380
+ if not (isinstance(layout_variant, str) and layout_variant in _THEME_LAYOUT_VARIANTS):
381
+ layout_variant = "standard"
382
+
383
+ result: Dict[str, Any] = {
384
+ "name": name,
385
+ "label": data.get("label") or name,
386
+ "description": data.get("description", ""),
387
+ "palette": palette,
388
+ "typography": typography,
389
+ "layout": layout,
390
+ "layoutVariant": layout_variant,
391
+ }
392
+ if color_overrides:
393
+ result["colorOverrides"] = color_overrides
394
+ if assets_out:
395
+ result["assets"] = assets_out
396
+ if custom_css is not None:
397
+ result["customCSS"] = custom_css
398
+ if component_styles:
399
+ result["componentStyles"] = component_styles
400
+ return result
401
+
402
+
403
+ def _discover_user_themes() -> list:
404
+ """Fully-normalised user themes from ``<launch home>/dashboard-themes/*.yaml``.
405
+
406
+ Uses the process launch home, not ``get_hermes_home()``, so a transient profile override
407
+ from embedded chat does not hide themes under the server's own ``HERMES_HOME``.
408
+ """
409
+ themes_dir = get_process_hermes_home() / "dashboard-themes"
410
+ if not themes_dir.is_dir():
411
+ return []
412
+ result = []
413
+ for f in sorted(themes_dir.glob("*.yaml")):
414
+ try:
415
+ data = yaml.safe_load(f.read_text(encoding="utf-8"))
416
+ except Exception:
417
+ continue
418
+ normalised = _normalise_theme_definition(data)
419
+ if normalised is not None:
420
+ result.append(normalised)
421
+ return result
422
+
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # Dashboard plugin system
426
+ # ---------------------------------------------------------------------------
427
+
428
+ def _safe_plugin_api_relpath(api_field: Any, *, dashboard_dir: Path) -> Optional[str]:
429
+ """Validate the manifest's ``api`` field (later imported as a Python module — arbitrary
430
+ code execution by design).
431
+
432
+ An absolute path would swallow the plugin dir (``Path('safe') / '/tmp/evil.py'`` ->
433
+ ``/tmp/evil.py``) and ``../`` could climb out of it (GHSA-5qr3-c538-wm9j). Returns the
434
+ original string when the resolved path stays under ``dashboard_dir``, else ``None`` so
435
+ the plugin still loads its static JS/CSS but its backend ``api`` is rejected.
436
+
437
+ The web server later imports this file as a Python module via ``importlib.util.spec_from_file_location``
438
+ (arbitrary code execution by design — that's how plugins extend the backend). Pre-#29156 the field was
439
+ used as-is, which meant:
440
+ """
441
+ if not isinstance(api_field, str) or not api_field.strip():
442
+ return None
443
+ candidate = Path(api_field)
444
+ if candidate.is_absolute():
445
+ return None
446
+ try:
447
+ (dashboard_dir / candidate).resolve().relative_to(dashboard_dir.resolve())
448
+ except (OSError, RuntimeError, ValueError):
449
+ return None
450
+ return api_field
451
+
452
+
453
+ def _dashboard_plugin_search_dirs() -> List[tuple]:
454
+ """``(root, source)`` pairs to scan, in priority order (first name wins).
455
+
456
+ User dashboard plugins are a dashboard-owned asset (like theme YAML): resolved from the
457
+ process launch home so they don't vanish when a request is scoped to another profile.
458
+ When the process itself is profile-scoped (``HERMES_HOME=<root>/profiles/<name>``) the
459
+ launch home has no ``plugins/`` — user plugins live in the hermes root — so the default
460
+ root is scanned too; profile-local plugins stay authoritative over same-named root ones.
461
+ The project source is gated on shared truthy semantics (``1``/``true``/``yes``/``on``):
462
+ a bare non-empty check let ``=0``/``=false`` silently enable it (GHSA-5qr3-c538-wm9j).
463
+ """
464
+ from hermes_cli.plugins import get_bundled_plugins_dir
465
+ from hermes_constants import get_default_hermes_root
466
+
467
+ bundled_root = get_bundled_plugins_dir()
468
+ # User dashboard plugins are a dashboard-owned asset (same category as theme YAML): resolve them from
469
+ # the process launch home so they don't vanish when a request is scoped to another profile via a
470
+ # context-local HERMES_HOME override (e.g. embedded /chat under --open-profile). #87197: when the
471
+ # process itself is profile-scoped (``--profile <name>`` sets ``HERMES_HOME=<root>/profiles/<name>``),
472
+ # the launch home is the profile directory, which has no ``plugins/`` — user plugins are installed in
473
+ # the hermes root (``~/.hermes/plugins``). Scan the default root as well (``get_default_hermes_root()``
474
+ # unwraps ``<root>/profiles/<name>`` → ``<root>`` and returns a custom ``HERMES_HOME`` unchanged when it
475
+ # *is* the root), mirroring how ``hermes_cli.plugins`` resolves plugin install locations. The
476
+ # ``seen_names`` dedupe below keeps profile-local plugins (if any) authoritative over same-named root
477
+ # plugins.
478
+ user_plugin_roots = [get_process_hermes_home() / "plugins"]
479
+ root_plugins = get_default_hermes_root() / "plugins"
480
+ if root_plugins.resolve(strict=False) != user_plugin_roots[0].resolve(strict=False):
481
+ user_plugin_roots.append(root_plugins)
482
+ search_dirs = [(d, "user") for d in user_plugin_roots]
483
+ search_dirs += [(bundled_root / "memory", "bundled"), (bundled_root, "bundled")]
484
+ # GHSA-5qr3-c538-wm9j (#29156): the previous ``os.environ.get(...)`` check treated *any* non-empty
485
+ # string as truthy, so ``=0``, ``=false``, and ``=no`` — all of which the agent loader and operators
486
+ # correctly read as "disabled" — silently *enabled* the untrusted project source in the web server.
487
+ # Combined with the absolute-path RCE primitive on the manifest's ``api`` field (now patched below),
488
+ # this turned the opt-in into a sticky always-on switch. Use the shared truthy semantics (``1`` /
489
+ # ``true`` / ``yes`` / ``on``) so the gate matches ``hermes_cli/plugins.py`` and the documented user
490
+ # contract.
491
+ if env_var_enabled("HERMES_ENABLE_PROJECT_PLUGINS"):
492
+ search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project"))
493
+ return search_dirs
494
+
495
+
496
+ def _dashboard_plugin_entry(data: Dict[str, Any], name: str, dashboard_dir: Path, source: str) -> Dict[str, Any]:
497
+ # Tab options: ``path`` + ``position`` for a new tab, optional ``override`` to replace a
498
+ # built-in route, and ``hidden`` to register component/slots without adding a tab.
499
+ raw_tab = data.get("tab", {}) if isinstance(data.get("tab"), dict) else {}
500
+ tab_info = {"path": raw_tab.get("path", f"/{name}"), "position": raw_tab.get("position", "end")}
501
+ override_path = raw_tab.get("override")
502
+ if isinstance(override_path, str) and override_path.startswith("/"):
503
+ tab_info["override"] = override_path
504
+ if bool(raw_tab.get("hidden")):
505
+ tab_info["hidden"] = True
506
+ # Slots the plugin populates via ``window.registerSlot(pluginName, slotName, Component)``.
507
+ slots_src = data.get("slots")
508
+ slots = [s for s in slots_src if isinstance(s, str) and s] if isinstance(slots_src, list) else []
509
+ # Validate ``api`` at discovery time so the cached value is already safe for the importer.
510
+ raw_api = data.get("api")
511
+ safe_api = _safe_plugin_api_relpath(raw_api, dashboard_dir=dashboard_dir)
512
+ if raw_api and safe_api is None:
513
+ _log.warning(
514
+ "Plugin %s: refusing unsafe api path %r (must be a "
515
+ "relative file inside the plugin's dashboard/ "
516
+ "directory); backend routes from this plugin will "
517
+ "not be mounted",
518
+ name, raw_api,
519
+ )
520
+ return {
521
+ "name": name,
522
+ "label": data.get("label", name),
523
+ "description": data.get("description", ""),
524
+ "icon": data.get("icon", "Puzzle"),
525
+ "version": data.get("version", "0.0.0"),
526
+ "tab": tab_info,
527
+ "slots": slots,
528
+ "entry": data.get("entry", "dist/index.js"),
529
+ "css": data.get("css"),
530
+ "has_api": bool(safe_api),
531
+ "source": source,
532
+ "_dir": str(dashboard_dir),
533
+ "_api_file": safe_api,
534
+ }
535
+
536
+
537
+ def _discover_dashboard_plugins() -> list:
538
+ """Scan ``<plugins root>/*/dashboard/manifest.json`` across user, bundled and (opt-in)
539
+ project plugin sources — same three sources as ``hermes_cli.plugins``."""
540
+ plugins = []
541
+ seen_names: set = set()
542
+ for plugins_root, source in _dashboard_plugin_search_dirs():
543
+ if not plugins_root.is_dir():
544
+ continue
545
+ with os.scandir(plugins_root) as scan:
546
+ children = sorted((Path(e.path) for e in scan), key=lambda p: p.name)
547
+ for child in children:
548
+ manifest_file = child / "dashboard" / "manifest.json"
549
+ if not child.is_dir() or not manifest_file.exists():
550
+ continue
551
+ try:
552
+ data = json.loads(manifest_file.read_text(encoding="utf-8"))
553
+ name = data.get("name", child.name)
554
+ if name in seen_names:
555
+ continue
556
+ seen_names.add(name)
557
+ plugins.append(_dashboard_plugin_entry(data, name, child / "dashboard", source))
558
+ except Exception as exc:
559
+ _log.warning("Bad dashboard plugin manifest %s: %s", manifest_file, exc)
560
+ continue
561
+ return plugins
562
+
563
+
564
+ def _strip_dashboard_manifest(p: Dict[str, Any]) -> Dict[str, Any]:
565
+ return {k: v for k, v in p.items() if not k.startswith("_")}
566
+
567
+
568
+ _PLUGINS_HUB_CACHE_TTL_SECONDS = 5.0
569
+ _plugins_hub_cache: Optional[Dict[str, Any]] = None
570
+ _plugins_hub_cache_expires_at = 0.0
571
+ _plugins_hub_cache_lock = threading.Lock()
572
+
573
+
574
+ def _invalidate_plugins_hub_cache() -> None:
575
+ global _plugins_hub_cache, _plugins_hub_cache_expires_at
576
+ with _plugins_hub_cache_lock:
577
+ _plugins_hub_cache = None
578
+ _plugins_hub_cache_expires_at = 0.0
579
+
580
+
581
+ _plugins_hub_probe_inflight: set = set()
582
+ _plugins_hub_probe_lock = threading.Lock()
583
+
584
+
585
+ def _schedule_check_fn_probe(fn) -> Optional[threading.Thread]:
586
+ """Warm a cold ``check_fn`` verdict off the request path.
587
+
588
+ The hub read path only consumes cached availability; the only other warmer is the
589
+ tool-schema build, which a dashboard-only session never runs — so a cold cache would
590
+ report ``auth_required=False`` forever. Daemon-thread probe, deduplicated per function;
591
+ the short hub TTL surfaces the verdict on the next fetch. Returns ``None`` when a probe
592
+ for *fn* is already in flight.
593
+ """
594
+ with _plugins_hub_probe_lock:
595
+ if fn in _plugins_hub_probe_inflight:
596
+ return None
597
+ _plugins_hub_probe_inflight.add(fn)
598
+
599
+ def _probe():
600
+ try:
601
+ from tools.registry import _check_fn_cached
602
+
603
+ _check_fn_cached(fn)
604
+ except Exception:
605
+ pass
606
+ finally:
607
+ with _plugins_hub_probe_lock:
608
+ _plugins_hub_probe_inflight.discard(fn)
609
+
610
+ thread = threading.Thread(target=_probe, name="plugins-hub-checkfn-probe", daemon=True)
611
+ thread.start()
612
+ return thread
613
+
614
+
615
+ def _plugin_auth_hint(name: str, provides_tools: list) -> tuple:
616
+ """``(auth_required, auth_command)`` from last-known cached tool availability only.
617
+
618
+ A missing cache entry is "unknown": schedule a background probe rather than probing
619
+ inline (which would starve the root event loop), so the short hub TTL picks it up.
620
+ """
621
+ try:
622
+ from tools.registry import get_cached_check_fn_result, registry
623
+ for tname in provides_tools:
624
+ entry = registry.get_entry(tname)
625
+ if not entry or not entry.check_fn:
626
+ continue
627
+ cached_result = get_cached_check_fn_result(entry.check_fn)
628
+ if cached_result is None:
629
+ _schedule_check_fn_probe(entry.check_fn)
630
+ elif cached_result is False:
631
+ return True, f"hermes auth {name}"
632
+ except Exception:
633
+ pass
634
+ return False, ""
635
+
636
+
637
+ def _plugin_runtime_status(aliases: set, enabled_set: set, disabled_set: set) -> str:
638
+ """enabled / disabled / inactive for a plugin's name+key alias set (disabled wins)."""
639
+ return "disabled" if aliases & disabled_set else "enabled" if aliases & enabled_set else "inactive"
640
+
641
+
642
+ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
643
+ """Agent discovery + dashboard manifests + provider picker metadata.
644
+
645
+ IMPORTANT: powers a dashboard request path, so it must stay read-only and cheap — never
646
+ execute tool ``check_fn`` probes here (imports, auth/network checks would starve the root
647
+ event loop). Only cached availability is consumed and the payload is memoized briefly to
648
+ collapse the dashboard's bursty duplicate fetches.
649
+ """
650
+ from hermes_cli.web_server_memory import _discover_memory_provider_statuses, _normalize_memory_provider_name
651
+ from hermes_cli.web_server import _get_dashboard_plugins
652
+ from hermes_cli.config import get_hermes_home, load_config
653
+ global _plugins_hub_cache, _plugins_hub_cache_expires_at
654
+ now = time.monotonic()
655
+ if not force_refresh:
656
+ with _plugins_hub_cache_lock:
657
+ if _plugins_hub_cache is not None and now < _plugins_hub_cache_expires_at:
658
+ return _plugins_hub_cache
659
+
660
+ started_at = time.monotonic()
661
+ from hermes_cli.plugins_cmd import (
662
+ _discover_all_plugins,
663
+ _get_current_context_engine,
664
+ _get_current_memory_provider,
665
+ _discover_context_engines,
666
+ _get_disabled_set,
667
+ _get_enabled_set,
668
+ _read_manifest as _read_plugin_manifest_at,
669
+ )
670
+ from hermes_cli.plugins_cmd_catalog import removed_annotation
671
+
672
+ dashboard_list = _get_dashboard_plugins()
673
+ dash_by_name = {str(p["name"]): p for p in dashboard_list}
674
+ disabled_set = _get_disabled_set()
675
+ enabled_set = _get_enabled_set()
676
+ hidden_plugins: list = cfg_get(load_config(), "dashboard", "hidden_plugins", default=[]) or []
677
+ plugins_root_resolved = (get_hermes_home() / "plugins").resolve()
678
+ rows: List[Dict[str, Any]] = []
679
+
680
+ for name, version, description, source, dir_str, key in _discover_all_plugins():
681
+ # Both the path-derived key (nested category plugins) and the bare manifest name
682
+ # count for enabled/disabled state, matching the runtime loader's back-compat lookup.
683
+ aliases = {name, key} if key else {name}
684
+ runtime_status = _plugin_runtime_status(aliases, enabled_set, disabled_set)
685
+
686
+ dir_path = Path(dir_str)
687
+ dm = dash_by_name.get(name)
688
+ try:
689
+ dir_path.resolve().relative_to(plugins_root_resolved)
690
+ under_user_tree = True
691
+ except ValueError:
692
+ under_user_tree = False
693
+ can_remove_update = source in {"user", "git"} and under_user_tree and dir_path.is_dir()
694
+
695
+ provides_tools = _read_plugin_manifest_at(dir_path).get("provides_tools") or []
696
+ auth_required, auth_command = _plugin_auth_hint(name, provides_tools) if provides_tools else (False, "")
697
+
698
+ rows.append({
699
+ "name": name,
700
+ "version": version or "",
701
+ "description": description or "",
702
+ "source": source,
703
+ "runtime_status": runtime_status,
704
+ "has_dashboard_manifest": dm is not None or (dir_path / "dashboard" / "manifest.json").exists(),
705
+ "dashboard_manifest": _strip_dashboard_manifest(dm) if dm else None,
706
+ "path": dir_str,
707
+ "can_remove": can_remove_update,
708
+ "can_update_git": can_remove_update and (dir_path / ".git").exists(),
709
+ "auth_required": auth_required,
710
+ "auth_command": auth_command,
711
+ "user_hidden": name in hidden_plugins,
712
+ "removed_reason": removed_annotation(name, dir_str),
713
+ })
714
+
715
+ agent_names = {r["name"] for r in rows}
716
+ orphan_dashboard = [_strip_dashboard_manifest(p) for p in dashboard_list if str(p["name"]) not in agent_names]
717
+ memory_providers = _discover_memory_provider_statuses()
718
+ try:
719
+ context_engines = [{"name": n, "description": desc} for n, desc in _discover_context_engines()]
720
+ except Exception:
721
+ context_engines = []
722
+
723
+ payload = {
724
+ "plugins": rows,
725
+ "orphan_dashboard_plugins": orphan_dashboard,
726
+ "providers": {
727
+ "memory_provider": _normalize_memory_provider_name(_get_current_memory_provider()),
728
+ "memory_options": memory_providers,
729
+ "context_engine": _get_current_context_engine(),
730
+ "context_options": context_engines,
731
+ },
732
+ }
733
+ duration = time.monotonic() - started_at
734
+ if duration >= 0.25:
735
+ _log.info(
736
+ "plugins/hub rebuilt in %.3fs (plugins=%d memory_options=%d)", duration, len(rows), len(memory_providers)
737
+ )
738
+ with _plugins_hub_cache_lock:
739
+ _plugins_hub_cache = payload
740
+ _plugins_hub_cache_expires_at = time.monotonic() + _PLUGINS_HUB_CACHE_TTL_SECONDS
741
+ return payload
742
+
743
+
744
+ def _plugin_api_mount_skip_reason(plugin: Dict[str, Any], enabled_set: set, disabled_set: set) -> Optional[str]:
745
+ """Why a plugin's backend ``api`` must NOT be imported, or None when it may be.
746
+
747
+ User plugins must be in ``plugins.enabled`` and not ``plugins.disabled`` before their
748
+ Python runs (GHSA-mcfc-hp25-cjv7); bundled plugins are trusted but respect an explicit
749
+ disable; project plugins (``./.hermes/plugins/``) ship with the CWD and are
750
+ attacker-controlled when opening a malicious repo — never auto-imported (GHSA-5qr3-c538-wm9j).
751
+ """
752
+ source, plugin_name = plugin.get("source"), plugin.get("name", "")
753
+ if source in ("user", "bundled") and plugin_name in disabled_set:
754
+ return "explicitly disabled"
755
+ if source == "user" and plugin_name not in enabled_set:
756
+ return "not in plugins.enabled"
757
+ return None
758
+
759
+
760
+ def _mount_plugin_api_routes():
761
+ """Import and mount backend API routes from plugins that declare them.
762
+
763
+ Each plugin's ``api`` file must expose a ``router`` (FastAPI APIRouter), mounted under
764
+ ``/api/plugins/<name>/``. See ``_plugin_api_mount_skip_reason`` for the trust gates.
765
+
766
+ Backend import is restricted to ``bundled`` and ``user`` sources. Project plugins
767
+ (``./.hermes/plugins/``) ship with the CWD and are therefore attacker-controlled in any threat model
768
+ where the user opens a malicious repo; they can extend the dashboard UI via static JS/CSS but their
769
+ Python ``api`` file is never auto-imported by the web server. See GHSA-5qr3-c538-wm9j (#29156).
770
+ Additionally, user plugins must be explicitly enabled via the ``plugins.enabled`` allow-list in
771
+ config.yaml before their backend code is imported. Without this gate, an installed-but-not-enabled
772
+ plugin's Python code would execute at dashboard startup — a code execution vector that bypasses the
773
+ user's intent. (#46435, GHSA-mcfc-hp25-cjv7)
774
+ """
775
+ from hermes_cli.web_server import _get_dashboard_plugins, app
776
+ try:
777
+ from hermes_cli.plugins_cmd import _get_enabled_set, _get_disabled_set
778
+ enabled_set = _get_enabled_set()
779
+ disabled_set = _get_disabled_set()
780
+ except Exception:
781
+ enabled_set = set()
782
+ disabled_set = set()
783
+
784
+ for plugin in _get_dashboard_plugins():
785
+ api_file_name = plugin.get("_api_file")
786
+ if not api_file_name:
787
+ continue
788
+ skip = _plugin_api_mount_skip_reason(plugin, enabled_set, disabled_set)
789
+ if skip:
790
+ _log.debug("Plugin %s: skipping API mount (%s)", plugin.get("name", ""), skip)
791
+ continue
792
+ if plugin.get("source") == "project":
793
+ _log.warning(
794
+ "Plugin %s: ignoring backend api=%s (project plugins may "
795
+ "not auto-import Python code; move the plugin to "
796
+ "~/.hermes/plugins/ if you trust it)",
797
+ plugin["name"], api_file_name,
798
+ )
799
+ continue
800
+ dashboard_dir = Path(plugin["_dir"])
801
+ api_path = dashboard_dir / api_file_name
802
+ try:
803
+ api_path.resolve().relative_to(dashboard_dir.resolve())
804
+ except (OSError, RuntimeError, ValueError):
805
+ # Discovery already filters this; defence in depth in case ``_dir`` was tampered
806
+ # with after caching or a future caller bypasses the validator.
807
+ _log.warning(
808
+ "Plugin %s: refusing to import api file outside its "
809
+ "dashboard directory (%s)", plugin["name"], api_path,
810
+ )
811
+ continue
812
+ if not api_path.exists():
813
+ _log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name)
814
+ continue
815
+ try:
816
+ module_name = f"hermes_dashboard_plugin_{plugin['name']}"
817
+ spec = importlib.util.spec_from_file_location(module_name, api_path)
818
+ if spec is None or spec.loader is None:
819
+ continue
820
+ mod = importlib.util.module_from_spec(spec)
821
+ # Register in sys.modules BEFORE exec_module so pydantic/FastAPI can resolve
822
+ # string annotations (``from __future__ import annotations``) by module name.
823
+ sys.modules[module_name] = mod
824
+ try:
825
+ spec.loader.exec_module(mod)
826
+ except Exception:
827
+ sys.modules.pop(module_name, None)
828
+ raise
829
+ router = getattr(mod, "router", None)
830
+ if router is None:
831
+ _log.warning("Plugin %s api file has no 'router' attribute", plugin["name"])
832
+ continue
833
+ app.include_router(router, prefix=f"/api/plugins/{plugin['name']}")
834
+ _log.info("Mounted plugin API routes: /api/plugins/%s/", plugin["name"])
835
+ except Exception as exc:
836
+ _log.warning("Failed to load plugin %s API routes: %s", plugin["name"], exc)
hermes_cli/web_server_sessions.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Session-DB access for the dashboard: per-profile SessionDB opening with schema
2
+ heal, latest-descendant lookup and the auto-archive ticker.
3
+ """
4
+
5
+ import logging
6
+ import asyncio
7
+ import threading
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Dict, Optional
11
+
12
+ # Same logger the code used before extraction (record parity).
13
+ _log = logging.getLogger("hermes_cli.web_server")
14
+
15
+ _DESCENDANTS_SQL = """
16
+ WITH RECURSIVE descendants(id, parent_session_id, started_at) AS (
17
+ SELECT id, parent_session_id, started_at FROM sessions WHERE id = ?
18
+ UNION
19
+ SELECT s.id, s.parent_session_id, s.started_at
20
+ FROM sessions s
21
+ JOIN descendants d ON s.parent_session_id = d.id
22
+ )
23
+ SELECT id, parent_session_id, started_at FROM descendants
24
+ """
25
+
26
+
27
+ def _session_latest_descendant(session_id: str, db):
28
+ """Resolve a session id to the newest child leaf session.
29
+
30
+ /model may create child sessions; a dashboard refresh should continue the
31
+ newest child instead of reopening the old parent. Returns ``(leaf, path)``.
32
+ """
33
+ sid = db.resolve_session_id(session_id)
34
+ if not sid or not db.get_session(sid):
35
+ return None, []
36
+
37
+ conn = getattr(db, "_conn", None)
38
+ if conn is not None:
39
+ keys = ("id", "parent_session_id", "started_at")
40
+ rows = [dict(zip(keys, row)) for row in conn.execute(_DESCENDANTS_SQL, (sid,)).fetchall()]
41
+ else:
42
+ rows = db.list_sessions_rich(limit=10000, offset=0, compact_rows=True)
43
+
44
+ children = {}
45
+ for row in rows:
46
+ rid = row.get("id")
47
+ parent = row.get("parent_session_id")
48
+ if rid and parent:
49
+ children.setdefault(parent, []).append(row)
50
+
51
+ def started(row):
52
+ try:
53
+ return float(row.get("started_at") or 0)
54
+ except Exception:
55
+ return 0.0
56
+
57
+ current = sid
58
+ path = [sid]
59
+ seen = {sid}
60
+ while children.get(current):
61
+ candidates = [r for r in children[current] if r.get("id") not in seen]
62
+ if not candidates:
63
+ break
64
+ candidates.sort(key=started, reverse=True)
65
+ current = candidates[0]["id"]
66
+ path.append(current)
67
+ seen.add(current)
68
+ return current, path
69
+
70
+
71
+ # Serialises the one-time writable schema bootstrap for read-only opens, so
72
+ # concurrent first-load polls don't open mode=ro against a half-written schema
73
+ # ("no such table: sessions").
74
+ _session_db_bootstrap_lock = threading.Lock()
75
+
76
+
77
+ def _session_db_read_probe_statements() -> tuple:
78
+ """Stale-schema probes for read-only opens (which skip _reconcile_columns()).
79
+ Derived from SCHEMA_SQL so a new column is probed automatically — a
80
+ hand-written list once went stale and emptied the sidebar after update."""
81
+ from hermes_state_schema import schema_read_probe_statements
82
+
83
+ return schema_read_probe_statements()
84
+
85
+
86
+ # Stores where a heal WRITABLE OPEN SUCCEEDED but the read probe still failed:
87
+ # one reconciliation cannot fix them (e.g. a NOT-NULL-without-default column),
88
+ # so they fall back to the raw read-only open until restart instead of paying
89
+ # a writable init per poll. A FAILED writable open (transient lock) is NOT
90
+ # recorded — the next poll retries the heal.
91
+ _session_db_heal_exhausted: set = set()
92
+
93
+ # Deduplicates the heal-failure warning per store per process.
94
+ _session_db_heal_warned: set = set()
95
+
96
+
97
+ def _is_stale_schema_error(exc: BaseException) -> bool:
98
+ message = str(exc).lower()
99
+ return "no such table" in message or "no such column" in message
100
+
101
+
102
+ def _open_session_db_at_path(db_path: Path, *, read_only: bool):
103
+ """Open a SessionDB at an explicit path with an explicit access mode.
104
+
105
+ Read-only opens bootstrap a missing/zero-byte store once and heal a stale or
106
+ malformed schema through ONE writable open before reopening read-only; the
107
+ healthy read path never takes a write lock. Tables outside SCHEMA_SQL
108
+ (telemetry ``tel_*``, FTS shadow tables) are outside both probe and heal.
109
+ """
110
+ import sqlite3
111
+
112
+ from hermes_state import SessionDB, is_malformed_schema_error
113
+ from hermes_state_registry import acquire, release_or_close
114
+
115
+ # Read-only file/sidecar preflight (port of kilocode#12508): repair-or-refuse BEFORE the first
116
+ # connection so users get an actionable message instead of an opaque "attempt to write a readonly
117
+ # database" from deep inside _init_schema.
118
+ if not read_only:
119
+ return acquire(db_path)
120
+
121
+ def _needs_bootstrap() -> bool:
122
+ try:
123
+ return db_path.stat().st_size == 0
124
+ except FileNotFoundError:
125
+ return True
126
+ except OSError:
127
+ return False
128
+
129
+ if _needs_bootstrap():
130
+ with _session_db_bootstrap_lock:
131
+ if _needs_bootstrap():
132
+ db = acquire(db_path)
133
+ release_or_close(db)
134
+
135
+ def _open_probed():
136
+ db = SessionDB(db_path=db_path, read_only=True)
137
+ # Unit-test fakes may replace SessionDB without exposing a raw
138
+ # connection. Probe only real connections.
139
+ conn = getattr(db, "_conn", None)
140
+ if conn is not None and str(db_path) not in _session_db_heal_exhausted:
141
+ try:
142
+ for statement in _session_db_read_probe_statements():
143
+ conn.execute(statement).fetchone()
144
+ except BaseException:
145
+ db.close()
146
+ raise
147
+ return db
148
+
149
+ try:
150
+ return _open_probed()
151
+ except (sqlite3.DatabaseError, UnicodeDecodeError) as exc:
152
+ # UnicodeDecodeError = pysqlite could not decode SQLite's own error
153
+ # message because corrupt file bytes were embedded in it; the
154
+ # one-writable-open heal is the only repair path, so treat it as
155
+ # malformed schema.
156
+ if not (
157
+ _is_stale_schema_error(exc)
158
+ or is_malformed_schema_error(exc)
159
+ or isinstance(exc, UnicodeDecodeError)):
160
+ raise
161
+ db = acquire(db_path)
162
+ release_or_close(db)
163
+ try:
164
+ return _open_probed()
165
+ except (sqlite3.DatabaseError, UnicodeDecodeError) as still_stale:
166
+ if not _is_stale_schema_error(still_stale):
167
+ raise
168
+ # Writable open succeeded but the store is STILL behind the probe:
169
+ # serve reads without the probe (only queries touching the broken
170
+ # part fail) and stop paying the writable init per poll.
171
+ _session_db_heal_exhausted.add(str(db_path))
172
+ if str(db_path) not in _session_db_heal_warned:
173
+ _session_db_heal_warned.add(str(db_path))
174
+ _log.warning(
175
+ "state.db at %s is missing schema that a writable "
176
+ "reconcile could not add (%s); read paths may partially "
177
+ "fail until the store is repaired",
178
+ db_path,
179
+ still_stale)
180
+ return _open_probed()
181
+
182
+
183
+ def _session_db_path_for_profile(profile: Optional[str]) -> Path:
184
+ """state.db path for ``profile`` (None/empty = this process's own)."""
185
+ from hermes_cli.web_server_cron import _cron_profile_home
186
+ from hermes_state import _default_db_path
187
+
188
+ if profile:
189
+ _name, home = _cron_profile_home(profile)
190
+ return Path(home) / "state.db"
191
+ return Path(_default_db_path())
192
+
193
+
194
+ def _open_session_db_for_profile(profile: Optional[str], *, read_only: bool):
195
+ """Open a SessionDB for ``profile`` (None/empty = this process's own state.db).
196
+
197
+ Access-mode semantics: see :func:`_open_session_db_at_path`.
198
+ """
199
+ return _open_session_db_at_path(_session_db_path_for_profile(profile), read_only=read_only)
200
+
201
+
202
+ # In-process throttle for the opportunistic auto-archive trigger, keyed by
203
+ # profile: bounds the config.yaml read to once per window; the sweep itself is
204
+ # throttled far more coarsely by state_meta (sessions.min_interval_hours).
205
+ _AUTO_ARCHIVE_CHECK_INTERVAL_S = 300.0
206
+ _last_auto_archive_check: Dict[str, float] = {}
207
+
208
+
209
+ def _maybe_auto_archive_for_profile(profile: Optional[str]) -> None:
210
+ """Config-gated stale-session auto-archive for ``profile``; never raises.
211
+ ``hermes serve`` runs neither CLI nor gateway startup hooks, so this
212
+ session-list trigger is what makes ``sessions.auto_archive`` work there."""
213
+ try:
214
+ key = profile or ""
215
+ now = time.monotonic()
216
+ last = _last_auto_archive_check.get(key)
217
+ if last is not None and now - last < _AUTO_ARCHIVE_CHECK_INTERVAL_S:
218
+ return
219
+ _last_auto_archive_check[key] = now
220
+
221
+ from hermes_cli.config import load_config as _load_full_config
222
+ cfg = (_load_full_config().get("sessions") or {})
223
+ if not cfg.get("auto_archive", False):
224
+ return
225
+ db = _open_session_db_for_profile(profile, read_only=False)
226
+ try:
227
+ db.maybe_auto_archive(
228
+ idle_days=float(cfg.get("auto_archive_days", 3)),
229
+ min_interval_hours=int(cfg.get("min_interval_hours", 24)))
230
+ finally:
231
+ db.close()
232
+ except Exception as exc:
233
+ _log.debug("opportunistic auto-archive skipped: %s", exc)
234
+
235
+
236
+ def _skill_maintenance_idle_for(started_at: float) -> Optional[float]:
237
+ """Measure chat inactivity, not socket inactivity (Desktop stays connected)."""
238
+ import tui_gateway.server as gateway
239
+
240
+ from hermes_constants import get_hermes_home
241
+
242
+ home = get_hermes_home().resolve()
243
+ with gateway._sessions_lock:
244
+ sessions = [session for session in gateway._sessions.values()
245
+ if Path(session.get("profile_home") or home).resolve() == home]
246
+ if any(session.get("running") for session in sessions):
247
+ return None
248
+ last_active = max(
249
+ [started_at, gateway._closed_session_activity.get(str(home), 0)]
250
+ + [float(session.get("last_active") or started_at) for session in sessions])
251
+ return max(0.0, time.time() - last_active)
252
+
253
+
254
+ def _maybe_run_skill_maintenance(started_at: float) -> None:
255
+ from hermes_constants import get_hermes_home
256
+ from hermes_cli.profiles import _check_gateway_running
257
+
258
+ # A live messaging gateway already owns these chores for this profile.
259
+ if _check_gateway_running(get_hermes_home()):
260
+ return
261
+
262
+ from agent.curator import maybe_run_curator
263
+ from tools.skills_sync_client import maybe_pull_skills
264
+ from tools.skills_sync_client_org import maybe_pull_org_skills
265
+
266
+ try:
267
+ idle_for = _skill_maintenance_idle_for(started_at)
268
+ if idle_for is not None:
269
+ maybe_run_curator(idle_for_seconds=idle_for)
270
+ except Exception as exc:
271
+ _log.debug("serve curator tick skipped: %s", exc)
272
+ for pull in (maybe_pull_skills, maybe_pull_org_skills):
273
+ try:
274
+ pull()
275
+ except Exception as exc:
276
+ _log.debug("serve skill sync tick skipped: %s", exc)
277
+
278
+
279
+ async def _auto_archive_ticker_loop(
280
+ interval_s: float = 3600.0, initial_delay_s: float = 90.0) -> None:
281
+ """Poll maintenance for this serve profile, including Desktop-only installs.
282
+
283
+ Individual chores own their config/interval gates. Curator additionally uses
284
+ real chat inactivity; merely keeping a Desktop WebSocket open is not activity.
285
+ """
286
+ started_at = time.time()
287
+
288
+ def _sweep() -> None:
289
+ _maybe_auto_archive_for_profile(None)
290
+ _maybe_run_skill_maintenance(started_at)
291
+
292
+ await asyncio.sleep(initial_delay_s)
293
+ while True:
294
+ try:
295
+ await asyncio.to_thread(_sweep)
296
+ except Exception as exc:
297
+ _log.debug("auto-archive tick skipped: %s", exc)
298
+ await asyncio.sleep(interval_s)
hermes_constants.py ADDED
@@ -0,0 +1,1293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared constants for Hermes Agent.
2
+
3
+ Import-safe, stdlib-only — importable from anywhere without circular-import risk.
4
+ """
5
+
6
+ import contextlib
7
+ import os
8
+ import re
9
+ import shutil
10
+ import stat
11
+ import sys
12
+ from contextvars import ContextVar, Token
13
+ from pathlib import Path
14
+
15
+ _profile_fallback_warned: bool = False
16
+ _UNSET = object()
17
+ _HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar("_HERMES_HOME_OVERRIDE", default=_UNSET)
18
+
19
+ # TUI busy-indicator styles (CLI /indicator, TUI gateway config, /help registry).
20
+ # Keep in sync with INDICATOR_STYLES / DEFAULT_INDICATOR_STYLE in ui-tui/src/app/interfaces.ts.
21
+ INDICATOR_STYLES: tuple[str, ...] = ("ascii", "emoji", "kaomoji", "unicode")
22
+ DEFAULT_INDICATOR_STYLE: str = "kaomoji"
23
+
24
+
25
+ def set_hermes_home_override(path: str | Path | None) -> Token:
26
+ """Set a context-local Hermes home override and return its reset token.
27
+
28
+ Deliberately does not mutate ``os.environ`` (shared by every thread in the process).
29
+ """
30
+ value: str | object = _UNSET if path is None else str(path)
31
+ return _HERMES_HOME_OVERRIDE.set(value)
32
+
33
+
34
+ def reset_hermes_home_override(token: Token) -> None:
35
+ """Restore the previous context-local Hermes home override."""
36
+ _HERMES_HOME_OVERRIDE.reset(token)
37
+
38
+
39
+ def get_hermes_home_override() -> str | None:
40
+ """Return the active context-local Hermes home override, if any."""
41
+ override = _HERMES_HOME_OVERRIDE.get()
42
+ return str(override) if override is not _UNSET and override else None
43
+
44
+
45
+ def _get_platform_default_hermes_home() -> Path:
46
+ """Return the platform-native default Hermes home path."""
47
+ if sys.platform == "win32":
48
+ local_appdata = os.environ.get("LOCALAPPDATA", "").strip()
49
+ base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local"
50
+ return base / "hermes"
51
+ return Path.home() / ".hermes"
52
+
53
+
54
+ def sudo_invoker_default_home() -> Path | None:
55
+ """The invoking user's native ``~/.hermes`` when this process is root under ``sudo``, else None.
56
+
57
+ sudo strips HERMES_HOME and sets HOME=/root, so the process's own default is root's; the profile
58
+ store and the system service being operated on belong to SUDO_USER.
59
+ """
60
+ if not hasattr(os, "geteuid") or os.geteuid() != 0:
61
+ return None
62
+ sudo_user = os.environ.get("SUDO_USER", "").strip()
63
+ if not sudo_user or sudo_user == "root":
64
+ return None
65
+ import pwd
66
+
67
+ try:
68
+ return Path(pwd.getpwnam(sudo_user).pw_dir) / ".hermes"
69
+ except KeyError: # SUDO_USER not in passwd (chroot/container)
70
+ return None
71
+
72
+
73
+ def _warn_profile_fallback_once() -> None:
74
+ """Warn once when HERMES_HOME is unset but a non-default profile is sticky-active (wrong fallback)."""
75
+ global _profile_fallback_warned
76
+ if _profile_fallback_warned:
77
+ return
78
+ try:
79
+ fallback_home = _get_platform_default_hermes_home()
80
+ active_path = fallback_home / "active_profile"
81
+ active = active_path.read_text(encoding="utf-8").strip() if active_path.exists() else ""
82
+ except (UnicodeDecodeError, OSError):
83
+ active = ""
84
+ if active and active != "default":
85
+ _profile_fallback_warned = True
86
+ # Direct stderr, not logging: runs at import time (often before logging is
87
+ # configured) and root-logger propagation would double-emit.
88
+ msg = (
89
+ f"[HERMES_HOME fallback] HERMES_HOME is unset but active "
90
+ f"profile is {active!r}. Falling back to {fallback_home}, which "
91
+ f"is the DEFAULT profile — not {active!r}. Any data this "
92
+ f"process writes will land in the wrong profile. The "
93
+ f"subprocess spawner should pass HERMES_HOME explicitly "
94
+ f"(see issue #18594)."
95
+ )
96
+ with contextlib.suppress(Exception):
97
+ sys.stderr.write(msg + "\n")
98
+ sys.stderr.flush()
99
+
100
+
101
+ def get_hermes_home() -> Path:
102
+ """Hermes home: context-local override → ``HERMES_HOME`` env var → platform default."""
103
+ override = get_hermes_home_override()
104
+ if override:
105
+ return Path(override)
106
+ if not os.environ.get("HERMES_HOME", "").strip():
107
+ _warn_profile_fallback_once()
108
+ return get_process_hermes_home()
109
+
110
+
111
+ # Resolved keys, keyed by the path string that was handed in. Path.resolve()
112
+ # is a filesystem call, and this function sits under every ToolRegistry
113
+ # lookup through current_scope_key(), so without this the registry pays a
114
+ # syscall per lookup. A process only ever sees a handful of home paths, so
115
+ # the dict stays tiny. Only paths that really exist are stored, see below.
116
+ _HOME_KEY_CACHE: dict[str, str] = {}
117
+
118
+
119
+ def hermes_home_key(path: str | Path | None = None) -> str:
120
+ """Stable registry key for a Hermes home/profile dir.
121
+
122
+ ``strict=False`` so profiles whose directories don't exist yet still get a key.
123
+
124
+ The resolved value is remembered per input path. A directory that does
125
+ not exist yet is resolved without touching the cache, because the answer
126
+ can change once it is created (e.g. part of the path turns out to be a symlink).
127
+ """
128
+ candidate = Path(path) if path is not None else get_hermes_home()
129
+ raw = str(candidate)
130
+ cached = _HOME_KEY_CACHE.get(raw)
131
+ if cached is not None:
132
+ return cached
133
+ expanded = candidate.expanduser()
134
+ try:
135
+ resolved = expanded.resolve(strict=True)
136
+ except OSError:
137
+ # Not on disk yet: lenient resolve, not stored, so the real answer is
138
+ # picked up once the directory appears.
139
+ return os.path.normcase(str(expanded.resolve(strict=False)))
140
+ key = os.path.normcase(str(resolved))
141
+ _HOME_KEY_CACHE[raw] = key
142
+ return key
143
+
144
+
145
+ def reset_hermes_home_key_cache() -> None:
146
+ """Forget every remembered home key (for tests that move a home dir on disk)."""
147
+ _HOME_KEY_CACHE.clear()
148
+
149
+
150
+ def get_process_hermes_home() -> Path:
151
+ """Hermes home of the running process, ignoring task overrides.
152
+
153
+ For process-level assets (theme YAML, dashboard plugin manifests) that must stay visible while a
154
+ request is scoped to another profile (e.g. embedded ``/chat`` under ``--open-profile``).
155
+ """
156
+ val = os.environ.get("HERMES_HOME", "").strip()
157
+ return Path(val) if val else _get_platform_default_hermes_home()
158
+
159
+
160
+ # get_default_hermes_root() memo keyed on (native home, HERMES_HOME) so it stays
161
+ # fresh when a test or plugin mutates HERMES_HOME; saves ~80us/call at 31+ sites.
162
+ _default_hermes_root_memo: "tuple[str, str, Path] | None" = None
163
+
164
+
165
+ def get_default_hermes_root() -> Path:
166
+ """Root Hermes dir for profile-level ops: ``<root>`` when ``HERMES_HOME=<root>/profiles/<name>``."""
167
+ global _default_hermes_root_memo
168
+ native_home = _get_platform_default_hermes_home()
169
+ env_home = os.environ.get("HERMES_HOME", "")
170
+ memo = _default_hermes_root_memo
171
+ if memo is not None and memo[:2] == (str(native_home), env_home):
172
+ return memo[2]
173
+ result = native_home
174
+ if env_home:
175
+ env_path = Path(env_home)
176
+ try:
177
+ env_path.resolve().relative_to(native_home.resolve()) # under ~/.hermes (normal or profile mode)
178
+ except ValueError: # Docker/custom root: <root>/profiles/<name> -> <root>, else HERMES_HOME itself
179
+ result = env_path.parent.parent if env_path.parent.name == "profiles" else env_path
180
+ _default_hermes_root_memo = (str(native_home), env_home, result)
181
+ return result
182
+
183
+
184
+ # Tombstone lives beside the profile dir (not inside) so a stale mkdir or rmtree cannot erase it.
185
+ _DELETED_PROFILES_DIR = ".deleted"
186
+ # Files marking a real Hermes home; arbitrary dirs with a ``profiles`` segment lack them.
187
+ _HERMES_HOME_MARKERS = ("config.yaml", ".env", "state.db")
188
+
189
+
190
+ def _is_hermes_profiles_root(profiles_dir: Path) -> bool:
191
+ """True when *profiles_dir* is provably ``<hermes-home>/profiles``.
192
+
193
+ Accepts the classic ``~/.hermes`` layout, a root carrying Hermes-home marker files, a
194
+ ``profiles/.deleted`` tombstone dir (only ``profile delete`` creates it), or the default root.
195
+ """
196
+ root = profiles_dir.parent
197
+ if root.name == ".hermes":
198
+ return True
199
+ try:
200
+ if (profiles_dir / _DELETED_PROFILES_DIR).is_dir() or any(
201
+ (root / marker).exists() for marker in _HERMES_HOME_MARKERS
202
+ ):
203
+ return True
204
+ except OSError:
205
+ pass
206
+ try:
207
+ return root.resolve(strict=False) == get_default_hermes_root().resolve(strict=False)
208
+ except OSError:
209
+ return False
210
+
211
+
212
+ def named_profile_home(path: str | Path) -> Path | None:
213
+ """Return ``<root>/profiles/<name>`` when *path* is that home or under it.
214
+
215
+ Requires ``<name>`` not to start with ``.`` and the ``profiles`` parent to be a real Hermes home;
216
+ a default home whose path merely contains a ``profiles`` segment is not a named profile.
217
+ """
218
+ current = Path(path)
219
+ for candidate in (current, *current.parents):
220
+ if (candidate.parent.name == "profiles" and not candidate.name.startswith(".")
221
+ and _is_hermes_profiles_root(candidate.parent)):
222
+ return candidate
223
+ if candidate.name == ".hermes": # default home: a coincidental profiles/ ancestor is not a root
224
+ return None
225
+ return None
226
+
227
+
228
+ def profile_name_for_home(path: str | Path | None) -> str | None:
229
+ """Return the canonical profile id owning *path*, or ``None`` when it is not a profile home.
230
+
231
+ The default home is the Hermes root itself, so its basename is an installation detail (``.hermes``
232
+ on POSIX and commonly ``hermes`` on Windows), not the profile id ``default``.
233
+ """
234
+ if path is None or not str(path).strip():
235
+ return None
236
+ current = Path(path).expanduser()
237
+ try:
238
+ default_root = get_default_hermes_root()
239
+ for candidate in (current, current.resolve(strict=False)):
240
+ if candidate == default_root or candidate == default_root.resolve(strict=False):
241
+ return "default"
242
+ named = named_profile_home(candidate)
243
+ if named is not None:
244
+ return named.name
245
+ # A stored profile home is authoritative: its owner already resolved it, so the
246
+ # <root>/profiles/<name> shape names the profile even when <root> carries no markers.
247
+ if candidate.parent.name == "profiles" and not candidate.name.startswith("."):
248
+ return candidate.name
249
+ except (OSError, RuntimeError, ValueError):
250
+ return None
251
+ return None
252
+
253
+
254
+ def profile_tombstone_path(profile_home: Path) -> Path:
255
+ return profile_home.parent / _DELETED_PROFILES_DIR / profile_home.name
256
+
257
+
258
+ def named_profile_is_deleted(profile_home: str | Path) -> bool:
259
+ return profile_tombstone_path(Path(profile_home)).exists()
260
+
261
+
262
+ def mark_named_profile_deleted(profile_home: str | Path) -> None:
263
+ marker = profile_tombstone_path(Path(profile_home))
264
+ marker.parent.mkdir(parents=True, exist_ok=True)
265
+ marker.write_text("deleted\n", encoding="utf-8")
266
+
267
+
268
+ def clear_named_profile_deleted(profile_home: str | Path) -> None:
269
+ profile_tombstone_path(Path(profile_home)).unlink(missing_ok=True)
270
+
271
+
272
+ def assert_named_profile_home_live(path: str | Path) -> None:
273
+ """Refuse missing or tombstoned named profile homes."""
274
+ home = named_profile_home(path)
275
+ if home is not None and (named_profile_is_deleted(home) or not home.exists()):
276
+ raise FileNotFoundError(
277
+ f"Named profile home does not exist: {home}. "
278
+ "Create the profile explicitly before using it."
279
+ )
280
+
281
+
282
+ def mkdir_under_hermes_home(path: str | Path) -> Path:
283
+ """Create *path*, but never materialize a deleted/missing named profile."""
284
+ target = Path(path)
285
+ assert_named_profile_home_live(target)
286
+ target.mkdir(parents=True, exist_ok=True)
287
+ return target
288
+
289
+
290
+ def _packaged_dir(env_var: str, default: Path | None, subdir: str) -> Path:
291
+ """Resolve a package-manager-relocatable directory.
292
+
293
+ Order: *env_var* (Nix wrapper / explicit override) → caller ``default`` (source checkout) →
294
+ ``<HERMES_HOME>/<subdir>``.
295
+ """
296
+ override = os.getenv(env_var, "").strip()
297
+ return Path(override) if override else default if default is not None else get_hermes_home() / subdir
298
+
299
+
300
+ def get_optional_skills_dir(default: Path | None = None) -> Path:
301
+ """Return the optional-skills directory, honoring package-manager wrappers."""
302
+ return _packaged_dir("HERMES_OPTIONAL_SKILLS", default, "optional-skills")
303
+
304
+
305
+ def get_optional_mcps_dir(default: Path | None = None) -> Path:
306
+ """Return the optional-mcps directory, honoring package-manager wrappers (``HERMES_OPTIONAL_MCPS``)."""
307
+ return _packaged_dir("HERMES_OPTIONAL_MCPS", default, "optional-mcps")
308
+
309
+
310
+ def get_bundled_skills_dir(default: Path | None = None) -> Path:
311
+ """Return the bundled skills directory, honoring package-manager wrappers (``HERMES_BUNDLED_SKILLS``)."""
312
+ return _packaged_dir("HERMES_BUNDLED_SKILLS", default, "skills")
313
+
314
+
315
+ def get_hermes_dir(new_subpath: str, old_name: str, *, home: Path | None = None) -> Path:
316
+ """Resolve a Hermes subdirectory, honouring a populated legacy ``<old_name>/`` (no migration).
317
+
318
+ An empty legacy dir does NOT count (install scaffolds, manual mkdir) so it cannot shadow the new path.
319
+
320
+ A bare empty ``<old_name>/`` directory does **not** count as "the legacy install is in use" — install
321
+ scaffolds, manual ``mkdir`` work, and cleared-then-abandoned locations all create empty stubs that would
322
+ otherwise silently shadow real data populated at ``<new_subpath>/``. See #27602 for the pairing-store
323
+ regression where a dormant empty ``pairing/`` orphaned approved-user data in ``platforms/pairing/``.
324
+ """
325
+ home = home or get_hermes_home()
326
+ old_path = home / old_name
327
+ return old_path if _legacy_path_has_content(old_path) else home / new_subpath
328
+
329
+
330
+ def iter_hermes_node_dirs(home: Path | None = None) -> list[Path]:
331
+ """Hermes-managed Node dirs in lookup order; both Windows and POSIX shapes so migrated installs work.
332
+
333
+ Keep in sync with hermesManagedNodePathEntries() in apps/desktop/electron/backend-env.ts.
334
+ """
335
+ node_dir = (home or get_hermes_home()) / "node"
336
+ return [node_dir, node_dir / "bin"] if sys.platform == "win32" else [node_dir / "bin", node_dir]
337
+
338
+
339
+ _WINDOWS_NODE_SHIMS = {
340
+ "npm": ["npm.cmd", "npm.exe", "npm"], "npx": ["npx.cmd", "npx.exe", "npx"], "node": ["node.exe", "node"],
341
+ }
342
+
343
+
344
+ def _candidate_node_command_names(command: str) -> list[str]:
345
+ base = Path(command).name
346
+ if sys.platform != "win32" or "." in base:
347
+ return [base]
348
+ # Prefer npm.cmd: PowerShell may block npm.ps1 by policy; CreateProcess cannot launch a bare .ps1.
349
+ return _WINDOWS_NODE_SHIMS.get(base.lower(), [f"{base}.cmd", f"{base}.exe", base])
350
+
351
+
352
+ def _iter_managed_node_candidates(names: list[str], home: Path | None = None):
353
+ """Yield existing (and on POSIX, executable) ``<node-dir>/<name>`` files."""
354
+ for directory in iter_hermes_node_dirs(home):
355
+ for name in names:
356
+ candidate = directory / name
357
+ if candidate.is_file() and (sys.platform == "win32" or os.access(candidate, os.X_OK)):
358
+ yield candidate
359
+
360
+
361
+ def _first_runnable_managed(names: list[str]) -> tuple[str | None, bool]:
362
+ """Return ``(first runnable candidate, saw a broken one)``."""
363
+ broken = False
364
+ for candidate in map(str, _iter_managed_node_candidates(names)):
365
+ if node_tool_runnable(candidate):
366
+ return candidate, broken
367
+ broken = True
368
+ return None, broken
369
+
370
+
371
+ def _run_version_probe(argv: list[str], **kwargs):
372
+ """Run a hidden ``--version`` probe; ``None`` when it cannot run."""
373
+ import subprocess
374
+ try:
375
+ from hermes_cli._subprocess_compat import windows_hide_flags
376
+ return subprocess.run(
377
+ argv, capture_output=True, timeout=10, creationflags=windows_hide_flags(), **kwargs
378
+ )
379
+ except (OSError, subprocess.TimeoutExpired, ValueError):
380
+ return None
381
+
382
+
383
+ def _version_probe_ok(path: str) -> bool:
384
+ """True when ``<path> --version`` exits 0 under the Hermes-managed Node PATH."""
385
+ result = _run_version_probe([path, "--version"], env=with_hermes_node_path())
386
+ return result is not None and result.returncode == 0
387
+
388
+
389
+ _HERMES_NODE_TARGET_MAJOR = int(os.environ.get("HERMES_NODE_TARGET_MAJOR", "22"))
390
+ _managed_node_heal_attempted = False
391
+ _NODE_BOOTSTRAP_SCRIPT = Path(__file__).resolve().parent / "scripts" / "lib" / "node-bootstrap.sh"
392
+
393
+ # Install tree root (this file lives at <install_root>/hermes_constants.py). Used by secure_parent_dir() to
394
+ # skip chmod on the install dir — chmodding it 0700 breaks hermes-user traversal in Docker (UID 10000). See
395
+ # #25821, #93050.
396
+ _INSTALL_ROOT = Path(__file__).resolve().parent
397
+
398
+
399
+ def _is_executable_file(path: str) -> bool:
400
+ """``exists()`` follows symlinks, so a dangling link never spawns a probe."""
401
+ return os.path.exists(path) and os.access(path, os.X_OK)
402
+
403
+
404
+ def node_tool_runnable(path: str | None) -> bool:
405
+ """True only when *path* is a Node/npm/npx binary that actually runs (``--version`` probe)."""
406
+ if not path:
407
+ return False
408
+ present = Path(path).is_file() if sys.platform == "win32" else _is_executable_file(path)
409
+ return present and _version_probe_ok(path)
410
+
411
+
412
+ def hermes_managed_node_tree_present(home: Path | None = None) -> bool:
413
+ """Return True when any Hermes-managed node/npm/npx shim exists on disk."""
414
+ names = [n for c in ("node", "npm", "npx") for n in _candidate_node_command_names(c)]
415
+ return next(_iter_managed_node_candidates(names, home), None) is not None
416
+
417
+
418
+ def _path_under_any(path: str, roots: list[str]) -> bool:
419
+ """True when *path* sits inside one of *roots* (same drive).
420
+
421
+ Compared via ``normcase``: psutil and env vars can disagree on Windows drive-letter casing.
422
+ """
423
+ path_norm = os.path.normcase(os.path.normpath(path))
424
+ for root in roots:
425
+ root_norm = os.path.normcase(os.path.normpath(root))
426
+ try:
427
+ if os.path.commonpath([path_norm, root_norm]) == root_norm:
428
+ return True
429
+ except ValueError: # different drives on Windows
430
+ continue
431
+ return False
432
+
433
+
434
+ def managed_node_tree_in_use(home: Path | None = None) -> bool:
435
+ """True when a running process executes from the managed Node tree.
436
+
437
+ Windows locks running executables against delete/overwrite, so the updater must not rewrite
438
+ ``%HERMES_HOME%\\node`` while the desktop app holds it (``[WinError 5]`` on ``npm.cmd``).
439
+
440
+ Always ``False`` on POSIX, which has no equivalent lock semantics. See #80926.
441
+ """
442
+ if sys.platform != "win32":
443
+ return False
444
+ try:
445
+ import psutil
446
+ except Exception:
447
+ return False
448
+ dirs: list[str] = []
449
+ for directory in iter_hermes_node_dirs(home):
450
+ try:
451
+ dirs.append(str(Path(directory).resolve()))
452
+ except OSError:
453
+ continue
454
+ if not dirs:
455
+ return False
456
+ try:
457
+ procs = psutil.process_iter(["exe", "cmdline"])
458
+ except Exception:
459
+ return False
460
+ for proc in procs:
461
+ try:
462
+ info = proc.info
463
+ except Exception:
464
+ continue
465
+ exe = info.get("exe")
466
+ if exe:
467
+ try:
468
+ exe = str(Path(exe).resolve())
469
+ except (OSError, ValueError):
470
+ exe = str(exe)
471
+ if any(_path_under_any(p, dirs) for p in ([exe] if exe else []) + list(info.get("cmdline") or [])):
472
+ return True
473
+ return False
474
+
475
+
476
+ _managed_node_in_use_notice_printed = False
477
+
478
+
479
+ def _print_managed_node_in_use_notice() -> None:
480
+ """Print the managed-Node deferral notice once per process."""
481
+ global _managed_node_in_use_notice_printed
482
+ if _managed_node_in_use_notice_printed:
483
+ return
484
+ _managed_node_in_use_notice_printed = True
485
+ print(
486
+ "→ Hermes-managed Node.js is in use by a running app; deferring its "
487
+ "upgrade until the app is closed (re-run `hermes update` afterwards).", flush=True,
488
+ )
489
+
490
+
491
+ def _fetch_url(url: str, timeout: int) -> bytes | None:
492
+ import urllib.request
493
+
494
+ try:
495
+ with urllib.request.urlopen(url, timeout=timeout) as response:
496
+ return response.read()
497
+ except OSError:
498
+ return None
499
+
500
+
501
+ def _stage_windows_node_zip(home: Path, node_arch: str) -> Path | None:
502
+ """Download the target-major portable Node zip into a sibling ``node.new-*`` dir.
503
+
504
+ A sibling makes the later swap a same-volume rename. ``None`` on any failure.
505
+ """
506
+ import tempfile
507
+ import uuid
508
+ import zipfile
509
+
510
+ index_url = f"https://nodejs.org/dist/latest-v{_HERMES_NODE_TARGET_MAJOR}.x/"
511
+ index_bytes = _fetch_url(index_url, 60)
512
+ if index_bytes is None:
513
+ return None
514
+ pattern = rf"node-v{_HERMES_NODE_TARGET_MAJOR}\.\d+\.\d+-win-{node_arch}\.zip"
515
+ match = re.search(pattern, index_bytes.decode("utf-8", errors="replace"))
516
+ if not match:
517
+ return None
518
+ zip_name = match.group(0)
519
+ zip_bytes = _fetch_url(f"{index_url}{zip_name}", 300)
520
+ if zip_bytes is None:
521
+ return None
522
+ staged = home / f"node.new-{uuid.uuid4().hex[:8]}"
523
+ try:
524
+ with tempfile.TemporaryDirectory() as tmp_dir:
525
+ tmp_path = Path(tmp_dir)
526
+ (tmp_path / zip_name).write_bytes(zip_bytes)
527
+ extract_dir = tmp_path / "extract"
528
+ extract_dir.mkdir()
529
+ with zipfile.ZipFile(tmp_path / zip_name) as archive:
530
+ archive.extractall(extract_dir)
531
+ extracted = next(extract_dir.glob("node-v*"), None)
532
+ if extracted is None or not extracted.is_dir():
533
+ return None
534
+ shutil.move(str(extracted), str(staged))
535
+ except OSError:
536
+ return None
537
+ return staged
538
+
539
+
540
+ def _swap_node_tree(target: Path, staged: Path) -> bool | None:
541
+ """Rename the live tree aside (``node.old-*``) and *staged* into place.
542
+
543
+ ``None`` when the OS refuses to move the live tree (in use; untouched, retried next time),
544
+ ``False`` when the staged tree cannot be moved in (live tree rolled back).
545
+ """
546
+ backup = target.parent / f"node.old-{staged.name.removeprefix('node.new-')}"
547
+ had_live = target.exists()
548
+ if had_live:
549
+ try:
550
+ os.replace(str(target), str(backup))
551
+ except OSError:
552
+ _print_managed_node_in_use_notice()
553
+ shutil.rmtree(staged, ignore_errors=True)
554
+ return None
555
+ # Rename preserves mtime: touch the backup (best-effort) so a concurrent heal's
556
+ # litter sweep never removes it mid-swap.
557
+ with contextlib.suppress(OSError):
558
+ os.utime(backup, None)
559
+ try:
560
+ os.replace(str(staged), str(target))
561
+ except OSError:
562
+ if had_live: # roll the live tree back
563
+ with contextlib.suppress(OSError):
564
+ os.replace(str(backup), str(target))
565
+ shutil.rmtree(staged, ignore_errors=True)
566
+ return False
567
+ if had_live:
568
+ # Locked files may keep the old tree on disk until the next heal; safe.
569
+ shutil.rmtree(backup, ignore_errors=True)
570
+ return True
571
+
572
+
573
+ def _heal_managed_node_windows(home: Path | None = None) -> bool | None:
574
+ """Redownload the portable Node zip into ``%HERMES_HOME%\\node`` on Windows.
575
+
576
+ ``True`` on success, ``False`` on genuine failure (offline, bad archive), ``None`` when the
577
+ tree is in use and the heal is deferred — callers must not record the once-per-process attempt
578
+ for ``None``. Staging-first (extract to ``node.new-*``, rename live aside, rename staged in) so
579
+ an interrupted heal cannot gut the install; a refused rename *is* the in-use signal.
580
+
581
+ The replacement is staging-first: the new tree is fully downloaded and extracted to a sibling
582
+ ``node.new-*`` directory, then the live tree is renamed aside (``node.old-*``) and the staged tree
583
+ renamed into place. The live tree is never deleted before its replacement is ready, so an interrupted
584
+ heal cannot gut the running installation. Windows allows renaming a tree whose executables are running
585
+ (images are mapped with ``FILE_SHARE_DELETE`` — the same mechanism as the hermes.exe quarantine); when
586
+ the OS refuses the rename, that refusal *is* the in-use signal and the heal defers instead of forcing
587
+ the write and crashing with ``PermissionError: [WinError 5]`` on ``npm.cmd`` (#80926).
588
+ """
589
+ import time
590
+
591
+ arch = (os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE", "")).lower()
592
+ node_arch = {"amd64": "x64", "x86_64": "x64", "arm64": "arm64", "x86": "x86"}.get(arch)
593
+ if node_arch is None:
594
+ return False
595
+ home = home or get_hermes_home()
596
+ target = home / "node"
597
+ # Cheap pre-check; the rename-based swap is the authoritative guard.
598
+ if managed_node_tree_in_use(home):
599
+ _print_managed_node_in_use_notice()
600
+ return None
601
+ # Sweep litter from interrupted runs; only dirs >10 min old so a concurrent in-flight swap survives.
602
+ cutoff = time.time() - 600
603
+ for stale in (*home.glob("node.old-*"), *home.glob("node.new-*")):
604
+ try:
605
+ if stale.stat().st_mtime < cutoff:
606
+ shutil.rmtree(stale, ignore_errors=True)
607
+ except OSError:
608
+ continue
609
+ staged = _stage_windows_node_zip(home, node_arch)
610
+ if staged is None:
611
+ return False
612
+ return _swap_node_tree(target, staged) and node_tool_runnable(str(target / "node.exe"))
613
+
614
+
615
+ def _run_node_bootstrap(func: str, *, timeout: int, **extra_env: str) -> bool:
616
+ """Source ``scripts/lib/node-bootstrap.sh`` and run shell function *func*."""
617
+ if not _NODE_BOOTSTRAP_SCRIPT.is_file():
618
+ return False
619
+ import subprocess
620
+ try:
621
+ result = subprocess.run(
622
+ ["bash", "-c", f'source "{_NODE_BOOTSTRAP_SCRIPT}" && {func}'],
623
+ env={**os.environ, "HERMES_HOME": str(get_hermes_home()), **extra_env},
624
+ capture_output=True, timeout=timeout, check=False,
625
+ )
626
+ except (OSError, subprocess.SubprocessError):
627
+ return False
628
+ return result.returncode == 0
629
+
630
+
631
+ def bootstrap_hermes_managed_node() -> str | None:
632
+ """Install a Hermes-managed Node tree under ``$HERMES_HOME/node`` and return its npm path.
633
+
634
+ Hermes never modifies a user-owned toolchain (system, nvm, brew, Nix) that fails ``engines``.
635
+ """
636
+ existing = find_hermes_node_executable("npm")
637
+ if existing:
638
+ return existing
639
+ if sys.platform == "win32":
640
+ ok = _heal_managed_node_windows()
641
+ else:
642
+ # HERMES_NODE_SKIP_LINKS=1 keeps node/npm/npx out of ~/.local/bin: never shadow the user toolchain.
643
+ ok = _run_node_bootstrap("_nb_install_bundled_node", timeout=600, HERMES_NODE_SKIP_LINKS="1")
644
+ if not ok:
645
+ return None
646
+ return _first_runnable_managed(_candidate_node_command_names("npm"))[0]
647
+
648
+
649
+ def heal_hermes_managed_node() -> bool:
650
+ """Redownload Hermes-managed Node when the tree exists but is broken; at most once per process.
651
+
652
+ A Windows in-use deferral does NOT record the attempt so a later call can heal once free.
653
+
654
+ POSIX installs shell out to ``heal_managed_node`` in ``scripts/lib/node-bootstrap.sh``; Windows
655
+ downloads the portable zip directly (same source as ``install.ps1``). See #80926.
656
+ """
657
+ global _managed_node_heal_attempted
658
+ if _managed_node_heal_attempted or not hermes_managed_node_tree_present():
659
+ return False
660
+ if sys.platform == "win32":
661
+ result = _heal_managed_node_windows()
662
+ else:
663
+ result = _run_node_bootstrap("heal_managed_node", timeout=300)
664
+ if result is None: # in-use deferral: leave the attempt flag clear
665
+ return False
666
+ _managed_node_heal_attempted = True
667
+ return bool(result)
668
+
669
+
670
+ def _managed_node_tree_outdated(home: Path | None = None) -> bool:
671
+ """True when the managed node runs but is below the target major (heals like a broken tree)."""
672
+ for candidate in _iter_managed_node_candidates(_candidate_node_command_names("node"), home):
673
+ result = _run_version_probe([str(candidate), "--version"])
674
+ if result is None:
675
+ return False # broken, not outdated — the runnable probe handles it
676
+ try:
677
+ version = result.stdout.decode().strip().lstrip("v")
678
+ major = int(version.split(".")[0])
679
+ except (ValueError, IndexError):
680
+ return False
681
+ # A pre-release is outdated whatever its major: nodejs.org publishes headers only for
682
+ # final releases, so node-gyp cannot build node-pty. Mirrors node_satisfies_build() in install.sh.
683
+ if "-" in version:
684
+ return True
685
+ return major < _HERMES_NODE_TARGET_MAJOR
686
+ return False
687
+
688
+
689
+ def find_hermes_node_executable(command: str) -> str | None:
690
+ """Hermes-managed Node/npm path, healing broken/outdated trees; heal failure still returns old Node."""
691
+ names = _candidate_node_command_names(command)
692
+ resolved, broken_present = _first_runnable_managed(names)
693
+ needs_heal = broken_present or (resolved is not None and _managed_node_tree_outdated())
694
+ if needs_heal and heal_hermes_managed_node():
695
+ healed, _ = _first_runnable_managed(names)
696
+ if healed:
697
+ return healed
698
+ return resolved
699
+
700
+
701
+ def find_node_executable_on_path(command: str) -> str | None:
702
+ """Node/npm from PATH; on Windows prefer ``.cmd``/``.exe`` (CreateProcess cannot run the bare shim)."""
703
+ if sys.platform != "win32":
704
+ return shutil.which(command)
705
+ command_str = str(command)
706
+ if any(sep and sep in command_str for sep in (os.sep, os.altsep, "/", "\\")):
707
+ return command_str if Path(command_str).is_file() else None
708
+ directories = [d for d in os.environ.get("PATH", "").split(os.pathsep) if d]
709
+ for name in _candidate_node_command_names(command_str):
710
+ for directory in directories:
711
+ if (Path(directory) / name).is_file():
712
+ return str(Path(directory) / name)
713
+ return None
714
+
715
+
716
+ def find_node_executable(command: str) -> str | None:
717
+ """Resolve a Node command, preferring a healthy managed install.
718
+
719
+ A managed tree that exists but cannot be healed yields ``None`` rather than system Node.
720
+ """
721
+ managed = find_hermes_node_executable(command)
722
+ if managed:
723
+ return managed
724
+ if hermes_managed_node_tree_present():
725
+ return None
726
+ return find_node_executable_on_path(command)
727
+
728
+
729
+ def with_hermes_node_path(env: dict[str, str] | None = None) -> dict[str, str]:
730
+ """Return *env* with Hermes-managed Node directories prepended to PATH."""
731
+ merged = dict(os.environ if env is None else env)
732
+ parts = [p for p in merged.get("PATH", "").split(os.pathsep) if p]
733
+ for entry in reversed([str(path) for path in iter_hermes_node_dirs() if path.is_dir()]):
734
+ if entry not in parts:
735
+ parts.insert(0, entry)
736
+ merged["PATH"] = os.pathsep.join(parts)
737
+ return merged
738
+
739
+
740
+ def agent_browser_runnable(path: str | None) -> bool:
741
+ """True when *path* is an agent-browser CLI that runs (``--version`` exits 0) or the npx fallback.
742
+
743
+ Dead/wrong-arch/hung binaries are rejected so callers try the next candidate.
744
+
745
+ A bare presence check (``shutil.which`` / ``Path.exists``) is not enough: agent-browser's npm
746
+ ``postinstall`` re-points a *global* install symlink (e.g. ``/opt/homebrew/bin/agent-browser``) at our
747
+ local ``node_modules/agent-browser/bin/...`` binary, which then disappears on the next ``hermes update``
748
+ — leaving a **dangling symlink** that ``which`` still reports but exec fails on with exit 127 (issue
749
+ #48521). Callers that trust such a path silently break every browser tool.
750
+ """
751
+ if not path:
752
+ return False
753
+ # The npx fallback is a two-token command string, not a path; npx validates at run time.
754
+ if " " in path and path.split()[0].endswith("npx"):
755
+ return True
756
+ return _is_executable_file(path) and _version_probe_ok(path)
757
+
758
+
759
+ def _legacy_path_has_content(path: Path) -> bool:
760
+ """True iff *path* is a non-directory file or a populated directory.
761
+
762
+ Non-not-found ``OSError`` means "assume occupied" (never orphan legacy data). Symlinks are
763
+ judged on their target; a dangling one does NOT count.
764
+ """
765
+ try:
766
+ st = path.lstat()
767
+ if stat.S_ISLNK(st.st_mode):
768
+ st = path.stat() # judge a symlink on its target; dangling → FileNotFoundError
769
+ except FileNotFoundError:
770
+ return False
771
+ except OSError: # e.g. PermissionError on a parent: assume occupied
772
+ return True
773
+ if not stat.S_ISDIR(st.st_mode):
774
+ return True
775
+ try:
776
+ next(path.iterdir())
777
+ except StopIteration:
778
+ return False
779
+ except OSError:
780
+ pass
781
+ return True
782
+
783
+
784
+ def display_hermes_home() -> str:
785
+ """User-facing ``~/`` display string for HERMES_HOME (``~/.hermes/profiles/coder``)."""
786
+ home = get_hermes_home()
787
+ try: # as_posix(): str() on Windows yields chimeras like ~/AppData\Local\hermes/skills/
788
+ return "~/" + home.relative_to(Path.home()).as_posix()
789
+ except ValueError:
790
+ return str(home)
791
+
792
+
793
+ def profile_cli_selector() -> str:
794
+ """``-p <name> `` (trailing space) pinning copy-pasteable ``hermes ...`` guidance to the
795
+ active NAMED profile, else ``""``: a bare ``hermes`` follows the sticky ``active_profile``
796
+ file, which can name a different database than the one that failed (#105887). A custom
797
+ home outside the profile tree has no selector (only HERMES_HOME names it)."""
798
+ name = profile_name_for_home(get_hermes_home())
799
+ return f"-p {name} " if name and name != "default" else ""
800
+
801
+
802
+ def secure_parent_dir(path: Path) -> None:
803
+ """Chmod ``0o700`` on *path*'s parent, refusing ``/`` and top-level dirs (misresolved HERMES_HOME)."""
804
+ parent = path.parent.resolve()
805
+ if parent == Path("/") or len(parent.parts) < 3:
806
+ return
807
+ # Refuse the install tree: chmod 0700 breaks hermes-user traversal in Docker (UID 10000).
808
+ # A credential file here means HERMES_HOME misresolved; surface it (caused production lockouts).
809
+ # See #25821, #93050.
810
+ if parent == _INSTALL_ROOT or _INSTALL_ROOT in parent.parents:
811
+ import logging
812
+
813
+ logging.getLogger(__name__).warning(
814
+ "Not restricting permissions on %s: it is inside the "
815
+ "hermes-agent install directory (%s). Credential files are "
816
+ "normally stored under the hermes home directory instead.", parent, _INSTALL_ROOT,
817
+ )
818
+ return
819
+ with contextlib.suppress(OSError):
820
+ os.chmod(parent, 0o700)
821
+
822
+
823
+ def _norm_home_path(path: str | None) -> str:
824
+ """Return a comparable absolute path string, or ``""`` for empty input."""
825
+ raw = (path or "").strip()
826
+ if not raw:
827
+ return ""
828
+ try:
829
+ return os.path.normcase(os.path.abspath(os.path.expanduser(raw)))
830
+ except Exception:
831
+ return os.path.normcase(raw)
832
+
833
+
834
+ def _profile_home_path(env: dict[str, str] | None = None) -> str | None:
835
+ """Return ``{HERMES_HOME}/home`` when the profile-home directory exists."""
836
+ hermes_home = get_hermes_home_override() or (env or {}).get("HERMES_HOME") or os.getenv("HERMES_HOME")
837
+ if not hermes_home:
838
+ return None
839
+ profile_home = os.path.join(hermes_home, "home")
840
+ return profile_home if os.path.isdir(profile_home) else None
841
+
842
+
843
+ def _is_profile_home(candidate: str | None, profile_home: str | None) -> bool:
844
+ return bool(candidate and profile_home and _norm_home_path(candidate) == _norm_home_path(profile_home))
845
+
846
+
847
+ def _env_get(env: dict[str, str], key: str, default: str = "") -> str:
848
+ """Stripped *key* from *env*, falling back to the process environment."""
849
+ return str(env.get(key) or os.getenv(key, default)).strip()
850
+
851
+
852
+ def _iter_real_home_candidates(env: dict[str, str] | None = None) -> list[str]:
853
+ """Return likely OS-user home candidates in trust order."""
854
+ env = env or {}
855
+ candidates = [_env_get(env, "HERMES_REAL_HOME"), _env_get(env, "HOME")]
856
+ with contextlib.suppress(Exception):
857
+ import pwd
858
+ candidates.append(pwd.getpwuid(os.getuid()).pw_dir.strip()) # windows-footgun: ok — POSIX-only module inside try/except
859
+ candidates.append(_env_get(env, "USERPROFILE"))
860
+ drive, path = _env_get(env, "HOMEDRIVE"), _env_get(env, "HOMEPATH")
861
+ if drive and path:
862
+ candidates.append(f"{drive}{path}" if path.startswith(("\\", "/")) else os.path.join(drive, path))
863
+ expanded = os.path.expanduser("~")
864
+ if expanded != "~":
865
+ candidates.append(expanded)
866
+ return [c for c in candidates if c]
867
+
868
+
869
+ def get_real_home(env: dict[str, str] | None = None) -> str:
870
+ """The OS user's real home, avoiding the Hermes profile HOME.
871
+
872
+ ``HOME`` belongs to the OS account and external CLIs keeping credentials under ``~``; a parent
873
+ already running with ``HOME={HERMES_HOME}/home`` is repaired back when possible.
874
+ """
875
+ profile_home = _profile_home_path(env)
876
+ seen: set[str] = set()
877
+ for candidate in _iter_real_home_candidates(env):
878
+ key = _norm_home_path(candidate)
879
+ if not key or key in seen:
880
+ continue
881
+ seen.add(key)
882
+ if not _is_profile_home(candidate, profile_home):
883
+ return candidate
884
+ return "/tmp"
885
+
886
+
887
+ _HOME_MODE_ALIASES = {"isolated": "profile", "profile_home": "profile", "profile-home": "profile",
888
+ "host": "real", "user": "real", "real_home": "real", "real-home": "real"}
889
+
890
+
891
+ def get_subprocess_home(env: dict[str, str] | None = None) -> str | None:
892
+ """Subprocess ``HOME`` override, or ``None``.
893
+
894
+ ``auto``: hosts keep real HOME (repairing a profile-home parent), containers use
895
+ ``{HERMES_HOME}/home``; ``real``: always real HOME; ``profile``: always the profile home.
896
+ """
897
+ env = env or {}
898
+ profile_home = _profile_home_path(env)
899
+ mode = _env_get(env, "TERMINAL_HOME_MODE", "auto").lower() or "auto"
900
+ mode = _HOME_MODE_ALIASES.get(mode, mode)
901
+
902
+ if mode == "profile":
903
+ return profile_home
904
+ real_home = get_real_home(env)
905
+ current_home = _env_get(env, "HOME")
906
+ repaired = real_home if _norm_home_path(real_home) != _norm_home_path(current_home) else None
907
+ if mode == "real":
908
+ return repaired
909
+
910
+ if profile_home and is_container():
911
+ return profile_home
912
+ if _is_profile_home(current_home, profile_home):
913
+ return repaired
914
+ return None
915
+
916
+
917
+ def apply_subprocess_home_env(env: dict[str, str]) -> None:
918
+ """Apply Hermes' subprocess HOME contract to *env* in-place."""
919
+ real_home = get_real_home(env)
920
+ if real_home:
921
+ env["HERMES_REAL_HOME"] = real_home
922
+ home = get_subprocess_home(env)
923
+ if home:
924
+ env["HOME"] = home
925
+
926
+
927
+ VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
928
+
929
+
930
+ def parse_reasoning_effort(effort) -> dict | None:
931
+ """Parse a reasoning effort level into a config dict.
932
+
933
+ ``None`` for empty/unrecognized input (caller uses the default); ``{"enabled": False}`` for
934
+ "none"/"false"/"disabled"/YAML False — ``reasoning_effort: false`` must mean disabled.
935
+ """
936
+ if effort is None or effort is True:
937
+ return None
938
+ effort = str(effort).strip().lower() # False -> "false" -> disabled; "" matches neither set
939
+ if effort in {"none", "false", "disabled"}:
940
+ return {"enabled": False}
941
+ if effort in VALID_REASONING_EFFORTS:
942
+ return {"enabled": True, "effort": effort}
943
+ return None
944
+
945
+
946
+ def _canonical_model_variants(model: str) -> list[str]:
947
+ """Spelling variants for tolerant override matching, exact first, deduped in order.
948
+
949
+ Dot/dash recovery runs on EACH base form so ``x-4.5``, ``x-4-5`` and ``x.4.5`` share one variant set.
950
+ """
951
+ _dash_to_dot = lambda s: re.sub(r'(\d)-(\d)', r'\1.\2', s)
952
+ _dot_to_dash = lambda s: re.sub(r'(\d)\.(\d)', r'\1-\2', s)
953
+ seen: set[str] = set()
954
+ variants: list[str] = []
955
+
956
+ def _add(*values):
957
+ for v in values:
958
+ if v and v not in seen:
959
+ seen.add(v)
960
+ variants.append(v)
961
+
962
+ def _add_with_derivatives(s):
963
+ dashed, dotted = s.replace('.', '-'), s.replace('-', '.')
964
+ _add(s, dashed, dotted, _dash_to_dot(s), _dot_to_dash(s), _dash_to_dot(dashed), _dot_to_dash(dotted))
965
+ _add_with_derivatives(model)
966
+ parts = model.split('/')
967
+ if len(parts) >= 2: # bare model (strip provider/aggregator prefix)
968
+ _add_with_derivatives(parts[-1])
969
+ if len(parts) >= 3: # strip aggregator only: "openrouter/anthropic/x" → "anthropic/x"
970
+ _add_with_derivatives('/'.join(parts[1:]))
971
+ known_providers = (
972
+ 'anthropic', 'openai', 'google', 'openrouter', 'groq', 'mistral',
973
+ 'xai', 'cohere', 'perplexity', 'together', 'fireworks', 'deepseek',
974
+ )
975
+ for v in [v for v in variants if '/' not in v]:
976
+ _add(*(f"{provider}/{v}" for provider in known_providers))
977
+ known_aggregators = ('openrouter', 'opencode', 'fireworks', 'groq', 'together')
978
+ for v in [v for v in variants if v.count('/') == 1]:
979
+ _add(*(f"{agg}/{v}" for agg in known_aggregators))
980
+ return variants
981
+
982
+
983
+ def resolve_per_model_reasoning_effort(model: str, overrides: dict | None) -> dict | None:
984
+ """Per-model reasoning_effort override with spelling tolerance; first non-None parse wins.
985
+
986
+ Order: exact → dots↔dashes → provider stripped → aggregator stripped → known prefixes added.
987
+ """
988
+ if not overrides or not isinstance(overrides, dict) or not model:
989
+ return None
990
+ for variant in _canonical_model_variants(model):
991
+ if variant in overrides:
992
+ result = parse_reasoning_effort(overrides[variant])
993
+ if result is not None:
994
+ return result
995
+ return None
996
+
997
+
998
+ def resolve_per_model_provider_routing(model: str, models: dict | None) -> dict:
999
+ """``provider_routing.models.<id>`` entry for *model*, spelling-tolerant like
1000
+ ``reasoning_overrides``; ``{}`` when none matches. Only the keys a user sets per model
1001
+ are returned so unset ones fall through to the flat ``provider_routing`` values."""
1002
+ if not model or not isinstance(models, dict):
1003
+ return {}
1004
+ for variant in _canonical_model_variants(model):
1005
+ entry = models.get(variant)
1006
+ if isinstance(entry, dict):
1007
+ return entry
1008
+ return {}
1009
+
1010
+
1011
+ def resolve_reasoning_config(cfg: dict | None, model: str = "") -> dict | None:
1012
+ """Effective reasoning config for *model*: per-model override, then global ``agent.reasoning_effort``.
1013
+
1014
+ Single chokepoint for every surface (CLI, gateway, TUI, cron, ``/model``, fallback activation).
1015
+ """
1016
+ cfg = cfg if isinstance(cfg, dict) else {}
1017
+ agent_cfg = cfg.get("agent") if isinstance(cfg.get("agent"), dict) else {}
1018
+
1019
+ if not model:
1020
+ model_cfg = cfg.get("model")
1021
+ if isinstance(model_cfg, dict):
1022
+ model_cfg = model_cfg.get("default") or model_cfg.get("model") or ""
1023
+ model = model_cfg.strip() if isinstance(model_cfg, str) else ""
1024
+ per_model = resolve_per_model_reasoning_effort(model, agent_cfg.get("reasoning_overrides") or {})
1025
+ if per_model is not None:
1026
+ return per_model
1027
+
1028
+ # Keep the raw value: ``or ""`` would turn a YAML False into "" and silently re-enable thinking.
1029
+ effort = agent_cfg.get("reasoning_effort", "")
1030
+ result = parse_reasoning_effort(effort)
1031
+ if effort and str(effort).strip() and result is None:
1032
+ import logging
1033
+ logging.getLogger(__name__).warning("Unknown reasoning_effort '%s', using default (medium)", effort)
1034
+ return result
1035
+
1036
+
1037
+ def is_termux() -> bool:
1038
+ """True inside Termux (Android): ``TERMUX_VERSION`` or the Termux-specific ``PREFIX`` path."""
1039
+ prefix = os.getenv("PREFIX", "")
1040
+ return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
1041
+
1042
+
1043
+ _wsl_detected: bool | None = None
1044
+
1045
+
1046
+ def is_wsl() -> bool:
1047
+ """True inside WSL1/WSL2 (``microsoft`` marker in ``/proc/version``); cached per process."""
1048
+ global _wsl_detected
1049
+ if _wsl_detected is None:
1050
+ try:
1051
+ with open("/proc/version", "r", encoding="utf-8") as f:
1052
+ _wsl_detected = "microsoft" in f.read().lower()
1053
+ except Exception:
1054
+ _wsl_detected = False
1055
+ return _wsl_detected
1056
+
1057
+
1058
+ def windows_path_to_wsl(path: str) -> str | None:
1059
+ """Convert a Windows drive path (``C:\\...``) to its ``/mnt/<drive>/...`` form."""
1060
+ match = re.match(r"^([A-Za-z]):[\\/](.*)$", str(path or "").strip())
1061
+ return f"/mnt/{match.group(1).lower()}/{match.group(2).replace(chr(92), '/')}" if match else None
1062
+
1063
+
1064
+ def wsl_unc_path_to_posix(path: str) -> str | None:
1065
+ """Convert a ``\\\\wsl.localhost\\<distro>\\...`` (or legacy ``\\\\wsl$``) UNC path to POSIX."""
1066
+ normalized = str(path or "").strip().replace("/", "\\")
1067
+ match = re.match(r"^\\\\wsl(?:\.localhost|\$)\\[^\\]+\\(.*)$", normalized, re.IGNORECASE)
1068
+ return "/" + match.group(1).replace("\\", "/") if match else None
1069
+
1070
+
1071
+ def translate_cwd_for_wsl_backend(cwd: str) -> str:
1072
+ """Map a Windows-host cwd (drive path or ``\\\\wsl.localhost\\`` UNC) to POSIX when Hermes runs in WSL.
1073
+
1074
+ No-op off WSL and for paths already POSIX.
1075
+ """
1076
+ if not is_wsl():
1077
+ return cwd
1078
+ for translator in (wsl_unc_path_to_posix, windows_path_to_wsl):
1079
+ translated = translator(cwd)
1080
+ if translated is not None:
1081
+ return translated
1082
+ return cwd
1083
+
1084
+
1085
+ _container_detected: bool | None = None
1086
+
1087
+
1088
+ def is_container() -> bool:
1089
+ """True inside a container (Docker/Podman/LXC/Kubernetes markers); cached per process.
1090
+
1091
+ See: NousResearch/hermes-agent#47111
1092
+ """
1093
+ global _container_detected
1094
+ if _container_detected is None:
1095
+ _container_detected = _detect_container()
1096
+ return _container_detected
1097
+
1098
+
1099
+ def _read_proc(path: str) -> str:
1100
+ try:
1101
+ with open(path, "r", encoding="utf-8") as f:
1102
+ return f.read()
1103
+ except OSError:
1104
+ return ""
1105
+
1106
+
1107
+ def _proc_file_has_marker(path: str, markers: tuple[str, ...]) -> bool:
1108
+ content = _read_proc(path)
1109
+ return any(marker in content for marker in markers)
1110
+
1111
+
1112
+ def _detect_container() -> bool:
1113
+ if (
1114
+ os.path.exists("/.dockerenv")
1115
+ or os.path.exists("/run/.containerenv")
1116
+ or os.environ.get("KUBERNETES_SERVICE_HOST")
1117
+ or _proc_file_has_marker("/proc/1/cgroup", ("docker", "podman", "/lxc/", "kubepods", "containerd", "crio"))
1118
+ ):
1119
+ return True
1120
+ # cgroup v2: /proc/1/cgroup is just "0::/"; the runtime still shows in mountinfo — but ONLY on
1121
+ # the root ("/") mount line. A host that merely *runs* containers exposes every container's
1122
+ # overlay lowerdir (``lowerdir=/var/lib/containerd/...``) at non-root mount points, which a
1123
+ # whole-file scan misread as "inside a container" and flipped subprocess HOME (#58135).
1124
+ return _root_mount_has_marker("/proc/self/mountinfo", ("kubepods", "containerd", "crio"))
1125
+
1126
+
1127
+ def _root_mount_has_marker(path: str, markers: tuple[str, ...]) -> bool:
1128
+ """mountinfo field 5 (index 4) is the mount point; only the root ("/") line is the process's own rootfs."""
1129
+ root_lines = [line for line in _read_proc(path).splitlines() if len(f := line.split()) >= 5 and f[4] == "/"]
1130
+ return any(marker in line for line in root_lines for marker in markers)
1131
+
1132
+
1133
+ def get_config_path() -> Path:
1134
+ """Return the path to ``config.yaml`` under HERMES_HOME."""
1135
+ return get_hermes_home() / "config.yaml"
1136
+
1137
+
1138
+ def get_skills_dir() -> Path:
1139
+ """Return the path to the skills directory under HERMES_HOME."""
1140
+ return get_hermes_home() / "skills"
1141
+
1142
+
1143
+ def get_env_path() -> Path:
1144
+ """Return the path to the ``.env`` file under HERMES_HOME."""
1145
+ return get_hermes_home() / ".env"
1146
+
1147
+
1148
+ def apply_ipv4_preference(force: bool = False) -> None:
1149
+ """Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 when *force* is True.
1150
+
1151
+ Broken-IPv6 hosts hang on AAAA for the full TCP timeout; ``AF_UNSPEC`` resolves as ``AF_INET``,
1152
+ falling back to full resolution when no A record exists (pure-IPv6 hosts still work).
1153
+ """
1154
+ if not force:
1155
+ return
1156
+
1157
+ import socket
1158
+
1159
+ if getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False):
1160
+ return
1161
+ _original_getaddrinfo = socket.getaddrinfo
1162
+
1163
+ def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
1164
+ if family == 0: # AF_UNSPEC — caller didn't request a specific family
1165
+ try:
1166
+ return _original_getaddrinfo(host, port, socket.AF_INET, type, proto, flags)
1167
+ except socket.gaierror: # no A record — pure-IPv6 host
1168
+ return _original_getaddrinfo(host, port, family, type, proto, flags)
1169
+ return _original_getaddrinfo(host, port, family, type, proto, flags)
1170
+ _ipv4_getaddrinfo._hermes_ipv4_patched = True # type: ignore[attr-defined]
1171
+ socket.getaddrinfo = _ipv4_getaddrinfo # type: ignore[assignment]
1172
+
1173
+
1174
+ PARTIAL_STREAM_STUB_ID = "partial-stream-stub"
1175
+ FINISH_REASON_LENGTH = "length"
1176
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
1177
+ OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
1178
+
1179
+ # OpenRouter request-time routing variants (docs: guides/routing/model-variants).
1180
+ # These suffixes are per-request routing modifiers valid on ANY model id —
1181
+ # ":nitro" sorts the endpoint pool by throughput and admits priority-tier
1182
+ # endpoints, ":floor" sorts by price and admits flex-tier endpoints, ":exacto"
1183
+ # applies quality-first provider sorting, ":online" attaches the web plugin.
1184
+ # They are never separate catalog entries: /models lists only the base id, so
1185
+ # every catalog lookup must key on the BASE while the suffixed id stays on the
1186
+ # wire.
1187
+ # NOT in this set: ":free", ":batch", ":thinking", ":extended" — those ARE
1188
+ # distinct catalog SKUs with their own /models entries (and their own context
1189
+ # windows), so stripping them would resolve the wrong window.
1190
+ OPENROUTER_VARIANT_SUFFIXES: frozenset[str] = frozenset(
1191
+ {"nitro", "floor", "exacto", "online"}
1192
+ )
1193
+
1194
+
1195
+ def openrouter_variant_base(model_id: str) -> str | None:
1196
+ """Return the base model id when ``model_id`` carries a recognized
1197
+ OpenRouter routing-variant suffix (e.g. ``x-ai/grok-4:nitro`` →
1198
+ ``x-ai/grok-4``), else ``None``.
1199
+
1200
+ Lives here rather than in ``hermes_cli.models`` so the metadata layer
1201
+ (``agent.model_metadata``) can share one definition without importing the
1202
+ CLI — this module is dependency-free by contract.
1203
+
1204
+ >>> openrouter_variant_base("x-ai/grok-4:nitro")
1205
+ 'x-ai/grok-4'
1206
+ >>> openrouter_variant_base("x-ai/grok-4:free") is None
1207
+ True
1208
+ >>> openrouter_variant_base("x-ai/grok-4") is None
1209
+ True
1210
+ """
1211
+ base, sep, suffix = (model_id or "").rpartition(":")
1212
+ if not sep or not base:
1213
+ return None
1214
+ if suffix.lower() in OPENROUTER_VARIANT_SUFFIXES:
1215
+ return base
1216
+ return None
1217
+
1218
+
1219
+ AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
1220
+
1221
+
1222
+ def venv_bin_dir(venv_dir, *, windows: bool | None = None) -> Path:
1223
+ """Venv executable dir (``Scripts``/``bin``); *windows* lets tests exercise Windows paths on Linux.
1224
+
1225
+ Returned unconditionally — callers differ on whether a missing venv is an error.
1226
+
1227
+ Canonical helper for venv layout. This was open-coded in seven places across four ``hermes_cli`` modules
1228
+ using three different Windows predicates (``platform.system()``, ``is_windows()``, ``_is_windows()``);
1229
+ each new call site had to re-derive it, and #76091 shipped an eighth copy because the correct behaviour
1230
+ lived 2400 lines away in another function. A few sites outside ``hermes_cli``
1231
+ (``tools/code_execution_tool.py``, ``agent/lsp/install.py``, ``agent/lsp/servers.py``) still hand-roll
1232
+ it — convert them as they are touched.
1233
+ """
1234
+ windows = sys.platform == "win32" if windows is None else windows
1235
+ return Path(venv_dir) / ("Scripts" if windows else "bin")
1236
+
1237
+
1238
+ def project_venv_dir(project_root) -> Path | None:
1239
+ """The project's ``venv`` or ``.venv`` dir when one exists (``uv venv`` defaults to ``.venv``).
1240
+
1241
+ ``uv venv`` defaults to ``.venv`` while our installers create ``venv``, so both layouts are in the wild.
1242
+ Call sites that only knew about ``venv`` silently no-oped on a ``.venv`` install — that is how the
1243
+ Windows shim-lock preflight skipped itself entirely (#79542). ``venv`` wins when both exist, matching
1244
+ what the installers write.
1245
+ """
1246
+ root = Path(project_root)
1247
+ return next((root / n for n in ("venv", ".venv") if (root / n).is_dir()), None)
1248
+
1249
+
1250
+ def venv_python_path(venv_dir, *, windows: bool | None = None) -> Path:
1251
+ """Path to the Python interpreter inside *venv_dir* (may not exist)."""
1252
+ bin_dir = venv_bin_dir(venv_dir, windows=windows)
1253
+ return bin_dir / ("python.exe" if bin_dir.name == "Scripts" else "python")
1254
+
1255
+
1256
+ # First-party roots: an ImportError naming one means our own tree is inconsistent. The
1257
+ # update post-probe shares this set so the guard that BLOCKS and the hint that EXPLAINS agree.
1258
+ FIRST_PARTY_MODULE_ROOTS = frozenset({
1259
+ "agent", "acp_adapter", "cli", "cron", "gateway", "model_tools", "plugins",
1260
+ "providers", "tools", "toolsets", "run_agent", "tui_gateway", "utils",
1261
+ })
1262
+
1263
+
1264
+ def is_first_party_module(name: str | None) -> bool:
1265
+ """True when *name* ships with Hermes (exact first segment; ``startswith`` would claim ``agentops``)."""
1266
+ root = str(name).split(".")[0] if name else ""
1267
+ return bool(root) and (root in FIRST_PARTY_MODULE_ROOTS or root.startswith("hermes_"))
1268
+
1269
+
1270
+ def partial_update_hint(exc: BaseException) -> list[str]:
1271
+ """Recovery guidance lines when *exc* looks like a half-updated tree, else ``[]``."""
1272
+ # A missing third-party dep (bad venv, missing extra) is a different problem.
1273
+ if (not isinstance(exc, ImportError) or isinstance(exc, ModuleNotFoundError)
1274
+ or not is_first_party_module(getattr(exc, "name", None))):
1275
+ return []
1276
+ return [
1277
+ "",
1278
+ "This looks like a partially-updated install: one module was refreshed "
1279
+ "and a related one was not.",
1280
+ "Re-run the update to bring the whole tree to the same version:",
1281
+ " hermes update",
1282
+ "If that also fails, reinstall: https://hermes-agent.nousresearch.com",
1283
+ ]
1284
+
1285
+
1286
+ def emit_partial_update_hint(exc: BaseException, *, file=None) -> bool:
1287
+ """Print recovery guidance for a half-updated tree."""
1288
+ lines = partial_update_hint(exc)
1289
+ if not lines:
1290
+ return False
1291
+ for line in (f"Error: {exc}", *lines):
1292
+ print(line, file=sys.stderr if file is None else file)
1293
+ return True
hermes_state_registry.py ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Process-wide shared SessionDB registry.
2
+
3
+ Each bare ``SessionDB()`` mints its own writer connection, lock, close-time WAL checkpoint
4
+ and token writer thread, and one connection's close-time checkpoint can race another's
5
+ growth (lost/reordered-page-write corruption). This module owns that boundary: one shared
6
+ ``SessionDB`` per resolved path per process, refcounted, with generation-aware retirement
7
+ when the file is replaced (snapshot restore, recovery swap).
8
+
9
+ Lifecycle rules:
10
+ - ``acquire(path)`` returns the current generation for *path* and bumps its refcount.
11
+ - ``close()`` on a shared instance RELEASES one refcount instead of tearing the
12
+ connection down: the registry owns the physical lifecycle and only closes on the
13
+ final release, so legacy call sites return their reference instead of leaking it.
14
+ - ``release(db)`` decrements the generation *db was acquired from* (object-keyed, so an
15
+ inode replacement cannot strand a still-owned generation); the final release of a
16
+ retired generation tears it down.
17
+ - On inode change the old generation is RETIRED (never lent again) but stays alive until
18
+ its holders release. If the replacement open fails the registry keeps NO path entry.
19
+ - All teardown happens OUTSIDE the registry lock: a final release's WAL checkpoint must
20
+ never stall acquisition for every state.db.
21
+ - A final close/checkpoint is serialized with the next open for the same path; no new
22
+ generation is published while the previous generation is still tearing down.
23
+ - A path can have SEVERAL closes admitted at once (the current generation's final release
24
+ plus a retired generation's drain). The path barrier COUNTS them and is lifted only by
25
+ the last one to settle, so neither ``acquire`` nor ``close_all`` / ``close_all_under``
26
+ can escape while any handle for that path is still inside checkpoint/WAL-unlink.
27
+ - Maintenance callers borrow handles with a temporary registry reference instead of
28
+ iterating an unpinned snapshot.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ import logging
35
+ import os
36
+ import threading
37
+ from pathlib import Path
38
+ from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Tuple
39
+
40
+ from hermes_state_common import stat_db_file_identity as _stat_db_file_identity
41
+
42
+ if TYPE_CHECKING: # pragma: no cover - import cycle guard, typed only
43
+ from hermes_state import SessionDB
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ class _TeardownBarrier:
49
+ """Accounting for every admitted-but-unfinished physical close of one path.
50
+
51
+ A path can own more than one close at a time: the current generation's final
52
+ release and a retired generation's drain are admitted independently under
53
+ ``_lock`` and only meet at the lifecycle mutex. One event per path is honest
54
+ only if the LAST admitted teardown settles it. Signalling on the first lets
55
+ ``close_all()`` return and ``acquire()`` publish a replacement while an older
56
+ handle is still inside ``PRAGMA wal_checkpoint``/sidecar unlink -- the exact
57
+ overlap (#102827) this registry exists to forbid.
58
+ """
59
+
60
+ __slots__ = ("event", "pending")
61
+
62
+ def __init__(self) -> None:
63
+ self.event = threading.Event()
64
+ self.pending = 0
65
+
66
+
67
+ class _Generation:
68
+ """One shared SessionDB generation: instance, refcount, file identity."""
69
+
70
+ __slots__ = ("path", "db", "refcount", "identity", "retired")
71
+
72
+ def __init__(self, path: Path, db: "SessionDB", identity: Optional[Tuple[int, int]]) -> None:
73
+ self.path = path
74
+ self.db = db
75
+ self.refcount = 1
76
+ self.identity = identity
77
+ self.retired = False
78
+
79
+
80
+ _lock = threading.Lock()
81
+ # path → live generation; retired generations move to _retired (keyed by id(db)) until
82
+ # their last holder releases.
83
+ _generations: Dict[Path, _Generation] = {}
84
+ _retired: Dict[int, _Generation] = {}
85
+ # Paths whose next generation is being constructed. Construction runs outside _lock
86
+ # (schema reconciliation can take seconds), but peers for the SAME file must wait or
87
+ # every cold caller opens its own writer before a winner is chosen.
88
+ _opening: Dict[Path, threading.Event] = {}
89
+ # A final close/checkpoint must finish before a replacement writer is opened
90
+ # for the same path. The barrier is admitted while holding _lock and lifted
91
+ # only after the LAST admitted physical teardown, so acquire cannot slip
92
+ # through the generation-removal/open gap and close_all cannot report a
93
+ # finished sweep over a close that is still running.
94
+ _tearing_down: Dict[Path, _TeardownBarrier] = {}
95
+ # Open and close are both performed outside _lock. This per-path mutex closes
96
+ # the race between checking _tearing_down and entering sqlite3.connect(),
97
+ # including retired-generation drains after an inode replacement.
98
+ _path_lifecycle_locks: Dict[Path, threading.Lock] = {}
99
+
100
+
101
+ def _open_session_db(path: Path) -> "SessionDB":
102
+ """Construct the SessionDB for *path* (call-time import avoids cycles; tests patch this)."""
103
+ from hermes_state import SessionDB
104
+
105
+ return SessionDB(db_path=path)
106
+
107
+
108
+ def _teardown(db: "SessionDB") -> None:
109
+ """Close a shared instance, clearing its registry-owned flag first."""
110
+ with contextlib.suppress(Exception):
111
+ db._shared_registry_owned = False
112
+ _close_quietly(db, "Error closing shared SessionDB")
113
+
114
+
115
+ def _close_quietly(db: "SessionDB", debug_message: str) -> None:
116
+ """close() that never propagates. A lost WAL generation whose capture failed is data at risk,
117
+ not teardown noise: the handle stays open and the operator has to act, so that one surfaces."""
118
+ try:
119
+ db.close()
120
+ except Exception as exc:
121
+ from hermes_state_dbfile import RetiredGenerationCaptureError
122
+ if isinstance(exc, RetiredGenerationCaptureError):
123
+ logger.error("SessionDB for %s did not settle at close: %s", _db_path_of(db), exc)
124
+ else:
125
+ logger.debug(debug_message, exc_info=True)
126
+
127
+
128
+ def _path_lifecycle_lock_locked(path: Path) -> threading.Lock:
129
+ """Return the lifecycle mutex for *path* (caller holds ``_lock``)."""
130
+ lock = _path_lifecycle_locks.get(path)
131
+ if lock is None:
132
+ lock = threading.Lock()
133
+ _path_lifecycle_locks[path] = lock
134
+ return lock
135
+
136
+
137
+ def _admit_teardown_locked(path: Path) -> _TeardownBarrier:
138
+ """Register one pending physical close for *path* (caller holds ``_lock``).
139
+
140
+ Admission shares the lock section that removes the generation, so a peer
141
+ release, ``acquire`` or ``close_all`` taking the lock next always sees this
142
+ teardown accounted for.
143
+ """
144
+ barrier = _tearing_down.get(path)
145
+ if barrier is None:
146
+ barrier = _tearing_down[path] = _TeardownBarrier()
147
+ barrier.pending += 1
148
+ return barrier
149
+
150
+
151
+ def _finish_teardown(path: Path, barrier: _TeardownBarrier) -> None:
152
+ """Settle one admitted teardown; only the last one lifts the path barrier."""
153
+ with _lock:
154
+ barrier.pending -= 1
155
+ if barrier.pending > 0:
156
+ return
157
+ if _tearing_down.get(path) is barrier:
158
+ _tearing_down.pop(path, None)
159
+ barrier.event.set()
160
+
161
+
162
+ def _teardown_generation(
163
+ path: Path,
164
+ db: "SessionDB",
165
+ *,
166
+ barrier: Optional[_TeardownBarrier] = None,
167
+ ) -> None:
168
+ """Close *db* under its path lifecycle mutex, then settle its barrier slot."""
169
+ with _lock:
170
+ lifecycle_lock = _path_lifecycle_lock_locked(path)
171
+ try:
172
+ with lifecycle_lock:
173
+ _teardown(db)
174
+ finally:
175
+ if barrier is not None:
176
+ _finish_teardown(path, barrier)
177
+
178
+
179
+ def _db_path_of(db: "SessionDB") -> Optional[Path]:
180
+ """``Path(db.db_path)`` or None when absent/unconvertible."""
181
+ path = getattr(db, "db_path", None)
182
+ try:
183
+ return None if path is None else Path(path)
184
+ except (TypeError, ValueError):
185
+ return None
186
+
187
+
188
+ def _finish_opening(path: Path, opening: threading.Event) -> None:
189
+ """Drop the per-path construction marker and wake waiters (caller holds _lock)."""
190
+ if _opening.get(path) is opening:
191
+ _opening.pop(path, None)
192
+ opening.set()
193
+
194
+
195
+ def acquire(db_path: Optional[Path] = None) -> "SessionDB":
196
+ """Return the shared SessionDB for *db_path*, incrementing its refcount. If the file was
197
+ replaced (different inode) since the generation opened, that generation is RETIRED
198
+ but stays alive for its holders, and a fresh one is opened in its place. Raises
199
+ whatever ``SessionDB.__init__`` raises; on a replacement-open failure the registry
200
+ holds NO entry for the path."""
201
+ from hermes_state import _default_db_path
202
+
203
+ raw_path = Path(db_path) if db_path is not None else Path(_default_db_path())
204
+ try:
205
+ path = raw_path.resolve()
206
+ except OSError:
207
+ path = raw_path
208
+
209
+ while True:
210
+ wait_for: Optional[threading.Event] = None
211
+ with _lock:
212
+ generation = _generations.get(path)
213
+ if generation is not None:
214
+ current = _stat_db_file_identity(path)
215
+ if current is not None and generation.identity is not None and current != generation.identity:
216
+ # File replaced: retire this generation so it is never lent again, then elect one
217
+ # caller to open the replacement. It stays alive for its holders, tracked in
218
+ # ``_retired`` by ``id(db)`` so their releases find it after the path remaps.
219
+ generation.retired = True
220
+ del _generations[path]
221
+ _retired[id(generation.db)] = generation
222
+ else:
223
+ generation.refcount += 1
224
+ return generation.db
225
+ teardown = _tearing_down.get(path)
226
+ if teardown is not None:
227
+ wait_for = teardown.event
228
+ else:
229
+ opening = _opening.get(path)
230
+ if opening is None:
231
+ opening = _opening[path] = threading.Event()
232
+ lifecycle_lock = _path_lifecycle_lock_locked(path)
233
+ else:
234
+ wait_for = opening
235
+ if wait_for is not None:
236
+ # Another caller is constructing or closing this path; wait without holding the
237
+ # global lock. A failed opener signals too, so a waiter can retry.
238
+ wait_for.wait()
239
+ continue
240
+
241
+ # Open OUTSIDE the registry lock; the per-path marker prevents redundant writers without
242
+ # serialising other files, the lifecycle mutex keeps the open off a same-path close.
243
+ try:
244
+ with lifecycle_lock:
245
+ db = _open_session_db(path)
246
+ db._shared_registry_owned = True
247
+ identity = _stat_db_file_identity(path)
248
+ except BaseException:
249
+ with _lock:
250
+ _finish_opening(path, opening)
251
+ raise
252
+
253
+ with _lock:
254
+ teardown = _tearing_down.get(path)
255
+ if teardown is None:
256
+ existing = _generations.get(path)
257
+ if existing is not None: # Defensive: installed by explicit registry manipulation mid-open.
258
+ existing.refcount += 1
259
+ winner = existing.db
260
+ else:
261
+ _generations[path] = _Generation(path, db, identity)
262
+ winner = db
263
+ _finish_opening(path, opening)
264
+ if teardown is not None:
265
+ # A shutdown or retired-generation final release was admitted while this opener was
266
+ # constructing: never publish into that window — discard the speculative handle and
267
+ # go round again once the barrier lifts.
268
+ _teardown_generation(path, db)
269
+ teardown.event.wait()
270
+ continue
271
+ if winner is not db:
272
+ _teardown_generation(path, db)
273
+ return winner
274
+
275
+
276
+ def release(db: "SessionDB") -> bool:
277
+ """Decrement the refcount of a shared SessionDB. ``True`` if *db* was shared; ``False``
278
+ if it is not registry-managed (caller owns close()). The final release tears the
279
+ generation down OUTSIDE the registry lock. Lookup is object-keyed, so holders of an
280
+ old generation release into its retired record, not into whatever the path names."""
281
+ if db is None:
282
+ return False
283
+ key = id(db)
284
+ teardown_barrier: Optional[_TeardownBarrier] = None
285
+ with _lock:
286
+ generation = _retired.get(key)
287
+ if generation is None:
288
+ path = _db_path_of(db)
289
+ if path is None:
290
+ return False
291
+ generation = _generations.get(path)
292
+ if generation is None or generation.db is not db:
293
+ # Not shared (bare SessionDB()); the caller owns close().
294
+ return False
295
+ generation.refcount -= 1
296
+ needs_teardown = generation.refcount <= 0
297
+ if needs_teardown:
298
+ if generation.retired:
299
+ _retired.pop(key, None)
300
+ elif _generations.get(generation.path) is generation:
301
+ _generations.pop(generation.path, None)
302
+ # A retired generation's drain is admitted too: it checkpoints and unlinks the same
303
+ # sidecars as the current one, so a replacement writer must not open on top of it.
304
+ teardown_barrier = _admit_teardown_locked(generation.path)
305
+ # Teardown OUTSIDE the lock: stopping the token writer, WAL checkpoint and read-pool
306
+ # drain must not block acquisition for every other state.db.
307
+ if needs_teardown:
308
+ _teardown_generation(generation.path, db, barrier=teardown_barrier)
309
+ return True
310
+
311
+
312
+ def _path_is_under(path: Path, root: Path) -> bool:
313
+ """True when *path* is *root* or a file inside it (normcase, resolved)."""
314
+ try:
315
+ path_key = os.path.normcase(str(path.resolve()))
316
+ root_key = os.path.normcase(str(root.resolve()))
317
+ except OSError:
318
+ path_key = os.path.normcase(str(path))
319
+ root_key = os.path.normcase(str(root))
320
+ return path_key == root_key or path_key.startswith(root_key + os.sep)
321
+
322
+
323
+ def _teardown_swept_generations(
324
+ generations: List[_Generation],
325
+ teardown_barriers: Dict[Path, _TeardownBarrier],
326
+ active_teardowns: List[_TeardownBarrier],
327
+ ) -> int:
328
+ """Close *generations* outside the registry lock; wait for already-admitted teardowns."""
329
+ by_path: Dict[Path, List[_Generation]] = {}
330
+ for generation in generations:
331
+ by_path.setdefault(generation.path, []).append(generation)
332
+ for path, path_generations in by_path.items():
333
+ with _lock:
334
+ lifecycle_lock = _path_lifecycle_lock_locked(path)
335
+ try:
336
+ with lifecycle_lock:
337
+ for generation in path_generations:
338
+ _teardown(generation.db)
339
+ finally:
340
+ _finish_teardown(path, teardown_barriers[path])
341
+ for barrier in active_teardowns:
342
+ barrier.event.wait()
343
+ return len(generations)
344
+
345
+
346
+ def close_all() -> int:
347
+ """Close every shared SessionDB regardless of refcount; returns the count. For gateway
348
+ shutdown, after all agents and cron jobs finished. Idempotent."""
349
+ teardown_barriers: Dict[Path, _TeardownBarrier] = {}
350
+ with _lock:
351
+ active_teardowns = list(_tearing_down.values())
352
+ generations = list(_generations.values()) + list(_retired.values())
353
+ for path in {generation.path for generation in generations}:
354
+ teardown_barriers[path] = _admit_teardown_locked(path)
355
+ _generations.clear()
356
+ _retired.clear()
357
+ for generation in generations:
358
+ generation.retired = True
359
+ return _teardown_swept_generations(generations, teardown_barriers, active_teardowns)
360
+
361
+
362
+ def close_all_under(directory: str | Path) -> int:
363
+ """Force-close every shared SessionDB whose file lives under *directory*; returns the count.
364
+
365
+ Profile delete rmtree (and a same-name recreate) fails while this process still holds
366
+ ``state.db``. Same contract as ``MemoryStore.release_all_under``: a live holder is
367
+ expected to fail afterward; a process that holds none is a no-op returning 0.
368
+
369
+ A final ``release()`` can drop the generation and admit teardown before the physical
370
+ close finishes. Wait for those directory-matching barriers even when no generation
371
+ remains, otherwise rmtree still sees the open handle.
372
+ """
373
+ try:
374
+ root = Path(directory).expanduser().resolve()
375
+ except OSError:
376
+ root = Path(directory).expanduser()
377
+ teardown_barriers: Dict[Path, _TeardownBarrier] = {}
378
+ with _lock:
379
+ generations = [
380
+ generation
381
+ for generation in list(_generations.values()) + list(_retired.values())
382
+ if _path_is_under(generation.path, root)
383
+ ]
384
+ # Collect by directory, not by remaining generations: a last release already
385
+ # popped the generation and left only ``_tearing_down``.
386
+ active_teardowns = [
387
+ barrier for path, barrier in _tearing_down.items()
388
+ if _path_is_under(path, root)
389
+ ]
390
+ selected_paths = {generation.path for generation in generations}
391
+ for path in selected_paths:
392
+ teardown_barriers[path] = _admit_teardown_locked(path)
393
+ for generation in generations:
394
+ generation.retired = True
395
+ if _generations.get(generation.path) is generation:
396
+ _generations.pop(generation.path, None)
397
+ _retired.pop(id(generation.db), None)
398
+ return _teardown_swept_generations(generations, teardown_barriers, active_teardowns)
399
+
400
+
401
+ def live_shared_session_dbs() -> List["SessionDB"]:
402
+ """Snapshot of every live (non-retired) shared SessionDB (refcounts untouched), for
403
+ in-process maintenance. A concurrent final release may close an instance, in which
404
+ case the callee sees ``_conn is None``."""
405
+ with _lock:
406
+ return [g.db for g in _generations.values() if not g.retired]
407
+
408
+
409
+ @contextlib.contextmanager
410
+ def borrow_live_shared_session_dbs() -> Iterator[List["SessionDB"]]:
411
+ """Borrow live handles with registry references pinned for the whole block.
412
+
413
+ Maintenance must not operate on the unowned snapshot returned by
414
+ :func:`live_shared_session_dbs`: the last real owner could otherwise
415
+ release and physically close the connection between the snapshot and the
416
+ maintenance call. Each borrowed generation gets one temporary reference;
417
+ the ``finally`` block releases it even when maintenance raises.
418
+ """
419
+ with _lock:
420
+ borrowed_generations = [
421
+ generation for generation in _generations.values() if not generation.retired
422
+ ]
423
+ borrowed = [generation.db for generation in borrowed_generations]
424
+ for generation in borrowed_generations:
425
+ generation.refcount += 1
426
+ try:
427
+ yield borrowed
428
+ finally:
429
+ for db in reversed(borrowed):
430
+ release(db)
431
+
432
+
433
+ def stats() -> Dict[str, int]:
434
+ """Registry census for tests and diagnostics (no locks held long)."""
435
+ with _lock:
436
+ return {
437
+ "live_generations": len(_generations), "retired_generations": len(_retired),
438
+ "total_refcounts": sum(g.refcount for g in _generations.values()),
439
+ }
440
+
441
+
442
+ def release_or_close(db: "SessionDB") -> None:
443
+ """Release a shared instance, or close it when it is not registry-managed. Drop-in for a
444
+ plain ``db.close()``: read-only opens, CLI one-shots and test fakes fall back."""
445
+ if not release(db):
446
+ _close_quietly(db, "release_or_close fallback close failed")
447
+
448
+
449
+ # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
450
+ # Names external plugins imported from this module before the Sep 2026 decomposition.
451
+ # Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
452
+ # The whole block is removed by reverting the commit that added it.
453
+
454
+ def close_shared_session_dbs() -> int:
455
+ return close_all()
456
+
457
+ def get_shared_session_db(db_path: Optional[Path] = None) -> "SessionDB":
458
+ return acquire(db_path)
459
+
460
+ def release_shared_session_db(db: "SessionDB") -> bool:
461
+ return release(db)
462
+ # ---- END PLUGIN-COMPAT ----
hermes_state_repair.py ADDED
@@ -0,0 +1,1152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """state.db repair, backup and writability preflight (split from hermes_state).
2
+
3
+ Patchable helpers are looked up as module globals at call time, so tests patch ``hermes_state_repair.<name>``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import datetime
10
+ import hashlib
11
+ import itertools
12
+ import json
13
+ import logging
14
+ import os
15
+ import re
16
+ import shutil
17
+ import sqlite3
18
+ import stat
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional, Tuple
22
+
23
+ from hermes_constants import get_hermes_home
24
+ from hermes_startup_watchdog import report_startup_progress
25
+ from hermes_state_holders import read_only_db_uri
26
+ from hermes_state_common import (
27
+ _acquire_db_flock, _clear_lock_holder_record, _describe_lock_holder, _read_lock_holder_record,
28
+ is_advisory_lock_contention,
29
+ )
30
+
31
+ # Log-record parity with the origin module (caplog tests pin "hermes_state").
32
+ logger = logging.getLogger("hermes_state")
33
+
34
+ _REPAIR_LOCK_POLL_SECONDS = 0.1
35
+ # Snapshot copies are data transfer, not locking: bounded separately at 10 MiB/s (historical two-minute floor).
36
+ _REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND = 10 * 1024 * 1024
37
+ # ── Repair-loop bounding + dead-backup hygiene (#86747) ───────────────────── ``_claim_repair_attempt``
38
+ # above is an in-memory set: it bounds the loop only WITHIN one process. A corruption class the strategies
39
+ # cannot heal (b-tree page damage) failed repair on EVERY process start, and each pass took a fresh ~900MB
40
+ # forensic backup — 105 attempts / 89GB of identical dead copies in the reporting install. Two persistent
41
+ # bounds fix the class: * a sidecar attempt ledger (``<db>.repair-attempts.json``) that refuses further
42
+ # surgery after ``_MAX_PERSISTENT_REPAIR_ATTEMPTS`` failures on the SAME damaged file (fingerprint = size +
43
+ # a bounded content sample; any successful repair or replacement changes it and resets the count); * backup
44
+ # dedupe + a retention cap in ``_backup_db_file`` — an identical damaged file is never copied twice, and
45
+ # only the newest ``_MAX_MALFORMED_BACKUPS`` forensic copies are kept.
46
+ _MAX_PERSISTENT_REPAIR_ATTEMPTS = 3
47
+ _MAX_MALFORMED_BACKUPS = 3
48
+ # Sidecars copied with a damaged DB and pruned with it. ``-journal``: DELETE mode (the NFS/SMB/FUSE/ZFS and
49
+ # WAL-reset-bug fallback) leaves a hot journal whenever a transaction was open; without it the copy cannot roll back.
50
+ _DB_SIDECAR_SUFFIXES = ("-wal", "-shm", "-journal")
51
+ # Head/tail bytes sampled by ``_db_fingerprint``: changes on any real repair/truncation/restore, O(1) on a multi-GB file.
52
+ _FINGERPRINT_SAMPLE_BYTES = 65536
53
+ # Header ranges that move on ordinary commits, not on repair — file change counter (24-27), version-valid-for
54
+ # (92-95) — masked out of the sample. A malformed-SCHEMA DB still accepts writes and DELETE mode writes the main
55
+ # file directly, so without the mask any live write re-keys the ledger and the repair budget resets to 1
56
+ # forever. The page-1 sqlite_master b-tree (repair identity) sits after byte 100.
57
+ _FINGERPRINT_VOLATILE_HEADER_RANGES = ((24, 28), (92, 96))
58
+ # Headroom for the forensic backup (a full raw copy; a repair loop on a large state.db is a disk amplifier).
59
+ # Proportional, not a flat multi-GB floor: a refused backup is a HARD STOP, and a big reserve would make repair
60
+ # never run on small volumes.
61
+ # The backup is a full raw copy of the damaged DB (plus its -wal/-shm sidecars), so a repair loop on a large
62
+ # state.db is a disk amplifier: the reporting incident wrote ~98MB every ~10s until the volume was nearly
63
+ # full, which would have taken down every agent on the host. Require the copy itself plus a small slice of
64
+ # the volume, clamped to a modest floor. See #69603.
65
+ _REPAIR_BACKUP_MIN_FREE_BYTES = 256 * 1024 * 1024 # 256 MiB absolute floor
66
+ _REPAIR_BACKUP_FREE_FRACTION = 0.02 # plus 2% of the volume
67
+ _FTS_TABLES = ("messages_fts", "messages_fts_trigram", "messages_fts_cjk")
68
+ _MANUAL_RECOVER_HINT = ("Free disk space, then retry (or recover manually with "
69
+ "`hermes sessions recover --source {db_path} --inspect-only` first).")
70
+
71
+
72
+ def _sidecars(db_path: Path):
73
+ """The three SQLite sidecar paths for *db_path* (present or not)."""
74
+ return (db_path.with_name(db_path.name + suffix) for suffix in _DB_SIDECAR_SUFFIXES)
75
+
76
+
77
+ def _read_offline(db_path: Path, what: str, reader) -> Optional[str]:
78
+ """Run *reader()* under ``hermes_cli.sqlite_safe_read.offline_file_access``.
79
+
80
+ ``close()`` on ANY raw descriptor cancels every POSIX advisory lock this process holds on the file,
81
+ including a peer connection's RESERVED lock (``sqlite_safe_read`` rule 1), so a raw read is only safe with
82
+ no live connection; ``None`` when that makes it unsafe or the file is unreadable. Scaffold/embed installs
83
+ without hermes_cli have no tracked connections."""
84
+ try:
85
+ from hermes_cli.sqlite_safe_read import LiveConnectionError, offline_file_access
86
+ except ImportError:
87
+ offline_file_access, LiveConnectionError = (lambda _p, **_k: contextlib.nullcontext()), OSError
88
+ try:
89
+ with offline_file_access(db_path, what=what):
90
+ return reader()
91
+ except (LiveConnectionError, OSError):
92
+ return None
93
+
94
+
95
+ def _claim_repair_attempt(db_path: Path) -> bool:
96
+ """Claim the one-shot per-process repair attempt for *db_path*: True for the first caller, False
97
+ afterwards (bounds the repair/reopen loop and stops concurrent callers racing surgery on one file)."""
98
+ from hermes_state import _repair_attempt_lock, _repair_attempted_paths
99
+ with _repair_attempt_lock:
100
+ if str(db_path) in _repair_attempted_paths:
101
+ return False
102
+ _repair_attempted_paths.add(str(db_path))
103
+ return True
104
+
105
+
106
+ def _open_lock_file(db_path: Path, suffix: str, what: str, tail: str):
107
+ """Open ``<db>.<suffix>`` for locking; on failure warn and return None."""
108
+ lock_path = db_path.with_name(db_path.name + suffix)
109
+ try:
110
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
111
+ return lock_path, lock_path.open("a+b")
112
+ except OSError as exc:
113
+ logger.warning(f"Could not open state.db {what} lock %s (%s) — {tail}", lock_path, exc)
114
+ return lock_path, None
115
+
116
+
117
+ def _msvcrt_lock(handle, flag_name: str) -> None:
118
+ import msvcrt
119
+ handle.seek(0)
120
+ msvcrt.locking(handle.fileno(), getattr(msvcrt, flag_name), 1) # type: ignore[attr-defined]
121
+
122
+
123
+ def _try_lock_nonblocking(handle) -> None:
124
+ """Take the advisory lock on *handle* without waiting (raises on contention)."""
125
+ from hermes_state import _IS_WINDOWS
126
+ if _IS_WINDOWS:
127
+ _msvcrt_lock(handle, "LK_NBLCK")
128
+ else:
129
+ import fcntl
130
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
131
+
132
+
133
+ def _release_lock_handle(handle, *, clear_record: bool = False) -> None:
134
+ """Drop the advisory lock on *handle* (best effort) and close it."""
135
+ from hermes_state import _IS_WINDOWS
136
+ with contextlib.closing(handle), contextlib.suppress(OSError): # best-effort release; always close
137
+ if _IS_WINDOWS:
138
+ _msvcrt_lock(handle, "LK_UNLCK")
139
+ else:
140
+ import fcntl
141
+ if clear_record:
142
+ _clear_lock_holder_record(handle)
143
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
144
+
145
+
146
+ def _acquire_repair_lock_windows(lock_path: Path, handle, timeout: float):
147
+ """Windows counterpart of ``_acquire_db_flock``: True / False (timed out) / None (non-contention error)."""
148
+ deadline = time.monotonic() + timeout
149
+ while True:
150
+ try:
151
+ _try_lock_nonblocking(handle)
152
+ return True
153
+ except (BlockingIOError, OSError) as exc:
154
+ if not is_advisory_lock_contention(exc):
155
+ logger.warning("Could not acquire state.db repair lock %s (%s) — skipping schema surgery on a "
156
+ "non-contention error.", lock_path, exc)
157
+ return None
158
+ if time.monotonic() >= deadline:
159
+ return False
160
+ time.sleep(_REPAIR_LOCK_POLL_SECONDS)
161
+
162
+
163
+ @contextlib.contextmanager
164
+ def _cross_process_repair_lock(db_path: Path):
165
+ """Serialize state.db schema surgery across processes.
166
+
167
+ Yields True when this process holds the repair lock, False when the bounded acquire timed out or the lock
168
+ file could not be opened; on False the caller must NOT do surgery (unlocked surgery IS the interleaving
169
+ this prevents). ``flock``: the kernel drops it when the holder dies (a pidfile would wedge every future
170
+ repair); a forked child inheriting the fd is the exception, so the acquire records pid + start time and
171
+ breaks a provably dead holder's lock. Bounded because a live repairer can sit in ``VACUUM`` for minutes.
172
+ An unopenable lock file (no space/inodes/descriptors) fails closed too: a sibling that opened ITS handle
173
+ before the disk filled may be inside surgery.
174
+
175
+ The acquire is still bounded because a *live* repairer can legitimately sit in ``VACUUM`` for minutes on
176
+ a large DB, and an unbounded wait would hang the caller's open with no traceback (the failure shape of
177
+ #36644).
178
+ """
179
+ from hermes_state import _IS_WINDOWS, _REPAIR_LOCK_TIMEOUT_SECONDS
180
+ lock_path, handle = _open_lock_file(
181
+ db_path, ".repair.lock", "repair", "skipping schema surgery rather than running it without cross-process authority.")
182
+ if handle is None:
183
+ yield False
184
+ return
185
+ acquired = False
186
+ try:
187
+ if _IS_WINDOWS:
188
+ acquired = _acquire_repair_lock_windows(lock_path, handle, _REPAIR_LOCK_TIMEOUT_SECONDS)
189
+ else:
190
+ acquired, handle = _acquire_db_flock(str(lock_path), handle, _REPAIR_LOCK_TIMEOUT_SECONDS,
191
+ _REPAIR_LOCK_POLL_SECONDS, "state.db repair lock")
192
+ if acquired is None:
193
+ acquired = False # non-contention failure already logged with its errno
194
+ elif not acquired:
195
+ logger.warning("state.db repair lock %s held by another process for more than %.0fs — skipping schema "
196
+ "surgery in this process to avoid racing the repairer. Recorded holder: %s.",
197
+ lock_path, _REPAIR_LOCK_TIMEOUT_SECONDS,
198
+ _describe_lock_holder(None if _IS_WINDOWS else _read_lock_holder_record(handle)))
199
+ yield acquired
200
+ finally:
201
+ if acquired:
202
+ _release_lock_handle(handle, clear_record=True)
203
+ else:
204
+ handle.close()
205
+
206
+
207
+ def _try_acquire_auto_maintenance_lock(db_path: Path) -> Optional[Any]:
208
+ """Non-blocking cross-process lock for one auto-maintenance pass (None = skip the pass: otherwise two startups
209
+ both pass the interval check and the second prunes a row the first has only just closed recoverably)."""
210
+ _lock_path, handle = _open_lock_file(db_path, ".auto-maintenance.lock", "auto-maintenance", "skipping automatic maintenance.")
211
+ try:
212
+ if handle is not None:
213
+ _try_lock_nonblocking(handle)
214
+ return handle
215
+ except (BlockingIOError, OSError):
216
+ handle.close()
217
+ return None
218
+
219
+
220
+ _release_auto_maintenance_lock = _release_lock_handle # release a _try_acquire_auto_maintenance_lock handle
221
+
222
+
223
+ def _bump_schema_cookie(conn: sqlite3.Connection) -> None:
224
+ """Increment the schema cookie after direct ``sqlite_master`` surgery.
225
+
226
+ Ordinary DDL bumps it and peers compare it before running a prepared statement (how they discard a cached
227
+ schema); ``writable_schema=ON`` edits do NOT, so live connections would keep firing triggers into
228
+ ``messages_fts*`` shadow tables that no longer exist. Best-effort, never raises."""
229
+ try:
230
+ current = conn.execute("PRAGMA schema_version").fetchone()[0]
231
+ # Wrap within SQLite's 32-bit signed range; peers compare for equality.
232
+ conn.execute(f"PRAGMA schema_version={(int(current) + 1) & 0x7FFFFFFF}")
233
+ except (sqlite3.DatabaseError, TypeError, IndexError) as exc:
234
+ logger.warning("Could not bump state.db schema cookie: %s", exc)
235
+
236
+
237
+ def _mask_volatile_header(head: bytes) -> bytes:
238
+ """Zero the commit-counter fields so ordinary writes don't re-key the ledger."""
239
+ if len(head) < 96:
240
+ return head
241
+ buf = bytearray(head)
242
+ for start, end in _FINGERPRINT_VOLATILE_HEADER_RANGES:
243
+ buf[start:end] = b"\x00" * (end - start)
244
+ return bytes(buf)
245
+
246
+
247
+ def _repair_backup_headroom_bytes(total_bytes: int) -> int:
248
+ """Free space required *beyond* the copy itself, for a volume of *total_bytes*."""
249
+ return max(_REPAIR_BACKUP_MIN_FREE_BYTES, int(total_bytes * _REPAIR_BACKUP_FREE_FRACTION))
250
+
251
+
252
+ def _disk_budget(db_path: Path, refusal: str) -> "Tuple[Optional[str], int, int, int]":
253
+ """``(error, bundle_bytes, free_bytes, headroom_bytes)`` for *db_path*'s volume (main file plus every PRESENT
254
+ sidecar); *error* is set (and the sizes zero) on stat()/disk_usage() failure. Fails CLOSED: the nearly-full
255
+ volume these guards exist for is exactly where they are most likely to fail."""
256
+ try:
257
+ need = db_path.stat().st_size + sum(p.stat().st_size for p in _sidecars(db_path) if p.exists())
258
+ usage = shutil.disk_usage(db_path.parent)
259
+ except OSError as exc:
260
+ return (f"could not determine free space on {db_path.parent} ({exc}); refusing the {refusal} rather than risk "
261
+ "filling the volume"), 0, 0, 0
262
+ return None, need, usage.free, _repair_backup_headroom_bytes(usage.total)
263
+
264
+
265
+ def _repair_scratch_space_error(db_path: Path) -> Optional[str]:
266
+ """Return an error unless snapshot, VACUUM and promotion can fit safely."""
267
+ error, snapshot_bytes, free, headroom = _disk_budget(db_path, "repair snapshot")
268
+ if error is not None:
269
+ return error
270
+ # VACUUM on the staged DB may need up to 2x the database size (SQLite docs); the same reserve then covers
271
+ # transactional promotion into the live DB.
272
+ if free >= snapshot_bytes + (2 * snapshot_bytes) + headroom:
273
+ return None
274
+ return (f"only {free / 1e9:.2f}GB free on {db_path.parent}; the repair snapshot needs up to "
275
+ f"{snapshot_bytes / 1e9:.2f}GB, VACUUM may need another {(2 * snapshot_bytes) / 1e9:.2f}GB, and "
276
+ f"{headroom / 1e9:.2f}GB must remain as headroom. Free disk space, then retry.")
277
+
278
+
279
+ def _backup_free_space_error(db_path: Path) -> Optional[str]:
280
+ """Disk guard for the forensic copy: reason to refuse, or None. A full raw copy on a nearly-full volume (which a
281
+ preceding repair loop may itself have caused) can finish off the disk and every process on the machine."""
282
+ hint = _MANUAL_RECOVER_HINT.format(db_path=db_path)
283
+ error, need, free, headroom = _disk_budget(db_path, "forensic copy")
284
+ if error is not None:
285
+ return f"{error}. {hint}"
286
+ if free - need >= headroom:
287
+ return None
288
+ return (f"only {free / 1e9:.2f}GB free on {db_path.parent}; copying the damaged DB needs {need / 1e9:.2f}GB and must "
289
+ f"leave {headroom / 1e9:.2f}GB headroom. {hint}")
290
+
291
+
292
+ def _repair_snapshot_timeout_seconds(source_path: Path) -> float:
293
+ """Bound one SQLite snapshot by source size incl. sidecars (a WAL can hold committed rows not yet in the
294
+ main file), so a healthy large-database copy is not cut off by the repair-lock timeout."""
295
+ from hermes_state import _REPAIR_LOCK_TIMEOUT_SECONDS
296
+ source_bytes = 0
297
+ for candidate in (source_path, *_sidecars(source_path)):
298
+ with contextlib.suppress(FileNotFoundError): # a sidecar may vanish mid-walk
299
+ source_bytes += candidate.stat().st_size
300
+ return max(_REPAIR_LOCK_TIMEOUT_SECONDS, source_bytes / _REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND)
301
+
302
+
303
+ def _repair_failure_consumes_attempt(exc: BaseException) -> bool:
304
+ """Whether a pre-strategy SQLite failure proves deterministic corruption.
305
+
306
+ Lock contention, timeouts, disk-full and I/O failures are environmental — a retry may succeed, so they
307
+ must not burn the repair ledger. Only SQLite's corruption/image result codes prove deterministic damage."""
308
+ if not isinstance(exc, sqlite3.DatabaseError):
309
+ return False
310
+ if isinstance(error_code := getattr(exc, "sqlite_errorcode", None), int):
311
+ # Extended result codes keep the primary code in the low byte.
312
+ return (error_code & 0xFF) in (sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB)
313
+ # Older sqlite3 without result-code attributes: narrow message match only, never turning generic
314
+ # "disk is full"/"readonly" into permanent failures.
315
+ return any(m in str(exc).lower() for m in ("file is not a database", "database disk image is malformed"))
316
+
317
+
318
+ def _repair_ledger_path(db_path: Path) -> Path:
319
+ return db_path.with_name(db_path.name + ".repair-attempts.json")
320
+
321
+
322
+ def _db_fingerprint(db_path: Path) -> "Optional[str]":
323
+ """Cheap identity for a damaged DB file: size + a bounded content sample.
324
+
325
+ EXCLUDES mtime: a malformed-schema DB still accepts writes, so live writers, checkpoints and the
326
+ strategies move mtime between passes; keyed on mtime every pass looked like a NEW file and the attempt
327
+ counter reset forever. Hashing a multi-GB file per open is the cost this ledger avoids, so sample the
328
+ head/tail slices any real repair, truncation or restore must change.
329
+
330
+ ``None`` = identity unavailable (:func:`_read_offline`; a live peer is expected here since this runs
331
+ BEFORE ``_backup_db_file``'s live-connection guard). Callers MUST NOT substitute a differently-shaped key:
332
+ the ledger compares for equality, so alternating shapes never match and the unbounded loop returns."""
333
+ def _sample() -> str:
334
+ st = db_path.stat()
335
+ with open(db_path, "rb") as fh:
336
+ head = fh.read(_FINGERPRINT_SAMPLE_BYTES)
337
+ fh.seek(max(0, st.st_size - _FINGERPRINT_SAMPLE_BYTES))
338
+ tail = fh.read(_FINGERPRINT_SAMPLE_BYTES) if st.st_size > _FINGERPRINT_SAMPLE_BYTES else b""
339
+ return f"{st.st_size}:{hashlib.sha256(_mask_volatile_header(head) + tail).hexdigest()[:32]}"
340
+ return _read_offline(db_path, "fingerprint", _sample)
341
+
342
+
343
+ def _backup_content_identity(db_path: Path) -> "Optional[str]":
344
+ """Recovery-image identity for forensic-backup dedupe: whole file + sidecars.
345
+
346
+ A DIFFERENT relation from :func:`_db_fingerprint` (never conflate them): the fingerprint answers "same
347
+ repair epoch?" from head+tail only, and a live writer can commit into an *interior* page while preserving
348
+ size and both 64 KiB slices — reusing a backup on that basis hands the operator a snapshot predating real
349
+ user data. A forensic copy must claim byte identity, so this digests the ENTIRE main file plus every
350
+ present sidecar (the WAL can hold uncheckpointed frames). ``None`` when a live connection makes the read
351
+ unsafe (:func:`_read_offline`) — the caller then takes a fresh backup, never a false reuse.
352
+
353
+ See #87409.
354
+ """
355
+ def _digest() -> str:
356
+ hasher = hashlib.sha256()
357
+ members = [("main", db_path), *((sfx, p) for sfx, p in zip(_DB_SIDECAR_SUFFIXES, _sidecars(db_path)) if p.exists())]
358
+ for label, path in members:
359
+ # Length-delimit every member (main file included) so the concatenation is prefix-free; otherwise a
360
+ # main-file tail could coincide with a main+sidecar split and dedupe two images together.
361
+ hasher.update(f"\0{label}:{path.stat().st_size}\0".encode())
362
+ with open(path, "rb") as fh:
363
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
364
+ hasher.update(chunk)
365
+ return hasher.hexdigest()
366
+ return _read_offline(db_path, "backup-identity", _digest)
367
+
368
+
369
+ def _read_repair_ledger(db_path: Path) -> "Dict[str, Any]":
370
+ with contextlib.suppress(OSError, ValueError):
371
+ raw = json.loads(_repair_ledger_path(db_path).read_text(encoding="utf-8"))
372
+ return raw if isinstance(raw, dict) else {}
373
+ return {}
374
+
375
+
376
+ def _persistent_repair_attempts_exhausted(db_path: Path) -> bool:
377
+ """Whether *db_path* has already burned its cross-restart repair budget.
378
+
379
+ True only when the ledger records ``_MAX_PERSISTENT_REPAIR_ATTEMPTS`` failures against the CURRENT
380
+ fingerprint. Never raises; a missing/corrupt ledger or unstatable DB reads as "not exhausted" (the
381
+ in-process claim and cross-process lock still bound one run). Fingerprint unavailable (live connection) ->
382
+ compare the SIZE the ledger recorded, otherwise a peer connection hides an exhausted budget on every pass."""
383
+ ledger = _read_repair_ledger(db_path)
384
+ recorded = ledger.get("fingerprint")
385
+ fp = _db_fingerprint(db_path)
386
+ try: # size is the one key component that needs no raw read
387
+ same = recorded == fp if fp is not None else (
388
+ isinstance(recorded, str) and recorded.startswith(f"{db_path.stat().st_size}:"))
389
+ except OSError:
390
+ return False
391
+ return same and int(ledger.get("failed_attempts", 0)) >= _MAX_PERSISTENT_REPAIR_ATTEMPTS
392
+
393
+
394
+ def _persistent_repair_exhausted_error(db_path: Path) -> str:
395
+ """The stable operator-facing diagnostic for an exhausted repair budget. The ``hermes`` commands
396
+ carry the profile selector: a bare ``hermes`` follows ``active_profile`` (#105887)."""
397
+ from hermes_constants import profile_cli_selector
398
+ profile_arg = profile_cli_selector()
399
+ return (f"automatic repair has already failed {_MAX_PERSISTENT_REPAIR_ATTEMPTS} times on this exact file — the "
400
+ f"corruption is beyond the schema/FTS repair strategies (likely b-tree page damage). Manual recovery "
401
+ f"required: restore a backup, or salvage with `hermes {profile_arg}sessions recover --source {db_path} "
402
+ f"--inspect-only`, then (if it reports recoverable) `hermes {profile_arg}sessions recover --source {db_path} "
403
+ f"--output recovered-state.db` (recovery snapshots the damaged file first, then runs the page-level "
404
+ f"`.recover` lane on the copy; do NOT point a raw `sqlite3` shell at the live database). "
405
+ f"Delete {_repair_ledger_path(db_path).name} to force another automatic attempt.")
406
+
407
+
408
+ def _record_repair_outcome(db_path: Path, *, repaired: bool, fingerprint: "Optional[str]" = None) -> None:
409
+ """Update the persistent attempt ledger after a repair pass. Never raises.
410
+
411
+ Keys on the post-attempt fingerprint (what the NEXT exhaustion probe sees). If a live connection makes it
412
+ unavailable, keep the recorded key and still increment — dropping the pass lets a peer reset the budget
413
+ every time. Never write a differently shaped key."""
414
+ ledger_path = _repair_ledger_path(db_path)
415
+ try:
416
+ if repaired:
417
+ ledger_path.unlink(missing_ok=True)
418
+ return
419
+ ledger = _read_repair_ledger(db_path)
420
+ recorded = ledger.get("fingerprint")
421
+ fp = fingerprint if fingerprint is not None else _db_fingerprint(db_path)
422
+ if fp is None:
423
+ if not isinstance(recorded, str):
424
+ # No prior key to extend and no safe way to mint one; the in-process claim and cross-process lock
425
+ # still bound this run.
426
+ return
427
+ fp = recorded
428
+ attempts = int(ledger.get("failed_attempts", 0)) + 1 if recorded == fp else 1
429
+ stamp = datetime.datetime.now().isoformat(timespec="seconds")
430
+ ledger_path.write_text(json.dumps({"fingerprint": fp, "failed_attempts": attempts, "last_attempt": stamp}), encoding="utf-8")
431
+ except Exception as exc: # pragma: no cover - best effort
432
+ logger.warning("Could not update state.db repair ledger: %s", exc)
433
+
434
+
435
+ def _existing_malformed_backups(db_path: Path) -> "List[Path]":
436
+ """Timestamped forensic backups of *db_path*, newest first."""
437
+ prefix = f"{db_path.name}.malformed-backup-"
438
+ try:
439
+ found = [p for p in db_path.parent.iterdir() if p.name.startswith(prefix) and not p.name.endswith(_DB_SIDECAR_SUFFIXES)]
440
+ except OSError:
441
+ return []
442
+ return sorted(found, key=lambda p: p.name, reverse=True)
443
+
444
+
445
+ def _prune_malformed_backups(db_path: Path, keep: int = _MAX_MALFORMED_BACKUPS) -> None:
446
+ """Delete all but the *keep* newest forensic backups (and sidecars)."""
447
+ for stale in _existing_malformed_backups(db_path)[keep:]:
448
+ for victim in (stale, *_sidecars(stale)):
449
+ try:
450
+ victim.unlink(missing_ok=True)
451
+ except OSError as exc: # pragma: no cover - best effort
452
+ logger.warning("Could not prune stale DB backup %s: %s", victim, exc)
453
+
454
+
455
+ def _publish_backup_bundle(db_path: Path, staging: Path, backup_path: Path) -> None:
456
+ """Copy DB + sidecars to *staging* names, then rename each into place.
457
+
458
+ ORDER MATTERS: the main DB name is the bundle's commit marker (what ``_existing_malformed_backups``
459
+ counts), so sidecars publish FIRST and the main DB LAST — a failure partway never leaves a countable
460
+ backup over a missing sidecar. On failure, staging files AND anything promoted are removed."""
461
+ main = (db_path, staging, backup_path)
462
+ sidecars = [(sidecar, staging.with_name(staging.name + suffix), backup_path.with_name(backup_path.name + suffix))
463
+ for suffix, sidecar in zip(_DB_SIDECAR_SUFFIXES, _sidecars(db_path)) if sidecar.exists()]
464
+ published: "List[Path]" = []
465
+ try:
466
+ for src, staged, _dst in (main, *sidecars):
467
+ shutil.copy2(src, staged)
468
+ for _src, staged, dst in (*sidecars, main):
469
+ os.replace(staged, dst)
470
+ published.append(dst)
471
+ except Exception:
472
+ for victim in (*(staged for _s, staged, _d in (*sidecars, main)), *published):
473
+ with contextlib.suppress(OSError): # best effort; a failure here is never the caller's error
474
+ victim.unlink(missing_ok=True)
475
+ raise
476
+
477
+
478
+ def _backup_db_file(db_path: Path) -> "Tuple[Optional[Path], Optional[str]]":
479
+ """Raw-copy a (possibly malformed) DB plus sidecars to a timestamped backup.
480
+
481
+ Raw bytes on purpose: the DB won't open cleanly, so preserve them exactly for forensics. Returns ``(backup_path,
482
+ None)`` or ``(None, reason)``; repair treats a refused backup as a HARD STOP because the bundle is the recovery
483
+ path when every strategy fails. Refuses while a connection to this DB is live in the process: the raw read would
484
+ ``close()`` a descriptor and cancel that connection's POSIX advisory locks (``hermes_cli.sqlite_safe_read``) —
485
+ real case: one SessionDB enters repair while the gateway holds others.
486
+
487
+ Dedupe: reuse the newest backup when byte-identical to the current recovery image (``_backup_content_identity``
488
+ — NOT mtime, NOT ``_db_fingerprint``); a repair loop once re-copied the same bytes on every restart. Staging
489
+ names live OUTSIDE the ``.malformed-backup-`` prefix: inside it they count as a backup, sort NEWEST (prune kept
490
+ partials, deleted intact copies) and dedupe could return one with no real forensic copy on disk.
491
+
492
+ Refusal reasons (``_backup_free_space_error`` / ``_MANUAL_RECOVER_HINT``) point operators at the safe lane,
493
+ `hermes sessions recover --source <db> --inspect-only`, never at a raw sqlite3 shell on the live file.
494
+
495
+ See #69603.
496
+ """
497
+ with contextlib.suppress(ImportError): # scaffold/embed installs without hermes_cli track no connections
498
+ from hermes_cli.sqlite_safe_read import has_live_connection
499
+ if has_live_connection(db_path):
500
+ reason = (f"a connection to {db_path} is still open in this process; raw-copying it would cancel that "
501
+ "connection's POSIX advisory locks. Close all SessionDB handles first.")
502
+ logger.error("Refusing to raw-copy %s for backup: %s", db_path, reason)
503
+ return None, reason
504
+ stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
505
+ backup_path = db_path.with_name(f"{db_path.name}.malformed-backup-{stamp}")
506
+ for seq in itertools.count(1): # same-second collision must not overwrite the earlier forensic copy
507
+ if not backup_path.exists():
508
+ break
509
+ backup_path = db_path.with_name(f"{db_path.name}.malformed-backup-{stamp}_{seq}")
510
+ try:
511
+ # Sweep staging debris from an interrupted pass BEFORE the dedupe (it is byte-identical to the damaged
512
+ # DB, so dedupe would hand it back as a backup). Also the old ``.incomplete`` spelling, which
513
+ # prefix-matches as a backup, sorts NEWEST and would otherwise survive prune forever.
514
+ for pattern in (f"{db_path.name}.backup-staging-*", f"{db_path.name}.malformed-backup-*.incomplete*"):
515
+ for old in db_path.parent.glob(pattern):
516
+ with contextlib.suppress(OSError):
517
+ old.unlink(missing_ok=True)
518
+ with contextlib.suppress(OSError):
519
+ # Hash the source only when there is a candidate: hashing a multi-GB file before copying it is waste.
520
+ if newest := _existing_malformed_backups(db_path)[:1]:
521
+ src_id = _backup_content_identity(db_path)
522
+ if src_id is not None and _backup_content_identity(newest[0]) == src_id:
523
+ logger.info("Reusing existing forensic backup %s (identical to the damaged DB).", newest[0])
524
+ return newest[0], None
525
+ if (reason := _backup_free_space_error(db_path)) is not None:
526
+ logger.error("Refusing forensic backup of %s: %s", db_path, reason)
527
+ return None, reason
528
+ _publish_backup_bundle(db_path, db_path.with_name(f"{db_path.name}.backup-staging-{stamp}"), backup_path)
529
+ # Retention cap (#86747): keep only the newest few forensic copies.
530
+ _prune_malformed_backups(db_path)
531
+ return backup_path, None
532
+ except Exception as exc: # pragma: no cover - best effort
533
+ logger.warning("Could not back up malformed DB %s: %s", db_path, exc)
534
+ return None, f"backup copy failed: {exc}"
535
+
536
+
537
+ def preflight_db_writability(db_path: Path, *, db_label: str = "state.db") -> None:
538
+ """Refuse-or-repair read-only DB files BEFORE the first connection opens.
539
+
540
+ A stray read-only ``state.db`` / ``-wal`` / ``-shm`` (sudo run, restored backup, copied dotfiles) otherwise
541
+ surfaces as an opaque "attempt to write a readonly database" inside ``_init_schema``, and the obvious wrong
542
+ "fix" (deleting the ``-wal``) loses committed transactions. ``chmod u+rw`` repair only inside the Hermes home
543
+ tree (Hermes owns those files; ``chmod`` fails on files the user doesn't own, bounding the repair exactly);
544
+ otherwise fail fast naming the file and command. Never deletes/truncates a WAL sidecar — once writable, the
545
+ normal open checkpoints it. ``:memory:``/``file:`` skipped. Shared with ``kanban_db``.
546
+
547
+ Port of Kilo-Org/kilocode#12508's startup preflight. This preflight:
548
+ """
549
+ if str(db_path) == ":memory:" or str(db_path).startswith("file:"):
550
+ return
551
+ home: Optional[Path] = None
552
+ with contextlib.suppress(Exception): # pragma: no cover - defensive
553
+ home = Path(get_hermes_home()).resolve()
554
+ # SQLite needs a writable directory in every journal mode (WAL/SHM sidecars, or the DELETE-mode journal).
555
+ sidecars = (db_path.with_name(db_path.name + "-wal"), db_path.with_name(db_path.name + "-shm"))
556
+ for p, is_dir in [(db_path.parent, True), *((p, False) for p in (db_path, *sidecars) if p.is_file())]:
557
+ if (is_dir and not p.is_dir()) or os.access(p, os.R_OK | os.W_OK):
558
+ continue
559
+ x = "x" if is_dir else ""
560
+ in_scope = False
561
+ with contextlib.suppress(OSError, ValueError):
562
+ in_scope = home is not None and p.resolve().is_relative_to(home)
563
+ if in_scope:
564
+ os.chmod(p, p.stat().st_mode | stat.S_IRUSR | stat.S_IWUSR | (stat.S_IXUSR if is_dir else 0))
565
+ if in_scope and os.access(p, os.R_OK | os.W_OK):
566
+ logger.info("%s preflight: repaired read-only %s (chmod u+rw%s)", db_label, p, x)
567
+ continue
568
+ wal_note = (" Do NOT delete the -wal file — it contains committed data that "
569
+ "will be merged into the database once it is writable." if p.name.endswith("-wal") else "")
570
+ raise sqlite3.OperationalError(
571
+ f"{db_label} is not writable: {'directory' if is_dir else 'file'} {p} is read-only for this user. Hermes "
572
+ f"needs read-write access to open the database. Fix with: chmod u+rw{x} '{p}' (files owned by another "
573
+ f"user may need sudo/chown).{wal_note}")
574
+
575
+
576
+ def _connect_repair_durable(db_path: Path, *, timeout: float = 5.0) -> sqlite3.Connection:
577
+ """``sqlite3.connect`` for the repair/probe paths, with macOS write barriers.
578
+
579
+ These paths bypass ``SessionDB``/:func:`apply_wal_with_fallback`, so they inherited ``synchronous=NORMAL`` and
580
+ no ``checkpoint_fullfsync`` — on Darwin an interrupted ``REINDEX``/``VACUUM``/``writable_schema`` rewrite leaves
581
+ half-written b-tree pages. Autocommit (``isolation_level=None``): DDL and ``VACUUM`` are illegal inside an
582
+ implicit transaction. Barriers are best-effort: on a malformed schema even ``PRAGMA synchronous=FULL`` raises,
583
+ so whole-file rewrites call :func:`_reapply_durability_barriers` once the schema parses again."""
584
+ conn = sqlite3.connect(str(db_path), timeout=timeout, isolation_level=None)
585
+ _reapply_durability_barriers(conn)
586
+ return conn
587
+
588
+
589
+ def _repair_conn(db_path: Path, *, timeout: float = 5.0):
590
+ """A :func:`_connect_repair_durable` connection as a context manager, closed on exit."""
591
+ return contextlib.closing(_connect_repair_durable(db_path, timeout=timeout))
592
+
593
+
594
+ def _reapply_durability_barriers(conn: sqlite3.Connection) -> bool:
595
+ """Best-effort (re)application of the macOS write barriers; True if accepted.
596
+ Call before ``VACUUM``/``REINDEX`` once the schema parses: a connection opened
597
+ on a malformed schema could not take them at open time. Never raises."""
598
+ from hermes_state_wal import _apply_macos_checkpoint_barrier, _enforce_macos_synchronous_full
599
+ try:
600
+ _apply_macos_checkpoint_barrier(conn)
601
+ _enforce_macos_synchronous_full(conn)
602
+ return True
603
+ except Exception: # schema still unparseable, or anything else — pragmas cannot be set yet
604
+ return False
605
+
606
+
607
+ def apply_durability_barriers(conn: sqlite3.Connection) -> bool:
608
+ """Durability barriers for guest users of ``state.db`` that must inherit its owner's journal mode. Also
609
+ applies the configured ``database.synchronous`` level, a per-connection pragma that otherwise only
610
+ rides on the journal-mode setup path guests must not run."""
611
+ from hermes_state_wal import _apply_synchronous_pragma
612
+ ok = _reapply_durability_barriers(conn)
613
+ with contextlib.suppress(Exception):
614
+ from hermes_cli.config import cfg_get, load_config_readonly # local: avoids an import cycle
615
+ if (raw_synchronous := cfg_get(load_config_readonly(), "database", "synchronous", default=None)) is not None:
616
+ _apply_synchronous_pragma(conn, raw_synchronous, db_label="state.db (guest)")
617
+ return ok
618
+
619
+
620
+ def _close_unpinned(conn: sqlite3.Connection) -> None:
621
+ """Leave EXCLUSIVE locking mode (so the file is never left pinned) and close."""
622
+ with contextlib.suppress(Exception):
623
+ conn.execute("PRAGMA locking_mode=NORMAL")
624
+ conn.close()
625
+
626
+
627
+ def _open_exclusive(db_path: Path, begin: str) -> sqlite3.Connection:
628
+ """Zero-timeout connection holding ``locking_mode=EXCLUSIVE`` after a rolled-back
629
+ *begin*; closed (unpinned) and re-raised when exclusion cannot be taken."""
630
+ conn = _connect_repair_durable(db_path, timeout=0.0)
631
+ try:
632
+ for statement in ("PRAGMA locking_mode=EXCLUSIVE", begin, "ROLLBACK"):
633
+ conn.execute(statement)
634
+ except BaseException:
635
+ _close_unpinned(conn)
636
+ raise
637
+ return conn
638
+
639
+
640
+ @contextlib.contextmanager
641
+ def _exclusive_repair_db_guard(db_path: Path):
642
+ """Yield ``(conn, None)`` — one live connection that excludes writers for repair surgery — or ``(None,
643
+ exc)`` when exclusion could not be taken.
644
+
645
+ ``locking_mode=EXCLUSIVE`` retains file-level exclusion after the short ``BEGIN EXCLUSIVE`` is rolled
646
+ back; the rollback is essential because ``Connection.backup`` uses this connection as *source* and later
647
+ as the promotion *destination*, both transaction-free. It stays open across the snapshot -> strategies ->
648
+ promotion window so no writer can commit a change promotion would overwrite. Existing readers make
649
+ acquisition fail rather than being disturbed (fail closed). Timeout 0: the cross-process lock already
650
+ serializes repairers, and a partial repair is less safe than "stop the gateway and retry"."""
651
+ try:
652
+ guard = _open_exclusive(db_path, "BEGIN EXCLUSIVE")
653
+ except (sqlite3.Error, OSError) as exc:
654
+ yield None, exc
655
+ return
656
+ try:
657
+ yield guard, None
658
+ finally:
659
+ # Releasing the exclusive locks before close keeps a close-time checkpoint from being mistaken for a
660
+ # repair write by callers that reopen immediately.
661
+ _close_unpinned(guard)
662
+
663
+
664
+ def _copy_database_snapshot(source_path: Path, destination_path: Path, *,
665
+ source_connection: Optional[sqlite3.Connection] = None,
666
+ destination_connection: Optional[sqlite3.Connection] = None) -> None:
667
+ """Copy one complete SQLite snapshot without replacing either file inode: the online backup API folds
668
+ committed WAL frames into the source snapshot and writes the destination in one transaction (rolled
669
+ back if interrupted), so ``state.db`` is never swapped out from under handles that refer to it."""
670
+ # Deadline first: a sidecar vanishing mid-stat must not leak a just-opened descriptor.
671
+ deadline_seconds = _repair_snapshot_timeout_seconds(source_path)
672
+ deadline = time.monotonic() + deadline_seconds
673
+
674
+ def _check_deadline(_status: int, _remaining: int, _total: int) -> None:
675
+ if time.monotonic() >= deadline:
676
+ raise TimeoutError(f"timed out copying SQLite repair snapshot after {deadline_seconds:.0f}s")
677
+
678
+ with contextlib.ExitStack() as owned: # closes only connections opened here, destination first
679
+ source = source_connection or owned.enter_context(_repair_conn(source_path))
680
+ if destination_connection is not None and destination_connection.in_transaction:
681
+ # sqlite3_backup needs a transaction-free destination (the guard holds exclusion via locking_mode).
682
+ raise sqlite3.ProgrammingError("SQLite repair backup destination has an active transaction")
683
+ destination = destination_connection or owned.enter_context(_repair_conn(destination_path))
684
+ source.backup(destination, pages=256, progress=_check_deadline, sleep=_REPAIR_LOCK_POLL_SECONDS)
685
+
686
+
687
+ def _schema_not_built(exc: BaseException) -> bool:
688
+ """``no such table/column``: FTS5 / core tables not created yet (brand new file mid-init)."""
689
+ return any(m in str(exc).lower() for m in ("no such table", "no such column"))
690
+
691
+
692
+ # Hermes-owned FTS5 objects: the virtual tables and their shadow b-trees. Full-matched, so a
693
+ # user-created lookalike (``archive_fts_data``) is not swept into the rebuildable set.
694
+ _FTS_OBJECT_RE = re.compile(
695
+ r"messages_fts(_trigram|_cjk)?(_data|_idx|_content|_docsize|_config|_segdir|_segments)?"
696
+ )
697
+ _INTEGRITY_TREE_RE = re.compile(r"\bTree (\d+)\b")
698
+ _INTEGRITY_MISSING_INDEX_RE = re.compile(r"missing from index (\S+)")
699
+
700
+
701
+ def integrity_damage_is_structural(integrity_lines, master_rows) -> bool:
702
+ """True when any damaged object named by ``PRAGMA integrity_check`` output lies outside
703
+ the FTS shadow set: a ``Tree N`` id mapped through ``sqlite_master.rootpage``, an index in
704
+ ``row N missing from index X``, or the file's own freelist. Unparseable lines are not
705
+ counted (the FTS wording stays, which is incomplete rather than wrong). #88587: an FTS
706
+ rebuild cannot repair a canonical b-tree, and the ``.malformed-backup`` it leaves behind
707
+ is a snapshot of the same damage."""
708
+ name_by_rootpage = {int(rp): name for rp, _type, name in master_rows if rp}
709
+ for line in integrity_lines:
710
+ text = str(line)
711
+ if text.startswith("Freelist"):
712
+ return True
713
+ tree = _INTEGRITY_TREE_RE.search(text)
714
+ if tree:
715
+ name = name_by_rootpage.get(int(tree.group(1)), "")
716
+ if name and not _FTS_OBJECT_RE.fullmatch(name):
717
+ return True
718
+ missing = _INTEGRITY_MISSING_INDEX_RE.search(text)
719
+ if missing and not _FTS_OBJECT_RE.fullmatch(missing.group(1)):
720
+ return True
721
+ return False
722
+
723
+
724
+ def state_db_has_structural_damage(db_path: Path) -> bool:
725
+ """Read-only ``integrity_check`` + ``sqlite_master`` rootpage map on a fresh connection;
726
+ ``integrity_damage_is_structural`` over the result. A check that RAISES instead of
727
+ reporting (a torn page under the walk) is structural too: no FTS-only fixture does that
728
+ while ``messages``/``sessions`` read cleanly, and the FTS rebuild ladder cannot help.
729
+ Cannot-open / locked stays False so the caller keeps the FTS path."""
730
+ try:
731
+ conn = sqlite3.connect(read_only_db_uri(db_path), uri=True, timeout=1.0)
732
+ except sqlite3.Error:
733
+ return False
734
+ try:
735
+ master_rows = [tuple(r) for r in conn.execute(
736
+ "SELECT rootpage, type, name FROM sqlite_master WHERE rootpage > 0").fetchall()]
737
+ lines = [str(r[0]) for r in conn.execute("PRAGMA integrity_check").fetchall()]
738
+ except sqlite3.OperationalError:
739
+ return False
740
+ except sqlite3.DatabaseError:
741
+ return True
742
+ finally:
743
+ conn.close()
744
+ return integrity_damage_is_structural(
745
+ itertools.chain.from_iterable(line.splitlines() for line in lines), master_rows)
746
+
747
+
748
+ def _db_opens_cleanly(db_path: Path) -> Optional[str]:
749
+ """Probe a DB on a fresh connection. Returns None if healthy, else a reason.
750
+
751
+ Runs the first statement that trips the malformed-schema parse (``PRAGMA journal_mode``),
752
+ ``integrity_check``, a ``sessions`` read, FTS5 MATCH probes and a rolled-back ``messages`` write — so FTS5
753
+ index corruption (reads and ``integrity_check`` pass, every ``INSERT INTO messages`` fails through the FTS
754
+ triggers) is reported as unhealthy.
755
+
756
+ See #50502.
757
+ """
758
+ from hermes_state import SessionDB, load_fts5_cjk_extension
759
+ # ── Strategy 0.5: rebuild stale B-tree indexes (#63386) ── PRAGMA integrity_check can report "wrong #
760
+ # of entries in index" when a B-tree index (e.g. idx_sessions_handoff_state) falls out of sync with its
761
+ # base table. REINDEX rewrites the index b-tree from the canonical table rows using the existing index
762
+ # definition, fixing the mismatch without touching data or FTS schema.
763
+ conn = _connect_repair_durable(db_path)
764
+ try:
765
+ with contextlib.closing(conn):
766
+ # Best-effort tokenizer load: messages_fts_cjk needs cjk_unicode61 before any statement can touch it;
767
+ # tokenizer absence must never classify as corruption.
768
+ load_fts5_cjk_extension(conn)
769
+ conn.execute("PRAGMA journal_mode").fetchone()
770
+ rows = conn.execute("PRAGMA integrity_check").fetchall()
771
+ problems = [str(r[0]) for r in rows if r and str(r[0]).lower() != "ok"]
772
+ if problems:
773
+ return "; ".join(problems[:3])
774
+ conn.execute("SELECT COUNT(*) FROM sessions").fetchone()
775
+ # FTS5 read probe: partial shadow-table corruption makes MATCH/snippet/rank raise while integrity_check
776
+ # reports healthy. MATCH '""' (empty phrase) parses, scans zero rows and exercises the shadow tables;
777
+ # FTS5 rejects MATCH ''.
778
+ for fts_table in _FTS_TABLES:
779
+ try:
780
+ conn.execute(f"SELECT 1 FROM {fts_table} WHERE {fts_table} MATCH '\"\"' LIMIT 1").fetchone()
781
+ except sqlite3.DatabaseError as exc:
782
+ # Builds without fts5/trigram raise "no such module|tokenizer"; calling that corruption would
783
+ # send the DB into repair, whose final fallback deletes messages_fts%. "no such table/column" =
784
+ # not built yet.
785
+ benign = SessionDB._is_fts5_unavailable_error(exc) or _schema_not_built(exc)
786
+ if not (isinstance(exc, sqlite3.OperationalError) and benign):
787
+ # This is the corruption class #66724 actually wants caught: partial shadow-table
788
+ # damage where MATCH / snippet / rank queries raise DatabaseError("database disk
789
+ # image is malformed") while reads of the FTS5 table itself parse fine.
790
+ return f"fts5 read probe failed on {fts_table}: {exc}"
791
+ # FTS write probe: drive a row through the messages_fts* triggers in a transaction that is always
792
+ # rolled back. The trigger INSERT alone only buffers the row in FTS5's in-memory segment; the
793
+ # ``flush`` command writes that segment to ``<fts>_idx``/``_data`` exactly as a committed append
794
+ # would, so a stale ``_idx`` row at the next segid (IntegrityError "constraint failed", the #100227
795
+ # class: integrity_check and MATCH both clean, every real append fails) is hit here rather than
796
+ # by the user's next message.
797
+ probe_session_id = f"_hermes_fts_health_probe_{time.time_ns()}"
798
+ try:
799
+ conn.execute("BEGIN IMMEDIATE")
800
+ conn.execute("INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
801
+ (probe_session_id, "_health_probe", time.time()))
802
+ conn.execute("INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
803
+ (probe_session_id, "user", "_fts_health_probe", time.time()))
804
+ for fts_table in _FTS_TABLES:
805
+ try:
806
+ conn.execute(f"INSERT INTO {fts_table}({fts_table}) VALUES('flush')")
807
+ except sqlite3.OperationalError as exc:
808
+ if not (SessionDB._is_fts5_unavailable_error(exc) or _schema_not_built(exc)):
809
+ raise
810
+ except sqlite3.DatabaseError as exc:
811
+ # IntegrityError is a DatabaseError sibling of OperationalError, not a child: catching only the
812
+ # latter let the trigram-segment collision report "healthy".
813
+ # Missing messages/sessions tables = brand new file mid-init, not corruption. "no such tokenizer":
814
+ # this process lacks the cjk extension the DB's index needs — capability gap; a tokenizer-less
815
+ # SessionDB drops the triggers itself.
816
+ if _schema_not_built(exc) or "no such tokenizer: cjk_unicode61" in str(exc).lower():
817
+ return None
818
+ return f"fts5 write probe failed: {exc}"
819
+ finally:
820
+ with contextlib.suppress(sqlite3.Error):
821
+ conn.execute("ROLLBACK")
822
+ return None
823
+ except sqlite3.DatabaseError as exc:
824
+ return str(exc)
825
+
826
+
827
+ def _live_writer_holds_db(db_path: Path) -> bool:
828
+ """True when another process (or a connection outside this call) still holds ``db_path``.
829
+
830
+ The foreign-holder scan (``hermes_state_holders``) is the authority: any other process with the DB or a
831
+ WAL sidecar open, a deleted WAL generation, or an unknown/uninspectable holder fails CLOSED. The SQLite
832
+ probe (``locking_mode=EXCLUSIVE`` + ``BEGIN IMMEDIATE``) is only an additional positive signal — it cannot
833
+ see a ``journal_mode=DELETE`` reader and cannot run on a malformed file, which is why the scan comes first."""
834
+ import hermes_state_holders as _state_holders
835
+ return _state_holders.live_writer_holds_db(db_path, connect_repair_durable=_connect_repair_durable)
836
+
837
+
838
+ def _repair_skip(report: Dict[str, Any], verb: str, error: str, exc: Optional[BaseException] = None) -> Dict[str, Any]:
839
+ """Record *error* on *report* and log it as ``state.db repair <verb>``. An
840
+ *exc* proving deterministic corruption consumes the persistent repair budget
841
+ (private ``_repair_attempted`` marker, popped by the caller)."""
842
+ if exc is not None and _repair_failure_consumes_attempt(exc):
843
+ report["_repair_attempted"] = True
844
+ report["error"] = error
845
+ logger.error(f"state.db repair {verb}: %s", error)
846
+ return report
847
+
848
+
849
+ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, Any]:
850
+ """Repair a state.db whose ``sqlite_master`` is malformed or whose FTS indexes reject writes.
851
+
852
+ Two corruption classes: malformed schema / "duplicate object definition" (even ``PRAGMA`` fails), and FTS
853
+ write-corruption (reads and ``integrity_check`` pass, writes fail through ``messages_fts*`` triggers).
854
+ ``_REPAIR_STRATEGIES`` run least-destructive first on a complete snapshot; a success is copied back
855
+ transactionally, so canonical rows are never modified by a failed attempt. A raw backup is taken first
856
+ unless ``backup=False``. Serialised across processes (gateway, Desktop backend and CLI open the same file;
857
+ concurrent ``writable_schema`` surgery is itself a corruption source). Returns ``{repaired, strategy,
858
+ backup_path, error}``.
859
+
860
+ See #50502.
861
+ """
862
+ report: Dict[str, Any] = {"repaired": False, "strategy": None, "backup_path": None, "error": None}
863
+ # Startup-watchdog lease: repair is I/O-bound (near-zero CPU), which the watchdog's CPU fallback would
864
+ # misread as a parked deadlock. One lease (clamped to _MAX_LEASE_S=900) beats per-chunk renewal complexity.
865
+ report_startup_progress(900.0, phase="state_db_repair")
866
+ db_path = Path(db_path)
867
+ if not db_path.exists():
868
+ report["error"] = f"{db_path} does not exist"
869
+ return report
870
+ # Cross-restart cap: the in-memory claim bounds one process, but unhealable b-tree damage used to re-run
871
+ # surgery + a fresh backup on EVERY restart.
872
+ # Cross-restart attempt cap (#86747): the in-memory claim bounds one process, but a corruption class the
873
+ # strategies below cannot heal (b-tree page damage) previously re-ran the whole surgery — and took a
874
+ # fresh multi-hundred-MB forensic backup — on EVERY restart, forever. After
875
+ # _MAX_PERSISTENT_REPAIR_ATTEMPTS failures against the same damaged file, stop retrying and surface a
876
+ # terminal, actionable error.
877
+ if _persistent_repair_attempts_exhausted(db_path):
878
+ return _repair_skip(report, "skipped", _persistent_repair_exhausted_error(db_path))
879
+
880
+ with _cross_process_repair_lock(db_path) as holding_lock:
881
+ if not holding_lock:
882
+ # Another process holds the lock (or the lock file was unopenable); it may have healed the file
883
+ # already, so re-probe before failing.
884
+ if _db_opens_cleanly(db_path) is None:
885
+ report["repaired"], report["strategy"] = True, "repaired_by_other_process"
886
+ else:
887
+ report["error"] = ("could not obtain the state.db repair lock (held by another process, or the lock "
888
+ "file was unopenable); skipped schema surgery to avoid racing a concurrent repairer")
889
+ return report
890
+
891
+ result = report
892
+ # Recheck exhaustion after acquisition: a queued repairer can have recorded the final failure while
893
+ # this process waited.
894
+ if _persistent_repair_attempts_exhausted(db_path):
895
+ _repair_skip(report, "skipped", _persistent_repair_exhausted_error(db_path))
896
+ # WAL-holder preflight: fail closed for active readers before a backup is taken. Not the race defence — the
897
+ # exclusive guard in the locked routine excludes writers through promotion and sees DELETE-mode readers too.
898
+ elif _live_writer_holds_db(db_path):
899
+ _repair_skip(report, "skipped", "a live writer still holds state.db; skipped schema surgery to avoid tearing "
900
+ "b-tree pages under a concurrent writer. Stop the gateway (hermes gateway stop) and retry.")
901
+ else:
902
+ # Probe journal mode BEFORE surgery: a rebuilt file comes back in the default (delete) mode and nothing
903
+ # else records the flip. Unprobeable (damaged file) -> database.journal_mode is the restore target.
904
+ # The open-time WAL-reset gate never sees this flip because it happens inside the repair path
905
+ # (distinct from the open-time flip #89393 warns about).
906
+ before_mode = _probe_journal_mode_for_repair(db_path)
907
+ result = _repair_state_db_schema_locked(db_path, backup=backup, report=report,
908
+ journal_mode_before=before_mode)
909
+ if result.get("repaired"):
910
+ result["journal_mode_before"] = before_mode
911
+ # Environmental aborts (before a strategy mutates the snapshot) are retriable, not proof of exhaustion;
912
+ # the private marker stays out of the public report. The ledger update stays under the cross-process
913
+ # lock so two repairers cannot lose each other's updates; a queued loser must not record at all.
914
+ if result.pop("_repair_attempted", False) or result.get("repaired"):
915
+ _record_repair_outcome(db_path, repaired=bool(result.get("repaired")))
916
+ return result
917
+
918
+
919
+ def _probe_journal_mode_for_repair(db_path: Path) -> Optional[str]:
920
+ """Best-effort journal-mode probe: ``wal``/``delete``, or ``None`` when the file cannot be opened or
921
+ probed (malformed header, concurrent opener's locks — both expected on the repair path); callers then
922
+ fall back to ``database.journal_mode``."""
923
+ from hermes_state_wal import _on_disk_journal_mode
924
+ try:
925
+ with _repair_conn(db_path) as conn:
926
+ return _on_disk_journal_mode(conn)
927
+ except (sqlite3.Error, OSError):
928
+ return None
929
+
930
+
931
+ def _restore_journal_mode_after_repair(db_path: Path, before_mode: Optional[str], *, conn=None) -> None:
932
+ """Re-apply the journal mode after schema surgery.
933
+
934
+ ``conn`` must be the exclusive repair guard connection when called from the repair path: opening a fresh
935
+ connection AFTER the guard released let a writer still holding the unlinked old ``-wal`` inode coexist with
936
+ a brand-new ``state.db-wal`` — two generations of one store. The reopen is the hazard, not the mode.
937
+
938
+ A rebuilt file comes back in the default (delete) mode; without this a corruption event silently moves a
939
+ WAL store out of WAL (the open-time WAL-reset gate never sees a flip made inside repair). Routed through
940
+ :func:`apply_wal_with_fallback`, not a direct pragma, so it inherits the WAL-reset gate (a vulnerable
941
+ runtime deliberately keeps DELETE; the journal_mode-changed WARNING is expected there), the macOS-NFS
942
+ silent-refusal handling and the WAL companions. ``before_mode`` is only for the log comparison; the target
943
+ is ``database.journal_mode``. Best-effort: the repair already succeeded, so failures log at WARNING.
944
+
945
+ See #89674.
946
+ See #89393.
947
+ The transactional promotion already leaves the destination in its pre-repair mode, so on that path this
948
+ is mostly the WAL-companion re-assertion; the reopen is the hazard, not the mode. See #101064.
949
+ """
950
+ from hermes_state_wal import apply_wal_with_fallback
951
+ try:
952
+ if conn is None:
953
+ with _repair_conn(db_path) as owned:
954
+ after = apply_wal_with_fallback(owned, db_label=db_path.name)
955
+ else:
956
+ after = apply_wal_with_fallback(conn, db_label=db_path.name)
957
+ if before_mode and after != before_mode:
958
+ logger.warning("state.db repair changed journal_mode %r -> %r (pre-surgery probe %r; restore resolved "
959
+ "through apply_wal_with_fallback per database.journal_mode and the WAL-reset gate)",
960
+ before_mode, after, before_mode)
961
+ except (sqlite3.Error, OSError) as exc:
962
+ logger.warning("state.db repair at %s: post-surgery journal-mode restore failed (%s); verify with PRAGMA "
963
+ "journal_mode on the next open", db_path, exc)
964
+
965
+
966
+ def _repair_state_db_schema_locked(
967
+ db_path: Path, *, backup: bool, report: Dict[str, Any], journal_mode_before: Optional[str] = None,
968
+ ) -> Dict[str, Any]:
969
+ """Repair strategies for :func:`repair_state_db_schema`; caller holds the cross-process repair lock.
970
+
971
+ Strategies run on a SCRATCH COPY, copied back through SQLite's transactional backup API only once proven to open
972
+ cleanly, so a failed repair cannot modify or lose committed data (a WAL checkpoint of committed frames on guard
973
+ release is not a repair mutation). WHY not in place: the final strategy ends in ``VACUUM``, which rebuilds the
974
+ file from the schema SQLite can still parse — when the damage IS in the schema b-tree (the ``malformed database
975
+ schema ()`` class) every table hanging off the unreadable part is silently dropped, the probe still reports
976
+ malformed, and repair returned ``repaired=False`` having destroyed what it was asked to save.
977
+
978
+ The pre-repair backup (#69603) does not close this: it is a forensic artefact that nothing reads back,
979
+ so recovery still depends on a human noticing a ``.malformed-backup-*`` file and knowing what to do with
980
+ it. Not mutating the original in the first place is the property that holds without a human in the loop.
981
+ """
982
+ scratch = db_path.with_name(f"{db_path.name}.repair-scratch")
983
+ if (cleanup_error := _unlink_db_triple(scratch)) is not None:
984
+ return _repair_skip(report, "aborted", f"could not remove a stale repair snapshot before probing state.db: {cleanup_error}")
985
+ # Re-probe under the lock: a process we queued behind may have just repaired the file; redoing surgery
986
+ # would undo it (the repair/re-corrupt cascade).
987
+ if _db_opens_cleanly(db_path) is None:
988
+ report["repaired"], report["strategy"] = True, "already_healthy"
989
+ return report
990
+ if backup:
991
+ bpath, backup_error = _backup_db_file(db_path)
992
+ report["backup_path"] = str(bpath) if bpath else None
993
+ if bpath is None: # HARD STOP: the forensic image is the recovery path when every strategy fails.
994
+ return _repair_skip(report, "aborted", "pre-repair backup refused; aborting schema repair to avoid "
995
+ f"mutating the only copy of the damaged DB: {backup_error}")
996
+ # The forensic copy precedes this guard on purpose: its live-holder checks would be poisoned by our own
997
+ # exclusive connection. Everything touching the repair image or live promotion happens under writer exclusion.
998
+ with _exclusive_repair_db_guard(db_path) as (live_guard, guard_error):
999
+ if live_guard is None:
1000
+ return _repair_skip(report, "skipped", "could not acquire exclusive state.db repair ownership; skipped "
1001
+ f"schema surgery to avoid overwriting a concurrent writer. Stop the gateway and retry: "
1002
+ f"{guard_error}", exc=guard_error)
1003
+ if (space_error := _repair_scratch_space_error(db_path)) is not None:
1004
+ return _repair_skip(report, "aborted", space_error)
1005
+ try:
1006
+ # Source = live_guard: it owns the exclusion, and a second connection could be blocked by our own
1007
+ # EXCLUSIVE lock on some SQLite builds.
1008
+ _copy_database_snapshot(db_path, scratch, source_connection=live_guard)
1009
+ except (OSError, sqlite3.Error, TimeoutError) as exc:
1010
+ _unlink_db_triple(scratch)
1011
+ return _repair_skip(report, "aborted", f"could not stage a complete SQLite repair snapshot of {db_path}: {exc}", exc=exc)
1012
+ try:
1013
+ # Private marker for the outer wrapper: a strategy failure consumes the persistent budget; a
1014
+ # promotion failure is classified separately.
1015
+ report["_repair_attempted"] = True
1016
+ _run_repair_strategies(scratch, report)
1017
+ if report.get("repaired"):
1018
+ # Never ``os.replace`` the live DB: Windows rejects replacement under open handles and POSIX would
1019
+ # leave those handles on the old inode. The guard keeps writer exclusion throughout.
1020
+ try:
1021
+ _copy_database_snapshot(scratch, db_path, destination_connection=live_guard)
1022
+ except (OSError, sqlite3.Error, TimeoutError) as exc:
1023
+ report.update(repaired=False, strategy=None,
1024
+ _repair_attempted=_repair_failure_consumes_attempt(exc))
1025
+ report["error"] = f"repaired snapshot could not be promoted transactionally: {exc}"
1026
+ logger.error("state.db repair promotion failed: %s", exc)
1027
+ else:
1028
+ logger.warning("state.db repaired via '%s' and promoted transactionally: %s",
1029
+ report.get("strategy"), db_path)
1030
+ _restore_journal_mode_after_repair(db_path, journal_mode_before, conn=live_guard)
1031
+ if not report.get("repaired"):
1032
+ # Logged HERE, not in the strategies: they see the scratch copy, and the message a human acts on
1033
+ # must name a path that still exists.
1034
+ logger.error("state.db schema repair could not recover %s automatically (no committed canonical data "
1035
+ "was modified or lost; backup: %s); manual restore from backup may be required.",
1036
+ db_path, report["backup_path"])
1037
+ return report
1038
+ finally:
1039
+ # Never leave a half-repaired file beside the DB to be mistaken for the real thing.
1040
+ if (cleanup_error := _unlink_db_triple(scratch)) is not None:
1041
+ logger.warning("Could not remove state.db repair snapshot after repair: %s", cleanup_error)
1042
+
1043
+
1044
+ def _unlink_db_triple(path: Path) -> Optional[str]:
1045
+ """Remove *path* and every SQLite sidecar; return any cleanup failure."""
1046
+ from hermes_state import _IS_WINDOWS
1047
+ failures: List[str] = []
1048
+ for victim in (path, *_sidecars(path)):
1049
+ for attempt in range(10):
1050
+ try:
1051
+ victim.unlink(missing_ok=True)
1052
+ except OSError as exc:
1053
+ # Windows may retain a just-closed SQLite handle for a few scheduler ticks; bounded retry.
1054
+ if isinstance(exc, PermissionError) and _IS_WINDOWS and attempt < 9:
1055
+ time.sleep(0.05)
1056
+ continue
1057
+ failures.append(f"{victim}: {exc}")
1058
+ break
1059
+ return "; ".join(failures) or None
1060
+
1061
+
1062
+ # ── Repair strategies, least destructive first (each mutates its connection's DB) ──
1063
+
1064
+ def _edit_sqlite_master(conn: sqlite3.Connection, edit) -> None:
1065
+ """Run *edit* under ``writable_schema=ON``; bump the schema cookie when it
1066
+ reports a change so live peers discard their cached schema."""
1067
+ conn.execute("PRAGMA writable_schema=ON")
1068
+ if edit():
1069
+ _bump_schema_cookie(conn)
1070
+ conn.execute("PRAGMA writable_schema=OFF")
1071
+ conn.commit()
1072
+
1073
+
1074
+ def _strategy_rebuild_fts(conn: sqlite3.Connection) -> None:
1075
+ """FTS5 'rebuild' rewrites each index from the content table: the least-
1076
+ destructive fix for an index that rejects writes while reads work."""
1077
+ from hermes_state import load_fts5_cjk_extension
1078
+ # The cjk index can only be rebuilt with its tokenizer loaded (best-effort).
1079
+ load_fts5_cjk_extension(conn)
1080
+ for table_name in _FTS_TABLES:
1081
+ with contextlib.suppress(sqlite3.OperationalError): # table absent (FTS disabled / trigram off / cjk absent)
1082
+ conn.execute(f"INSERT INTO {table_name}({table_name}) VALUES('rebuild')")
1083
+
1084
+
1085
+ def _strategy_reindex(conn: sqlite3.Connection) -> None:
1086
+ """integrity_check reports "wrong # of entries in index" when a B-tree
1087
+ index drifts from its base table; REINDEX rewrites it from canonical rows."""
1088
+ # REINDEX rewrites every index b-tree; take the barriers now that the schema parses.
1089
+ _reapply_durability_barriers(conn)
1090
+ conn.execute("REINDEX")
1091
+ conn.commit()
1092
+
1093
+
1094
+ def _strategy_dedup_schema(conn: sqlite3.Connection) -> None:
1095
+ """De-duplicate sqlite_master (lowest rowid per type/name wins), keeping FTS."""
1096
+ def _dedup() -> bool:
1097
+ dupes = conn.execute(
1098
+ "SELECT type, name, COUNT(*) AS c, MIN(rowid) AS keep FROM sqlite_master GROUP BY type, name HAVING c > 1"
1099
+ ).fetchall()
1100
+ for type_, name, _count, keep in dupes:
1101
+ conn.execute("DELETE FROM sqlite_master WHERE type IS ? AND name IS ? AND rowid <> ?",
1102
+ (type_, name, keep))
1103
+ return bool(dupes)
1104
+
1105
+ _edit_sqlite_master(conn, _dedup)
1106
+
1107
+
1108
+ def _strategy_drop_fts_vacuum(conn: sqlite3.Connection) -> None:
1109
+ """Drop all FTS schema and VACUUM; indexes rebuild on the next open. The
1110
+ destructive one, and why strategies run on a scratch copy: on a damaged
1111
+ schema b-tree VACUUM silently drops every table hanging off the unreadable part."""
1112
+ _edit_sqlite_master(conn, lambda: conn.execute("DELETE FROM sqlite_master WHERE name LIKE 'messages_fts%'") or True)
1113
+ # The schema parses now, so the barriers can stick — VACUUM rewrites the whole file.
1114
+ _reapply_durability_barriers(conn)
1115
+ conn.execute("VACUUM")
1116
+
1117
+
1118
+ # (name, body, success log, failure log) in escalation order. failure log None =
1119
+ # final strategy: its failure lands in report["error"] instead of logged-and-skipped.
1120
+ _REPAIR_STRATEGIES = (
1121
+ ("rebuild_fts", _strategy_rebuild_fts, "state.db FTS indexes rebuilt in place (schema preserved): %s",
1122
+ "state.db FTS in-place rebuild pass failed: %s"),
1123
+ ("reindex_btree", _strategy_reindex, "state.db B-tree indexes rebuilt via REINDEX: %s",
1124
+ "state.db REINDEX pass failed: %s"),
1125
+ ("dedup_schema", _strategy_dedup_schema,
1126
+ "state.db schema repaired by de-duplicating sqlite_master (FTS index preserved): %s",
1127
+ "state.db dedup repair pass failed: %s"),
1128
+ ("drop_fts_rebuild", _strategy_drop_fts_vacuum,
1129
+ "state.db schema repaired by dropping FTS schema; indexes will rebuild from messages on next open: %s", None),
1130
+ )
1131
+
1132
+
1133
+ def _run_repair_strategies(db_path: Path, report: Dict[str, Any]) -> Dict[str, Any]:
1134
+ """Escalating repair attempts, applied to *db_path* IN PLACE — only ever a scratch copy nothing else holds open,
1135
+ never the user's database. The "could not recover" log lives in the caller so it names the user's database."""
1136
+
1137
+ for name, body, success_msg, failure_msg in _REPAIR_STRATEGIES:
1138
+ try:
1139
+ with _repair_conn(db_path) as conn:
1140
+ body(conn)
1141
+ reason = _db_opens_cleanly(db_path)
1142
+ except sqlite3.DatabaseError as exc:
1143
+ reason = str(exc)
1144
+ if failure_msg is not None:
1145
+ logger.warning(failure_msg, exc)
1146
+ if reason is None:
1147
+ report["repaired"], report["strategy"] = True, name
1148
+ logger.warning(success_msg, db_path)
1149
+ return report
1150
+ if failure_msg is None:
1151
+ report["error"] = reason
1152
+ return report
hermes_state_sessions.py ADDED
@@ -0,0 +1,1627 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Session lifecycle mixin for SessionDB: row upsert/inheritance, lifecycle
2
+ flags (end/reopen/archive/pin/hide/read), model_config patching, listing and
3
+ counting, delete cascades, and the auto-archive sweep."""
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ import sqlite3
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Dict, List, Optional, Tuple
12
+
13
+ from agent.session_activity import (
14
+ ActivityProvenance, bound_activity_description, normalize_activity_provenance,
15
+ )
16
+ from hermes_state_common import (
17
+ _LISTABLE_CHILD_SQL, _PREVIEW_ELIGIBLE_SQL, _PREVIEW_RAW_SELECT, _RECOVERABLE_END_REASONS,
18
+ _RECOVERABLE_END_REASONS_SQL, _RESET_END_REASONS, _legacy_reset_child_sql, _shape_preview,
19
+ _sql_json_extract, _sql_session_last_active, _sql_session_last_active_by_id, escape_like as _escape_like,
20
+ _SQL_IN_CHUNK, _id_chunks, _placeholders as _session_ids_placeholders,
21
+ )
22
+
23
+ # caplog tests pin the "hermes_state" logger name.
24
+ logger = logging.getLogger("hermes_state")
25
+
26
+
27
+ def workspace_key(row: Dict[str, Any]) -> Optional[str]:
28
+ """Workspace grouping key: git repo root, else cwd, else None (branch excluded: a checkout must not
29
+ fragment history)."""
30
+ return (row.get("git_repo_root") or "").strip() or (row.get("cwd") or "").strip() or None
31
+
32
+
33
+ def _delegate_from_json(col: str = "model_config") -> str:
34
+ return _sql_json_extract(col, "$._delegate_from")
35
+
36
+
37
+ # _merge_model_config_json's "no such row" result — distinct from the legal None
38
+ # ("merged config is empty → store NULL").
39
+ _MODEL_CONFIG_ROW_MISSING = object()
40
+
41
+
42
+ def _parse_model_config(raw: Any) -> Dict[str, Any]:
43
+ """Tolerant ``model_config`` decode: JSON text or dict -> dict copy; anything else -> {}."""
44
+ if isinstance(raw, str) and raw.strip():
45
+ try:
46
+ raw = json.loads(raw)
47
+ except (json.JSONDecodeError, TypeError):
48
+ return {}
49
+ return dict(raw) if isinstance(raw, dict) else {}
50
+
51
+
52
+ def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
53
+ prefix = cwd_prefix.rstrip("/\\") or cwd_prefix
54
+ # ``_``/``%`` are LIKE wildcards but ordinary path characters: unescaped, a
55
+ # prefix also matches sibling directories. The ``=`` arm keeps the raw prefix.
56
+ esc = _escape_like(prefix)
57
+ return (
58
+ "(s.cwd = ? OR s.cwd LIKE ? ESCAPE '\\' OR s.cwd LIKE ? ESCAPE '\\')",
59
+ [prefix, f"{esc}/%", f"{esc}\\\\%"],
60
+ )
61
+
62
+
63
+ def _workspace_key_clause(key: str) -> Tuple[str, List[str]]:
64
+ """WHERE for ``workspace_key(row) == key``: git_repo_root equals ``key``, or (rows predating
65
+ per-session git metadata) cwd is at/under ``key``."""
66
+ prefix = key.rstrip("/\\") or key
67
+ cwd_clause, cwd_params = _cwd_prefix_clause(prefix)
68
+ return (
69
+ f"(s.git_repo_root = ? OR (COALESCE(s.git_repo_root, '') = '' AND {cwd_clause}))",
70
+ [prefix, *cwd_params],
71
+ )
72
+
73
+
74
+ # First user message of a session, shaped by _shape_preview() in Python.
75
+ # The indentation is part of the list_sessions_rich SQL text.
76
+ _PREVIEW_COL_SQL = f"""COALESCE(
77
+ (SELECT {_PREVIEW_RAW_SELECT}
78
+ FROM messages m
79
+ WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
80
+ AND {_PREVIEW_ELIGIBLE_SQL}
81
+ ORDER BY m.timestamp, m.id LIMIT 1),
82
+ ''
83
+ ) AS _preview_raw"""
84
+
85
+
86
+ def _where_sql(clauses: List[str], lead: str = "") -> str:
87
+ """``WHERE a AND b`` (with *lead* prefix) or "" when there are no clauses."""
88
+ return f"{lead}WHERE {' AND '.join(clauses)}" if clauses else ""
89
+
90
+
91
+ def _session_filter_where(
92
+ *, exclude_children: bool = False, source: str = None, sources: List[str] = None,
93
+ session_key: str = None, exclude_sources: List[str] = None, cwd_prefix: str = None,
94
+ min_message_count: int = 0, archived_only: bool = False, include_archived: bool = False,
95
+ ) -> Tuple[List[str], List[Any]]:
96
+ """Shared ``sessions s`` WHERE builder so counts line up with listed rows. ``exclude_children``
97
+ hides sub-agent runs and compression continuations but keeps branch/reset children
98
+ (``_LISTABLE_CHILD_SQL``). Clause order is part of the SQL text contract."""
99
+ where: List[str] = []
100
+ params: List[Any] = []
101
+ if exclude_children:
102
+ where += [_LISTABLE_CHILD_SQL, f"{_delegate_from_json('s.model_config')} IS NULL"]
103
+ # Show roots and user-visible branch/reset sessions, while still hiding sub-agent runs and compression
104
+ # continuations. All four carry parent_session_id, so the shared predicate classifies the edge from
105
+ # stable markers plus legacy-compatible parent metadata. Branch sessions are identified two ways, OR'd
106
+ # for robustness: 1. A stable ``_branched_from`` marker in model_config, written by /branch at creation
107
+ # time. This survives the parent being reopened and re-ended with a different end_reason (e.g.
108
+ # tui_shutdown overwriting 'branched'), which otherwise hides the branch — see issue #20856. 2. The
109
+ # legacy heuristic (parent ended with 'branched' before the child started), covering branch sessions
110
+ # created before the marker existed.
111
+ include_sources = [source] if source else list(sources or [])
112
+ for clause, values in (
113
+ (f"s.source IN ({_session_ids_placeholders(include_sources)})", include_sources),
114
+ ("s.session_key = ?", [session_key] if session_key else []),
115
+ (f"s.source NOT IN ({_session_ids_placeholders(exclude_sources or ())})", exclude_sources or []),
116
+ (_cwd_prefix_clause(cwd_prefix) if cwd_prefix else ("", [])),
117
+ ("s.message_count >= ?", [min_message_count] if min_message_count > 0 else []),
118
+ ):
119
+ if values:
120
+ where.append(clause)
121
+ params.extend(values)
122
+ if archived_only:
123
+ where.append("s.archived = 1")
124
+ elif not include_archived:
125
+ where.append("s.archived = 0")
126
+ return where, params
127
+
128
+
129
+ def _collect_delegate_child_ids(conn, parent_ids: List[str]) -> List[str]:
130
+ """Delegate-subagent ids (``_delegate_from`` marker, walked recursively) to cascade-delete with
131
+ *parent_ids*; untagged children stay orphaned, not deleted."""
132
+ df = _delegate_from_json()
133
+ seeds = {sid for sid in parent_ids if sid}
134
+ # Seed visited with the parents: a marker chain can loop back onto a parent,
135
+ # which would then be collected as its own descendant. Never return parents.
136
+ # A delegation marker chain can loop back onto a parent — a cycle, or a parent that is also another
137
+ # parent's delegate child when several ids are deleted at once — and without this guard that parent
138
+ # would be collected as one of its own descendants and cascade-deleted along with all of its messages.
139
+ # Callers delete the parents separately, so parents must never appear in the returned child set.
140
+ # (#49148)
141
+ found: set[str] = set(seeds)
142
+ frontier = list(seeds)
143
+ while frontier:
144
+ next_frontier: List[str] = []
145
+ for chunk in _id_chunks(frontier, _SQL_IN_CHUNK // 2): # each id is bound twice below
146
+ ph = _session_ids_placeholders(chunk)
147
+ cursor = conn.execute(
148
+ f"SELECT id FROM sessions WHERE {df} IN ({ph}) "
149
+ f"OR (parent_session_id IN ({ph}) AND {df} IS NOT NULL)", chunk + chunk,
150
+ )
151
+ for row in cursor.fetchall():
152
+ if row["id"] not in found:
153
+ found.add(row["id"])
154
+ next_frontier.append(row["id"])
155
+ frontier = next_frontier
156
+ return [sid for sid in found if sid not in seeds]
157
+
158
+
159
+ def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]:
160
+ ids = _collect_delegate_child_ids(conn, parent_ids)
161
+ for chunk in _id_chunks(ids):
162
+ ph = _session_ids_placeholders(chunk)
163
+ conn.execute(f"DELETE FROM messages WHERE session_id IN ({ph})", chunk)
164
+ # FK safety: orphan any untagged stragglers pointing at a doomed row.
165
+ conn.execute(f"UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id IN ({ph})", chunk)
166
+ conn.execute(f"DELETE FROM sessions WHERE id IN ({ph})", chunk)
167
+ return ids
168
+
169
+
170
+ # Lifecycle statuses surfaced by session pickers; classified from the final
171
+ # message row ONLY so it stays O(1) per session.
172
+ SESSION_STATUS_COMPLETE = "complete"
173
+ SESSION_STATUS_INTERRUPTED = "interrupted"
174
+ SESSION_STATUS_ERROR = "error"
175
+ SESSION_STATUS_EMPTY = "empty"
176
+
177
+ # finish_reason values meaning the turn ended in a provider/agent error.
178
+ _ERROR_FINISH_REASONS = frozenset({"error", "agent_error", "content_filter"})
179
+
180
+
181
+ def classify_session_status(role: Optional[str], has_tool_calls: bool, finish_reason: Optional[str]) -> str:
182
+ """Error finish → ``error``; assistant with pending tool_calls or a trailing user/tool row →
183
+ ``interrupted``; otherwise ``complete`` (benign default: pickers must not alarm on unknown shapes)."""
184
+ if (finish_reason or "").strip().lower() in _ERROR_FINISH_REASONS:
185
+ return SESSION_STATUS_ERROR
186
+ r = (role or "").strip().lower()
187
+ if r in {"user", "tool"} or (r == "assistant" and has_tool_calls):
188
+ return SESSION_STATUS_INTERRUPTED
189
+ return SESSION_STATUS_COMPLETE
190
+
191
+
192
+ # Parent→child profile_name inheritance fence: keyless rows inherit freely; two
193
+ # ``agent:<ns>:...`` keyed rows must agree on the namespace.
194
+ # ``agent:<ns>:...`` gateway keys encode the profile namespace; a keyless row (CLI / subagent lineage)
195
+ # carries none and inherits freely. Two keyed rows must agree on ``agent:<ns>:`` — a default child
196
+ # (``agent:main:``) forked from a sibling profile's row must not be durably mislabelled as that profile's.
197
+ # See #88381.
198
+ _SAME_KEY_NAMESPACE_SQL = (
199
+ "p.session_key IS NULL OR sessions.session_key IS NULL"
200
+ " OR substr(p.session_key, 1, instr(substr(p.session_key, 7), ':') + 6)"
201
+ " = substr(sessions.session_key, 1, instr(substr(sessions.session_key, 7), ':') + 6)"
202
+ )
203
+
204
+
205
+ # Upsert tail of _insert_session_row: every routing/metadata column keeps the value an
206
+ # earlier writer set (whitespace is part of the SQL text).
207
+ _UPSERT_KEEP_EXISTING_SQL = ",\n".join(
208
+ f" {col} = COALESCE(sessions.{col}, excluded.{col})" for col in (
209
+ "session_key", "chat_id", "chat_type", "thread_id", "parent_session_id", "cwd", "profile_name",
210
+ "git_repo_root", "origin_json", "display_name",
211
+ )
212
+ )
213
+
214
+
215
+ def _inherit_col_sql(col: str, extra: str = "") -> str:
216
+ """``col = COALESCE(sessions.col, (SELECT p.col FROM parent))`` (whitespace is part of the SQL text)."""
217
+ pad = " " * (30 + len(col))
218
+ return (
219
+ f"{col} = COALESCE(sessions.{col},\n{pad}(SELECT p.{col} FROM sessions p\n"
220
+ f"{pad} WHERE p.id = sessions.parent_session_id{extra}))"
221
+ )
222
+
223
+
224
+ _INHERIT_SEP = ",\n" + " " * 27
225
+ _INHERIT_PARENT_META_SQL = (
226
+ "UPDATE sessions\n SET "
227
+ + _INHERIT_SEP.join((
228
+ *(_inherit_col_sql(c) for c in ("cwd", "git_repo_root", "git_branch")),
229
+ _inherit_col_sql("profile_name", "\n" + " " * 46 + f"AND ({_SAME_KEY_NAMESPACE_SQL})"),
230
+ ))
231
+ + "\n WHERE id = ? AND parent_session_id IS NOT NULL"
232
+ )
233
+ _INHERIT_PARENT_ROUTING_SQL = (
234
+ "UPDATE sessions\n SET "
235
+ + _INHERIT_SEP.join(_inherit_col_sql(c) for c in (
236
+ "user_id", "session_key", "chat_id", "chat_type", "thread_id", "display_name", "origin_json",
237
+ ))
238
+ + "\n WHERE id = ? AND parent_session_id IS NOT NULL\n"
239
+ " AND EXISTS (\n"
240
+ " SELECT 1 FROM sessions p\n"
241
+ " WHERE p.id = sessions.parent_session_id\n"
242
+ " AND p.end_reason = 'compression'\n"
243
+ " )"
244
+ )
245
+
246
+
247
+ class SessionSessionsMixin:
248
+ """Session rows: create/inherit, lifecycle flags, model_config, listing, deletion."""
249
+
250
+ def _own_profile_name(self) -> Optional[str]:
251
+ """The profile owning THIS store, from ``db_path`` alone (``<root>/state.db`` → default,
252
+ ``<root>/profiles/<name>/state.db`` → name): a gateway serving a NON-launch profile opens that
253
+ profile's store. None outside the profile tree — NULL beats a fabricated owner."""
254
+ try:
255
+ from hermes_constants import get_default_hermes_root
256
+ root = get_default_hermes_root().resolve()
257
+ parent = Path(self.db_path).resolve().parent
258
+ if parent == root:
259
+ return "default"
260
+ is_profile_dir = parent.parent == root / "profiles"
261
+ if is_profile_dir and re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", parent.name):
262
+ return parent.name
263
+ except Exception:
264
+ logger.debug("own-profile derivation failed", exc_info=True)
265
+ return None
266
+
267
+ @staticmethod
268
+ def _inherit_parent_session_metadata(conn, session_id: str) -> None:
269
+ """NULL-fill a child's cwd/git/profile from its parent (profile_name only within the same
270
+ ``agent:<ns>:`` namespace). Gateway routing columns are inherited ONLY by compression forks
271
+ (a crash before the gateway re-records the peer would strand the child unroutable); delegate
272
+ children must NOT inherit them (peer recovery could repoint traffic into a subagent's session)."""
273
+ conn.execute(_INHERIT_PARENT_META_SQL, (session_id,))
274
+ conn.execute(_INHERIT_PARENT_ROUTING_SQL, (session_id,))
275
+
276
+ def _insert_session_row(
277
+ self, session_id: str, source: str, model: str = None, model_config: Dict[str, Any] = None,
278
+ system_prompt: str = None, user_id: str = None, session_key: Optional[str] = None,
279
+ chat_id: str = None, chat_type: str = None, thread_id: str = None,
280
+ parent_session_id: str = None, cwd: str = None, profile_name: Optional[str] = None,
281
+ git_repo_root: str = None, origin_json: str = None, display_name: str = None,
282
+ ) -> None:
283
+ """Upsert a session row, never overwriting what an earlier writer set (the gateway creates a
284
+ bare row before create_session carries the real model/prompt). chat_id/thread_id scope gateway
285
+ /resume (IDOR). Children backfill from the parent; a missing profile_name is stamped with THIS
286
+ store's own (NULL reads as unowned).
287
+
288
+ When ``parent_session_id`` is set (compression fork, delegate/subagent spawn, branch continuation)
289
+ and this row's own ``cwd``/``git_repo_root``/ ``git_branch``/``profile_name`` are still NULL after
290
+ the insert, they are backfilled from the parent row. Callers of ``create_session`` for a child
291
+ session historically didn't propagate these fields themselves (e.g. the compression-fork path), so a
292
+ lineage could silently lose its working directory and drop out of the project sidebar every time it
293
+ forked (#64709), or lose its owning profile and be aggregated as "default" every time it rotated or
294
+ branched (the cross-profile session-jump bug). This only fills NULLs — an explicit value on the
295
+ child is never overwritten. For compression forks specifically (parent ended with
296
+ ``end_reason='compression'``), the gateway origin columns
297
+ (``user_id``/``session_key``/``chat_id``/``chat_type``/
298
+ ``thread_id``/``display_name``/``origin_json``) are inherited too, so a crash before the gateway
299
+ re-records the peer can't strand the child without a recoverable routing mapping (#59527).
300
+ When the caller passes no ``profile_name`` at all, the row is stamped with THIS store's own profile
301
+ (:meth:`_own_profile_name`) instead of NULL. Every ``state.db`` belongs to exactly one profile — the
302
+ same single-match contract :meth:`backfill_null_session_profiles` relies on — so the stamp is
303
+ derivation, not a guess. Rows minted NULL after that one-shot #94724 backfill ran stayed NULL
304
+ forever, and profile-keyed consumers (desktop sidebar scope matching, ``@session:<profile>/<id>``
305
+ deep links, the fail-closed owner ladder) treat NULL as unowned: the session vanishes from the
306
+ sidebar even though its transcript is intact (#99222). Stores outside the profile tree (explicit
307
+ ``db_path`` in tests, ad-hoc copies) derive nothing and keep NULL — never guess.
308
+ """
309
+ if not (profile_name or "").strip():
310
+ profile_name = self._own_profile_name()
311
+ def _do(conn):
312
+ system_prompt_hash = self._store_system_prompt(conn, system_prompt)
313
+ conn.execute(
314
+ """INSERT INTO sessions (
315
+ id, source, user_id, session_key, chat_id, chat_type, thread_id,
316
+ model, model_config, system_prompt, system_prompt_hash,
317
+ parent_session_id, cwd, profile_name, git_repo_root,
318
+ origin_json, display_name, started_at
319
+ )
320
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)
321
+ ON CONFLICT(id) DO UPDATE SET
322
+ model = COALESCE(sessions.model, excluded.model),
323
+ model_config = CASE
324
+ WHEN excluded.model_config IS NOT NULL
325
+ AND json_type(
326
+ sessions.model_config, '$._reset_from'
327
+ ) IS NOT NULL
328
+ AND json_remove(
329
+ sessions.model_config, '$._reset_from'
330
+ ) = '{}'
331
+ THEN json_set(
332
+ excluded.model_config,
333
+ '$._reset_from',
334
+ json_extract(
335
+ sessions.model_config, '$._reset_from'
336
+ )
337
+ )
338
+ ELSE COALESCE(
339
+ sessions.model_config, excluded.model_config
340
+ )
341
+ END,
342
+ system_prompt_hash = COALESCE(
343
+ sessions.system_prompt_hash,
344
+ excluded.system_prompt_hash
345
+ ),
346
+ system_prompt = CASE
347
+ WHEN sessions.system_prompt_hash IS NULL
348
+ AND excluded.system_prompt_hash IS NOT NULL
349
+ THEN NULL
350
+ ELSE sessions.system_prompt
351
+ END,
352
+ """ + _UPSERT_KEEP_EXISTING_SQL,
353
+ (
354
+ session_id, source, user_id, session_key, chat_id, chat_type, thread_id, model,
355
+ json.dumps(model_config) if model_config else None, system_prompt_hash,
356
+ parent_session_id, cwd, profile_name, git_repo_root, origin_json, display_name,
357
+ time.time(),
358
+ ),
359
+ )
360
+ if system_prompt_hash is not None:
361
+ self._delete_unreferenced_system_prompts(conn)
362
+ if parent_session_id:
363
+ self._inherit_parent_session_metadata(conn, session_id)
364
+ # Transcript-critical: a failed row creation aborts the turn.
365
+ self._execute_write(_do, patience_s=self._TRANSCRIPT_WRITE_PATIENCE_S)
366
+
367
+ def create_session(self, session_id: str, source: str, **kwargs) -> str:
368
+ """Create (upsert) a session record. Returns the session_id."""
369
+ self._insert_session_row(session_id, source, **kwargs)
370
+ return session_id
371
+
372
+ def ensure_session(self, session_id: str, source: str = "unknown", model: str = None, **kwargs) -> str:
373
+ """Ensure a session row exists (upsert). Accepts optional kwargs."""
374
+ self._insert_session_row(session_id, source, model=model, **kwargs)
375
+ return session_id
376
+
377
+ def set_expiry_finalized(self, session_id: str, finalized: bool = True) -> None:
378
+ """Mirror ``SessionEntry.expiry_finalized`` so it survives a lost sessions.json.
379
+
380
+ See #9006.
381
+ """
382
+ if not session_id:
383
+ return
384
+ self._write_sql(
385
+ "UPDATE sessions SET expiry_finalized = ? WHERE id = ?", (1 if finalized else 0, session_id),
386
+ )
387
+
388
+ def find_session_by_origin(
389
+ self, *, platform: str, chat_id: str, thread_id: Optional[str] = None,
390
+ user_id: Optional[str] = None,
391
+ ) -> Optional[str]:
392
+ """Most recent live session_id for source + chat_id (+ thread_id). With ``user_id`` exact sender
393
+ matches win; several distinct users and no match → None (never another participant's session)."""
394
+ if not platform or chat_id in (None, ""):
395
+ return None
396
+ query = """
397
+ SELECT id, user_id, started_at FROM sessions
398
+ WHERE LOWER(source) = LOWER(?)
399
+ AND session_key IS NOT NULL
400
+ AND chat_id = ?
401
+ AND ended_at IS NULL
402
+ """
403
+ params: list = [platform, str(chat_id)]
404
+ if thread_id is not None:
405
+ query += " AND COALESCE(thread_id, '') = ?"
406
+ params.append(str(thread_id))
407
+ rows = [dict(r) for r in self._read_all(query + " ORDER BY started_at DESC", params)]
408
+ if not rows:
409
+ return None
410
+ if user_id:
411
+ exact = [r for r in rows if str(r.get("user_id") or "") == str(user_id)]
412
+ if exact:
413
+ return str(exact[0]["id"])
414
+ if len(rows) > 1:
415
+ return None
416
+ elif len({u for u in (str(r.get("user_id") or "").strip() for r in rows) if u}) > 1:
417
+ return None
418
+ return str(rows[0]["id"])
419
+
420
+ # Orphaned gateway-session repair: widest plausible gap between a keyed predecessor going
421
+ # quiet and its unkeyed successor (incident was ~60s; 15 min without spanning conversations).
422
+ _ORPHAN_ADOPTION_MAX_GAP_S = 900.0
423
+
424
+ # Children that are NOT compression continuations (branches, delegates, tool sessions). Markers
425
+ # are bound to the queried parent id: continuations inherit model_config verbatim, so
426
+ # presence-matching misclassified them as delegates.
427
+ _NON_CONTINUATION_CHILD_FILTER_SQL = (
428
+ f" AND COALESCE({_sql_json_extract('{alias}model_config', '$._branched_from')}, '') != ?\n"
429
+ f" AND COALESCE({_sql_json_extract('{alias}model_config', '$._delegate_from')}, '') != ?\n"
430
+ " AND COALESCE({alias}source, '') != 'tool'\n"
431
+ )
432
+
433
+ def end_session(self, session_id: str, end_reason: str) -> None:
434
+ """Mark a session ended; the first end_reason wins (a compression split must keep
435
+ ``'compression'`` even if a stale end_session() lands later); reopen_session() to re-end."""
436
+ self._execute_write(lambda conn: self._end_and_bump(
437
+ conn, "UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND ended_at IS NULL",
438
+ (time.time(), end_reason, session_id), session_id, end_reason,
439
+ ))
440
+
441
+ def _end_and_bump(self, conn, sql: str, params: tuple, session_id: str, reason: str) -> int:
442
+ """Run an end-stamp UPDATE; only a boundary this call actually wrote advances the
443
+ conversation generation (a no-op must not rotate the peer). Returns rowcount."""
444
+ changed = conn.execute(sql, params).rowcount
445
+ if changed:
446
+ self._bump_conversation_generation(conn, session_id, reason)
447
+ return changed
448
+
449
+ def reopen_session(self, session_id: str) -> None:
450
+ """Clear ended_at/end_reason so a session can be resumed; first stamp markerless legacy reset
451
+ children that depend on the parent's mutable end_reason (WHERE shared with the listing predicate
452
+ so they cannot drift)."""
453
+ def _do(conn):
454
+ conn.execute(
455
+ "UPDATE sessions AS child SET model_config = json_set("
456
+ "COALESCE(child.model_config, '{}'), '$._reset_from', child.parent_session_id) "
457
+ f"WHERE child.parent_session_id = ? AND {_sql_json_extract('child.model_config', '$._reset_from')} IS NULL "
458
+ f"AND {_legacy_reset_child_sql('child', _session_ids_placeholders(_RESET_END_REASONS))}",
459
+ (session_id, *_RESET_END_REASONS),
460
+ )
461
+ conn.execute(
462
+ "UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?", (session_id,),
463
+ )
464
+ self._execute_write(_do)
465
+
466
+ def promote_to_session_reset(self, session_id: str, reason: str = "session_reset") -> bool:
467
+ """Durably mark an intentional reset boundary on live rows or rows with a *recoverable* accidental
468
+ end_reason (explicit boundaries are preserved): an ``agent_close`` row left recoverable would be
469
+ resurrected by stale-route recovery. Keep in sync with find_latest_gateway_session_for_peer.
470
+
471
+ Plain ``end_session()`` is NOT sufficient for reset boundaries: it no-ops on an already-ended row,
472
+ so a row that agent cleanup already closed as ``agent_close`` would stay recoverable and stale-route
473
+ recovery would resurrect the reset session with its full history (#61220, #61993, #63539).
474
+ """
475
+ if not session_id:
476
+ return False
477
+ now = time.time()
478
+ # /new and policy auto-resets promote rather than end_session, so the
479
+ # generation advances here too — same transaction, only when written.
480
+ try:
481
+ return bool(self._execute_write(lambda conn: self._end_and_bump(
482
+ conn, "UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND (ended_at IS NULL "
483
+ f"OR end_reason IN ({_RECOVERABLE_END_REASONS_SQL}))",
484
+ (now, reason, session_id), session_id, reason,
485
+ )))
486
+ except Exception:
487
+ return False
488
+
489
+ def update_session_cwd(
490
+ self, session_id: str, cwd: str, git_branch: Optional[str] = None,
491
+ git_repo_root: Optional[str] = None, replace_git_meta: bool = False,
492
+ ) -> Optional[int]:
493
+ """Persist the authoritative cwd and claim a Git metadata generation. git fields are written
494
+ only when non-empty (a probe failure never clobbers a value) except under ``replace_git_meta``
495
+ (a workspace MOVE overwrites the old repo identity). Async probes publish with the returned
496
+ generation so an older worker cannot overwrite a newer claim (A -> B -> A)."""
497
+ if not session_id or not cwd:
498
+ return None
499
+ branch = (git_branch or "").strip()
500
+ repo_root = (git_repo_root or "").strip()
501
+ def _do(conn):
502
+ current = conn.execute("SELECT cwd FROM sessions WHERE id = ?", (session_id,)).fetchone()
503
+ if current is None:
504
+ return None
505
+ sets = ["cwd = ?", "git_metadata_generation = COALESCE(git_metadata_generation, 0) + 1"]
506
+ params: List[Any] = [cwd]
507
+ if current[0] != cwd or replace_git_meta:
508
+ sets.extend(("git_branch = ?", "git_repo_root = ?"))
509
+ params.extend((branch or None, repo_root or None))
510
+ else: # same cwd: only overwrite with captured (non-empty) values
511
+ for col, val in (("git_branch", branch), ("git_repo_root", repo_root)):
512
+ if val:
513
+ sets.append(f"{col} = ?")
514
+ params.append(val)
515
+ conn.execute(f"UPDATE sessions SET {', '.join(sets)} WHERE id = ?", [*params, session_id])
516
+ row = conn.execute(
517
+ "SELECT git_metadata_generation FROM sessions WHERE id = ?", (session_id,),
518
+ ).fetchone()
519
+ return None if row is None else int(row[0])
520
+ return self._execute_write(_do)
521
+
522
+ def publish_session_git_metadata(
523
+ self, session_id: str, cwd: str, generation: int, git_branch: Optional[str] = None,
524
+ git_repo_root: Optional[str] = None,
525
+ ) -> bool:
526
+ """Publish async Git enrichment only while its cwd claim is current."""
527
+ valid_generation = isinstance(generation, int) and not isinstance(generation, bool) and generation >= 1
528
+ if not session_id or not cwd or not valid_generation:
529
+ return False
530
+ fields = [
531
+ (col, val) for col, val in (
532
+ ("git_branch", (git_branch or "").strip()), ("git_repo_root", (git_repo_root or "").strip()),
533
+ ) if val
534
+ ]
535
+ if not fields:
536
+ return False
537
+ return self._write_rowcount(
538
+ f"UPDATE sessions SET {', '.join(f'{col} = ?' for col, _ in fields)} "
539
+ "WHERE id = ? AND cwd = ? AND git_metadata_generation = ?",
540
+ [val for _, val in fields] + [session_id, cwd, generation],
541
+ ) == 1
542
+
543
+ def backfill_repo_roots(self, cwd_to_root: Dict[str, str]) -> None:
544
+ """Backfill git repo roots for cwds without one; never clobbers a recorded root."""
545
+ pairs = [(root, cwd) for cwd, root in cwd_to_root.items() if root and cwd]
546
+ if pairs:
547
+ self._write_sql(
548
+ "UPDATE sessions SET git_repo_root = ? WHERE cwd = ? AND COALESCE(git_repo_root, '') = ''",
549
+ pairs, many=True,
550
+ )
551
+
552
+ def touch_session_activity(
553
+ self, session_id: str, ts: Optional[float] = None, *, description: Optional[str] = None,
554
+ provenance: Optional[ActivityProvenance] = None,
555
+ ) -> None:
556
+ """Stamp durable mid-turn activity (observation-only; rate-limited by the caller) so surfaces see
557
+ activity before any message row lands. Never moves ``last_activity_at`` backwards.
558
+
559
+ Called (rate-limited) from ``AIAgent._touch_activity`` so gateway/CLI surfaces and stall consumers
560
+ observe API/tool/compaction activity even when no new message row has been written yet (#72016 /
561
+ #72039).
562
+ """
563
+ if not session_id:
564
+ return
565
+ when = float(ts if ts is not None else time.time())
566
+ self._write_sql(
567
+ "UPDATE sessions SET last_activity_at = ?, "
568
+ "last_activity_description = ?, last_activity_provenance = ? "
569
+ "WHERE id = ? AND (last_activity_at IS NULL OR last_activity_at < ?)",
570
+ (
571
+ when, bound_activity_description(description),
572
+ normalize_activity_provenance(provenance).value, session_id, when,
573
+ ),
574
+ patience_s=self._ACTIVITY_WRITE_PATIENCE_S,
575
+ )
576
+
577
+ # Observation-only write: never let it ride the full routine write-patience budget (#76354 review S1).
578
+ # Under contention a heartbeat that waits ~20s would delay the response-critical path it is merely
579
+ # observing; give up after a sub-second budget instead (the next due window retries naturally).
580
+ def clear_session_activity_labels(self, session_id: str) -> None:
581
+ """Clear activity labels after a turn (``last_activity_at`` is kept so idle / watchdog clocks stay
582
+ continuous). A no-op clear skips the write transaction.
583
+
584
+ Description and provenance are observation labels for *what was happening at* that timestamp during
585
+ an active turn; once the turn is idle they must not keep advertising "compressing" / "executing
586
+ tool" (#72039).
587
+ Response-critical-path contract (#76354 review S1): runs in the turn's ``finally``; a no-op clear
588
+ (labels already empty) skips the write transaction entirely, and a real clear uses the same short
589
+ sub-second busy budget as :meth:`touch_session_activity` instead of the full routine write patience.
590
+ """
591
+ if not session_id:
592
+ return
593
+ try:
594
+ row = self._read_one(
595
+ "SELECT last_activity_description, last_activity_provenance FROM sessions WHERE id = ?",
596
+ (session_id,),
597
+ )
598
+ except sqlite3.Error:
599
+ row = None
600
+ if row is not None and not row[0] and (not row[1] or row[1] == ActivityProvenance.UNKNOWN.value):
601
+ return
602
+ self._write_sql(
603
+ "UPDATE sessions SET last_activity_description = ?, last_activity_provenance = ? WHERE id = ?",
604
+ ("", ActivityProvenance.UNKNOWN.value, session_id), patience_s=self._ACTIVITY_WRITE_PATIENCE_S,
605
+ )
606
+
607
+ def update_session_meta(
608
+ self, session_id: str, model_config_json: str, model: Optional[str] = None,
609
+ ) -> None:
610
+ """Update model_config and (COALESCE) optionally model."""
611
+ self.flush_token_counts() # barrier against queued token deltas — see update_session_model
612
+ self._write_sql(
613
+ "UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
614
+ (model_config_json, model, session_id),
615
+ )
616
+
617
+ def update_system_prompt(self, session_id: str, system_prompt: Optional[str]) -> None:
618
+ """Store the full assembled system prompt snapshot."""
619
+ def _do(conn):
620
+ conn.execute(
621
+ "UPDATE sessions SET system_prompt_hash = ?, system_prompt = NULL WHERE id = ?",
622
+ (self._store_system_prompt(conn, system_prompt), session_id),
623
+ )
624
+ self._delete_unreferenced_system_prompts(conn)
625
+ self._execute_write(_do)
626
+
627
+ def update_session_tool_names(self, session_id: str, tool_names: Optional[List[str]]) -> None:
628
+ """Persist the resolved ``tools[]`` name order so a rebuilt AIAgent can't fork the cached tool
629
+ prefix on a flipped check_fn verdict; ``None`` clears."""
630
+ payload = json.dumps(list(tool_names)) if tool_names is not None else None
631
+ self._write_sql("UPDATE sessions SET tool_names = ? WHERE id = ?", (payload, session_id))
632
+
633
+ def update_session_model(self, session_id: str, model: str, provider: Optional[str] = None) -> None:
634
+ """Set the model after a mid-session /model switch (unconditionally), null system_prompt so
635
+ stale Model:/Provider: footers rebuild, and drop any Browser runtime lock (lineage markers
636
+ survive). *provider* is merged into model_config so resume recombines model and provider.
637
+
638
+ When *provider* is given, it is merged into ``model_config`` alongside the model (``$.model`` /
639
+ ``$.provider``) so a later resume recombines the persisted model with the provider that actually
640
+ serves it instead of the config.yaml primary provider (#79536). Callers without provider knowledge
641
+ leave any stored provider untouched.
642
+ """
643
+ # Flush first: a still-queued pre-switch delta applied after this UPDATE would trip the
644
+ # first_accounted_route overwrite and resurrect the old route.
645
+ self.flush_token_counts()
646
+ patch: Dict[str, Any] = {"browser_model_lock": None}
647
+ if model:
648
+ patch["model"] = model
649
+ if provider:
650
+ patch["provider"] = provider
651
+ self._write_model_config_patch(
652
+ session_id, patch, "UPDATE sessions SET model = ?, model_config = ?, "
653
+ "system_prompt = NULL, system_prompt_hash = NULL WHERE id = ?",
654
+ lambda merged: (model, merged, session_id),
655
+ )
656
+
657
+ def _write_model_config_patch(
658
+ self, session_id: str, patch: Dict[str, Any],
659
+ sql: str = "UPDATE sessions SET model_config = ? WHERE id = ?",
660
+ params: Optional[Callable[[Optional[str]], tuple]] = None,
661
+ ) -> None:
662
+ """Merge ``patch`` into model_config then run ``sql`` with ``params(merged)`` in one write
663
+ transaction; no-op when the row doesn't exist. Custom ``sql`` (prompt-nulling) also GCs prompts."""
664
+ def _do(conn):
665
+ merged = self._merge_model_config_json(conn, session_id, patch)
666
+ if merged is _MODEL_CONFIG_ROW_MISSING:
667
+ return
668
+ conn.execute(sql, params(merged) if params else (merged, session_id))
669
+ if params is not None:
670
+ self._delete_unreferenced_system_prompts(conn)
671
+ self._execute_write(_do)
672
+
673
+ def _merge_model_config_json(
674
+ self, conn, session_id: str, patch: Dict[str, Any], *, on_missing: str = "skip",
675
+ ):
676
+ """SELECT + tolerant-parse + merge ``patch`` into model_config (the one place that keeps
677
+ ``_branched_from``/``_delegate_from`` alive); ``None`` deletes a key. Returns serialized JSON
678
+ (``None`` when empty) or ``_MODEL_CONFIG_ROW_MISSING`` (``on_missing="raise"`` → ValueError)."""
679
+ row = conn.execute("SELECT model_config FROM sessions WHERE id = ?", (session_id,)).fetchone()
680
+ if row is None:
681
+ if on_missing == "raise":
682
+ raise ValueError(f"Session not found: {session_id}")
683
+ return _MODEL_CONFIG_ROW_MISSING
684
+ config = _parse_model_config(row[0])
685
+ for key, value in patch.items():
686
+ if value is None:
687
+ config.pop(key, None)
688
+ else:
689
+ config[key] = value
690
+ return json.dumps(config) if config else None
691
+
692
+ def patch_session_model_config(self, session_id: str, patch: Dict[str, Any]) -> None:
693
+ """Merge ``patch`` into model_config atomically (``None`` removes a key);
694
+ no-op when the row or patch is empty."""
695
+ if not session_id or not patch:
696
+ return
697
+ self._write_model_config_patch(session_id, patch)
698
+
699
+ def get_session_model_config_value(self, session_id: str, key: str, default: Any = None) -> Any:
700
+ """Read one key out of a session's model_config JSON (tolerant parse)."""
701
+ session = self.get_session(session_id) or {}
702
+ return _parse_model_config(session.get("model_config")).get(key, default)
703
+
704
+ def update_session_runtime_lock(
705
+ self, session_id: str, *, model: Optional[str] = None, provider: Optional[str] = None,
706
+ model_options: Optional[Dict[str, Any]] = None, route_source: Optional[str] = None,
707
+ confirmed: bool = False,
708
+ ) -> None:
709
+ """Persist a Browser / API-client runtime lock into model_config (lineage markers survive); null
710
+ system_prompt so cached footers cannot lie."""
711
+ lock = {
712
+ "provider": provider or "", "model": model or "", "model_options": model_options or {},
713
+ "route_source": route_source or "", "confirmed": bool(confirmed), "updated_at": time.time(),
714
+ }
715
+ self._write_model_config_patch(
716
+ session_id, {"browser_model_lock": lock},
717
+ """UPDATE sessions SET
718
+ model_config = ?,
719
+ model = COALESCE(?, model),
720
+ system_prompt = NULL,
721
+ system_prompt_hash = NULL
722
+ WHERE id = ?""",
723
+ lambda merged: (merged, model, session_id),
724
+ )
725
+
726
+ def set_session_yolo(self, session_id: str, enabled: bool) -> None:
727
+ """Persist the per-session YOLO flag so ``/yolo`` survives ``--resume``; no-op without a row."""
728
+ if not session_id:
729
+ return
730
+ self._write_model_config_patch(session_id, {"yolo_mode": bool(enabled)})
731
+
732
+ @staticmethod
733
+ def session_yolo_enabled(session_meta: Optional[Dict[str, Any]]) -> bool:
734
+ """Persisted YOLO flag; False on any parse failure (resume must never enable the bypass)."""
735
+ return bool(_parse_model_config((session_meta or {}).get("model_config")).get("yolo_mode"))
736
+
737
+ def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
738
+ """Get a session by ID (drains queued token deltas first so cost readers see exact totals)."""
739
+ self.flush_token_counts()
740
+ row = self._read_one(
741
+ "SELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved "
742
+ "FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash WHERE s.id = ?",
743
+ (session_id,),
744
+ )
745
+ return self._session_row_dict(row) if row else None
746
+
747
+ def get_recent_session_model_route(self, session_id: str) -> Optional[Dict[str, Any]]:
748
+ """Most recently used main-loop model route as one coherent per-call tuple
749
+ (``session_model_usage`` keeps model+provider together; ``sessions`` mixes route changes).
750
+ Recency, not lifetime call count: on a long session a route retired weeks ago can hold the
751
+ highest ``api_call_count`` forever, and /status and /usage would keep calling it current.
752
+ ``rowid DESC`` breaks same-timestamp ties toward the route that first appeared later; without
753
+ it SQLite's temp-sort order is unspecified and the retired route can win."""
754
+ self.flush_token_counts()
755
+ row = self._read_one(
756
+ """SELECT model, billing_provider, billing_base_url, billing_mode,
757
+ api_call_count
758
+ FROM session_model_usage
759
+ WHERE session_id = ?
760
+ AND task = ''
761
+ AND model <> 'unknown'
762
+ AND billing_provider <> ''
763
+ ORDER BY last_seen DESC, rowid DESC
764
+ LIMIT 1""",
765
+ (session_id,),
766
+ )
767
+ return dict(row) if row else None
768
+
769
+ def resolve_session_id(self, session_id_or_prefix: str) -> Optional[str]:
770
+ """Exact id, else the single unambiguous prefix match, else None."""
771
+ exact = self.get_session(session_id_or_prefix)
772
+ if exact:
773
+ return exact["id"]
774
+ matches = self._read_all(
775
+ "SELECT id FROM sessions WHERE id LIKE ? ESCAPE '\\' ORDER BY started_at DESC LIMIT 2",
776
+ (f"{_escape_like(session_id_or_prefix)}%",),
777
+ )
778
+ return matches[0]["id"] if len(matches) == 1 else None
779
+
780
+ def backfill_null_session_profiles(self, profile_name: str) -> int:
781
+ """Stamp this store's own profile onto legacy ``profile_name IS NULL`` rows, which the fail-closed
782
+ owner ladder cannot route. Never overwrites a non-NULL owner. Returns rows stamped.
783
+
784
+ Sessions created before the durable-ownership work (#95407 lineage) carry ``profile_name = NULL``.
785
+ On single-backend installs that was harmless, but once a Desktop registers a second connection the
786
+ fail-closed owner ladder (which is correct for new sessions) can no longer route those rows anywhere
787
+ — every pre-campaign session becomes unresumable after upgrade (#94724, field report).
788
+ """
789
+ stamp = (profile_name or "").strip()
790
+ if not stamp:
791
+ return 0
792
+ return int(self._write_rowcount(
793
+ """UPDATE sessions
794
+ SET profile_name = ?
795
+ WHERE profile_name IS NULL OR TRIM(profile_name) = ''""",
796
+ (stamp,),
797
+ ) or 0)
798
+
799
+ def _set_lineage_column(self, column: str, session_id: str, value: Any) -> bool:
800
+ """Set one ``sessions`` column across a whole compression lineage: Desktop projects roots
801
+ forward to their tip, so updating only the tip would let the root resurrect it on refresh."""
802
+ return self._write_rowcount(
803
+ f"""
804
+ WITH RECURSIVE
805
+ ancestors(id) AS (
806
+ SELECT ?
807
+ UNION
808
+ SELECT parent.id
809
+ FROM ancestors a
810
+ JOIN sessions child ON child.id = a.id
811
+ JOIN sessions parent ON parent.id = child.parent_session_id
812
+ WHERE parent.end_reason = 'compression'
813
+ ),
814
+ descendants(id) AS (
815
+ SELECT ?
816
+ UNION
817
+ SELECT child.id
818
+ FROM descendants d
819
+ JOIN sessions parent ON parent.id = d.id
820
+ JOIN sessions child ON child.parent_session_id = parent.id
821
+ WHERE parent.end_reason = 'compression'
822
+ ),
823
+ lineage(id) AS (
824
+ SELECT id FROM ancestors
825
+ UNION
826
+ SELECT id FROM descendants
827
+ )
828
+ UPDATE sessions
829
+ SET {column} = ?
830
+ WHERE id IN (SELECT id FROM lineage)
831
+ """,
832
+ (session_id, session_id, value),
833
+ ) > 0
834
+
835
+ def set_session_archived(self, session_id: str, archived: bool) -> bool:
836
+ """Soft-hide (or unhide) a session and its compression lineage; messages are kept."""
837
+ return self._set_lineage_column("archived", session_id, int(archived))
838
+
839
+ # Accidental end reasons recovery treats as resumable (also interpolated into
840
+ # the recovery/promotion SQL so literals cannot drift).
841
+ RECOVERABLE_END_REASONS = _RECOVERABLE_END_REASONS
842
+
843
+ def unarchive_recoverable_session(self, session_id: str) -> bool:
844
+ """Un-archive a session archived by a recoverable accident (ws_orphan_reap, agent_close);
845
+ deliberate archives are left alone. True when un-archived.
846
+
847
+ Registry-style lookups (Bot Mode's canonical "Bot Chat") use this to resurrect a row the ws-orphan
848
+ reaper (``ws_orphan_reap``) or older agent cleanup (``agent_close``) archived: those ends are
849
+ accidents, not user intent, so the identity-scoped canonical chat must survive them (#92687).
850
+ Sessions archived with no end_reason or an explicit boundary reason (user archived deliberately,
851
+ ``session_reset``, …) are left untouched — returns ``False`` for those, ``True`` only when the row
852
+ was archived for a recoverable reason and is now un-archived (whole compression lineage, via
853
+ :meth:`set_session_archived`).
854
+ """
855
+ if not session_id:
856
+ return False
857
+ try:
858
+ row = self.get_session(session_id)
859
+ except Exception:
860
+ return False
861
+ if not row or not row.get("archived"):
862
+ return False
863
+ # The accidental stamp lives on the live TIP; judge recoverability there.
864
+ tip = row
865
+ try:
866
+ tip_id = self.get_compression_tip(session_id) or session_id
867
+ if tip_id != session_id:
868
+ tip = self.get_session(tip_id) or row
869
+ except Exception:
870
+ pass
871
+ if (tip.get("end_reason") or "") not in self.RECOVERABLE_END_REASONS:
872
+ return False
873
+ if not self.set_session_archived(session_id, False):
874
+ return False
875
+ # Clear the accidental end stamp, or a LATER deliberate archive (which never
876
+ # writes end_reason) would auto-resurrect on the next lookup.
877
+ self._write_sql(
878
+ "UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?", (tip["id"],),
879
+ )
880
+ return True
881
+
882
+ def set_session_pinned(self, session_id: str, pinned: bool) -> bool:
883
+ """Pin/unpin a session and its compression lineage (pins are exempt from the auto_archive sweep).
884
+ Pinning also clears ``hidden``: a pin means "keep this visible", and a hidden+pinned row is
885
+ otherwise absent from both the default listing and the pinned back-fill (see #106171).
886
+ Exempt the canonical Bot Chat (hidden + exact registry title): the desktop contract keeps it
887
+ hidden and reachable only through the bot row, and unhiding it would also disable the
888
+ rename guard in ``_set_session_title`` that protects its identity (see review on #106180)."""
889
+ result = self._set_lineage_column("pinned", session_id, int(pinned))
890
+ if pinned:
891
+ row = self.get_session(session_id)
892
+ is_canonical_bot_chat = bool(row) and bool(row.get("hidden")) and (
893
+ (row.get("title") or "") == self.CANONICAL_BOT_CHAT_TITLE
894
+ )
895
+ if not is_canonical_bot_chat:
896
+ self._set_lineage_column("hidden", session_id, 0)
897
+ return result
898
+
899
+ def set_session_hidden(self, session_id: str, hidden: bool) -> bool:
900
+ """Hide/unhide a session and its compression lineage from the default listing; still resumable."""
901
+ return self._set_lineage_column("hidden", session_id, int(hidden))
902
+
903
+ def set_session_read(self, session_id: str, read: bool = True) -> bool:
904
+ """Mark read/unread across the compression lineage. ``last_read_at`` is a watermark: unread when
905
+ activity postdates it (no write on the message path). NULL = never tracked = read; 0 = unread."""
906
+ return self._set_lineage_column("last_read_at", session_id, time.time() if read else 0.0)
907
+
908
+ @staticmethod
909
+ def session_unread(session_row: Dict[str, Any]) -> bool:
910
+ """Unread = activity postdates the ``last_read_at`` watermark (NULL = read)."""
911
+ last_read = session_row.get("last_read_at")
912
+ if last_read is None:
913
+ return False
914
+ last_active = session_row.get("last_active") or session_row.get("started_at")
915
+ return float(last_active or 0) > float(last_read)
916
+
917
+ # compact_rows excludes only payload-heavy blobs no list consumer renders.
918
+ _SESSION_COMPACT_EXCLUDED = frozenset(
919
+ {"system_prompt", "system_prompt_hash", "git_metadata_generation"}
920
+ )
921
+ _session_compact_cols_sql: Optional[str] = None
922
+
923
+ @staticmethod
924
+ def _chain_search_where(where_sql: str, id_needle: str, search_needle: str) -> Tuple[str, List[Any]]:
925
+ """Extend ``where_sql`` with the id_query / search_query filters: a row is admitted when its own
926
+ id or any id in its forward compression chain matches (search also matches titles and a
927
+ punctuation-stripped form so ``an94`` finds ``AN-94``); chain membership bounds the LIKE."""
928
+ params: List[Any] = []
929
+ clauses: List[str] = []
930
+ def like(needle: str) -> str:
931
+ return f"%{_escape_like(needle)}%"
932
+ if id_needle:
933
+ clauses.append(
934
+ "EXISTS (SELECT 1 FROM chain cq WHERE cq.root_id = s.id"
935
+ " AND LOWER(cq.cur_id) LIKE ? ESCAPE '\\')"
936
+ )
937
+ params.append(like(id_needle))
938
+ if search_needle:
939
+ compact_needle = re.sub(r"[\W_]+", "", search_needle)
940
+ search_clause = (
941
+ "EXISTS (SELECT 1 FROM chain cq JOIN sessions cs ON cs.id = cq.cur_id"
942
+ " WHERE cq.root_id = s.id AND (LOWER(COALESCE(cs.title, '')) LIKE ? ESCAPE '\\'"
943
+ " OR LOWER(cq.cur_id) LIKE ? ESCAPE '\\'"
944
+ )
945
+ params.extend([like(search_needle)] * 2)
946
+ if compact_needle:
947
+ search_clause += (
948
+ " OR REPLACE(REPLACE(REPLACE(REPLACE(LOWER(COALESCE(cs.title, '')),"
949
+ " '-', ''), '_', ''), '.', ''), ' ', '') LIKE ? ESCAPE '\\'"
950
+ )
951
+ params.append(like(compact_needle))
952
+ clauses.append(search_clause + "))")
953
+ if not clauses:
954
+ return where_sql, params
955
+ combined = " AND ".join(clauses)
956
+ return (f"{where_sql} AND {combined}" if where_sql else f"WHERE {combined}"), params
957
+
958
+ def _project_compression_tips(self, sessions: List[Dict[str, Any]], compact_rows: bool) -> List[Dict[str, Any]]:
959
+ """Replace each compression root's surfaced fields with its live tip's (root ``started_at`` kept
960
+ for stable ordering), one batched query. ``_lineage_ids`` carries every chain id (a tile may
961
+ hold a MIDDLE segment's id)."""
962
+ chain_by_root: Dict[str, List[str]] = {} # only roots whose tip differs from themselves
963
+ for s in sessions:
964
+ if s.get("end_reason") == "compression":
965
+ chain = self.get_compression_chain(s["id"])
966
+ if chain and chain[-1] != s["id"]:
967
+ chain_by_root[s["id"]] = chain
968
+ tip_rows = (
969
+ self._get_session_rich_rows_batch(
970
+ {chain[-1] for chain in chain_by_root.values()}, compact_rows=compact_rows,
971
+ ) if chain_by_root else {}
972
+ )
973
+ projected = []
974
+ for s in sessions:
975
+ chain = chain_by_root.get(s["id"])
976
+ tip_row = tip_rows.get(chain[-1]) if chain else None
977
+ if not tip_row:
978
+ projected.append(s)
979
+ continue
980
+ merged = dict(s)
981
+ for key in (
982
+ "id", "ended_at", "end_reason", "message_count", "tool_call_count", "title", "last_active",
983
+ "preview", "model", "system_prompt", "cwd", "git_branch", "git_repo_root",
984
+ ):
985
+ if key in tip_row:
986
+ merged[key] = tip_row[key]
987
+ if merged.get("title") is None:
988
+ # The title is carried root->tip AFTER the publish transaction; a rotation cut off in
989
+ # between leaves it on the ended root, and exact-title lookups (`hermes peer dm` ->
990
+ # canonical "Bot Chat") must still see the lineage under its name (#106165).
991
+ merged["title"] = s.get("title")
992
+ merged["_lineage_root_id"] = s["id"]
993
+ merged["_lineage_ids"] = chain
994
+ projected.append(merged)
995
+ return projected
996
+
997
+ def list_recent_sessions_bounded(
998
+ self,
999
+ *,
1000
+ limit: int = 20,
1001
+ exclude_sources: List[str] = None,
1002
+ timeout_seconds: float = 3.0,
1003
+ candidate_limit: int = None,
1004
+ lineage_limit: int = None,
1005
+ ) -> List[Dict[str, Any]]:
1006
+ """Latency-bounded recent-conversation browse (``session_search()``): preselect a small candidate set
1007
+ from the indexed durable activity timestamp (fallback ``started_at``), resolve only those across
1008
+ compression ancestry/chains, then hydrate activity/previews for that bounded set. Lineage traversal
1009
+ uses ``UNION`` plus a total-row ceiling so a corrupt cycle or a deep/branching lineage cannot defeat
1010
+ the bound; a lineage that hits the ceiling before a terminal root/tip is omitted, not expanded.
1011
+ A cooperative SQLite progress deadline interrupts sustained work past ``timeout_seconds`` and raises
1012
+ ``TimeoutError`` (cheap statements may finish between callbacks). Supports only the agent-tool
1013
+ browse filters; rich callers keep using :meth:`list_sessions_rich`."""
1014
+ limit = max(1, int(limit))
1015
+ timeout_seconds = max(0.0, float(timeout_seconds))
1016
+ if candidate_limit is None:
1017
+ candidate_limit = max(128, limit * 8)
1018
+ candidate_limit = max(limit, min(int(candidate_limit), 2048))
1019
+ if lineage_limit is None:
1020
+ lineage_limit = min(8192, candidate_limit * 8)
1021
+ lineage_limit = max(candidate_limit, min(int(lineage_limit), 8192))
1022
+
1023
+ candidate_clauses = [
1024
+ "s.archived = 0",
1025
+ "s.hidden = 0",
1026
+ f"{_delegate_from_json('s.model_config')} IS NULL",
1027
+ ]
1028
+ candidate_params: List[Any] = []
1029
+ if exclude_sources:
1030
+ placeholders = ",".join("?" for _ in exclude_sources)
1031
+ candidate_clauses.append(f"s.source NOT IN ({placeholders})")
1032
+ candidate_params.extend(exclude_sources)
1033
+ candidate_where = " AND ".join(candidate_clauses)
1034
+
1035
+ # A compression continuation is an implementation edge, unlike /new
1036
+ # reset and /branch children which are independent user-visible
1037
+ # conversations. The same predicate is used in both directions so a
1038
+ # candidate tip maps to its logical root and the root maps back to the
1039
+ # freshest live tip.
1040
+ compression_parent_edge = f"""
1041
+ parent.end_reason = 'compression'
1042
+ AND child.parent_session_id = parent.id
1043
+ AND json_extract(
1044
+ COALESCE(child.model_config, '{{}}'), '$._branched_from'
1045
+ ) IS NULL
1046
+ AND {_delegate_from_json('child.model_config')} IS NULL
1047
+ AND COALESCE(child.source, '') != 'tool'
1048
+ """
1049
+
1050
+ query = f"""
1051
+ WITH RECURSIVE
1052
+ recent_candidates(id) AS (
1053
+ SELECT s.id
1054
+ FROM sessions s
1055
+ WHERE {candidate_where}
1056
+ ORDER BY COALESCE(s.last_activity_at, s.started_at) DESC,
1057
+ s.started_at DESC, s.id DESC
1058
+ LIMIT ?
1059
+ ),
1060
+ ancestors(candidate_id, cur_id) AS (
1061
+ SELECT id, id FROM recent_candidates
1062
+ UNION
1063
+ SELECT a.candidate_id, parent.id
1064
+ FROM ancestors a
1065
+ JOIN sessions child ON child.id = a.cur_id
1066
+ JOIN sessions parent ON {compression_parent_edge}
1067
+ LIMIT ?
1068
+ ),
1069
+ candidate_roots(root_id) AS (
1070
+ SELECT DISTINCT a.cur_id
1071
+ FROM ancestors a
1072
+ JOIN sessions child ON child.id = a.cur_id
1073
+ WHERE NOT EXISTS (
1074
+ SELECT 1
1075
+ FROM sessions parent
1076
+ WHERE {compression_parent_edge}
1077
+ )
1078
+ ),
1079
+ chain(root_id, cur_id) AS (
1080
+ SELECT root_id, root_id FROM candidate_roots
1081
+ UNION
1082
+ SELECT c.root_id, child.id
1083
+ FROM chain c
1084
+ JOIN sessions parent ON parent.id = c.cur_id
1085
+ JOIN sessions child ON {compression_parent_edge}
1086
+ LIMIT ?
1087
+ ),
1088
+ chain_rows AS (
1089
+ SELECT
1090
+ c.root_id,
1091
+ c.cur_id,
1092
+ {_sql_session_last_active_by_id('c.cur_id')} AS activity,
1093
+ CASE WHEN EXISTS (
1094
+ SELECT 1
1095
+ FROM sessions parent
1096
+ JOIN sessions child ON {compression_parent_edge}
1097
+ WHERE parent.id = c.cur_id
1098
+ ) THEN 0 ELSE 1 END AS is_tip
1099
+ FROM chain c
1100
+ ),
1101
+ ranked_tips AS (
1102
+ SELECT root_id, cur_id, activity,
1103
+ ROW_NUMBER() OVER (
1104
+ PARTITION BY root_id
1105
+ ORDER BY activity DESC, cur_id DESC
1106
+ ) AS rank_in_root
1107
+ FROM chain_rows
1108
+ WHERE is_tip = 1
1109
+ )
1110
+ SELECT
1111
+ tip.id,
1112
+ tip.source,
1113
+ tip.model,
1114
+ COALESCE(tip.title, s.title) AS title,
1115
+ s.started_at AS started_at,
1116
+ tip.ended_at,
1117
+ tip.end_reason,
1118
+ tip.message_count,
1119
+ tip.tool_call_count,
1120
+ rt.activity AS last_active,
1121
+ COALESCE(
1122
+ (SELECT {_PREVIEW_RAW_SELECT}
1123
+ FROM messages m
1124
+ WHERE m.session_id = tip.id
1125
+ AND m.role = 'user'
1126
+ AND m.content IS NOT NULL
1127
+ AND {_PREVIEW_ELIGIBLE_SQL}
1128
+ ORDER BY m.timestamp, m.id LIMIT 1),
1129
+ ''
1130
+ ) AS _preview_raw,
1131
+ CASE WHEN s.id != tip.id THEN s.id ELSE NULL END
1132
+ AS _lineage_root_id
1133
+ FROM ranked_tips rt
1134
+ JOIN sessions s ON s.id = rt.root_id
1135
+ JOIN sessions tip ON tip.id = rt.cur_id
1136
+ WHERE rt.rank_in_root = 1
1137
+ AND s.archived = 0
1138
+ AND s.hidden = 0
1139
+ AND {_LISTABLE_CHILD_SQL}
1140
+ AND {_delegate_from_json('s.model_config')} IS NULL
1141
+ ORDER BY rt.activity DESC, s.started_at DESC, tip.id DESC
1142
+ LIMIT ?
1143
+ """
1144
+ params = candidate_params + [
1145
+ candidate_limit,
1146
+ lineage_limit,
1147
+ lineage_limit,
1148
+ limit,
1149
+ ]
1150
+ deadline = time.monotonic() + timeout_seconds
1151
+ interrupted_by_deadline = False
1152
+
1153
+ def _deadline_progress_handler() -> int:
1154
+ nonlocal interrupted_by_deadline
1155
+ if time.monotonic() >= deadline:
1156
+ interrupted_by_deadline = True
1157
+ return 1
1158
+ return 0
1159
+
1160
+ try:
1161
+ with self._read_ctx() as conn:
1162
+ conn.set_progress_handler(_deadline_progress_handler, 1000)
1163
+ try:
1164
+ rows = conn.execute(query, params).fetchall()
1165
+ finally:
1166
+ conn.set_progress_handler(None, 0)
1167
+ except sqlite3.OperationalError as exc:
1168
+ if interrupted_by_deadline and "interrupt" in str(exc).lower():
1169
+ raise TimeoutError(
1170
+ f"recent-session browse exceeded {timeout_seconds:g}s deadline"
1171
+ ) from exc
1172
+ raise
1173
+
1174
+ sessions = []
1175
+ for row in rows:
1176
+ session = self._session_row_dict(row)
1177
+ session["preview"] = _shape_preview(session.pop("_preview_raw", ""))
1178
+ session["unread"] = self.session_unread(session)
1179
+ sessions.append(session)
1180
+ return sessions
1181
+
1182
+ @classmethod
1183
+ def _list_row(cls, row: sqlite3.Row) -> Dict[str, Any]:
1184
+ """Project a list_sessions_rich row: shape the preview, drop internal ordering columns."""
1185
+ s = cls._session_row_dict(row)
1186
+ s["preview"] = _shape_preview(s.pop("_preview_raw", ""))
1187
+ s.pop("_effective_last_active", None)
1188
+ return s
1189
+
1190
+ def list_sessions_rich(
1191
+ self, source: str = None, sources: List[str] = None, exclude_sources: List[str] = None,
1192
+ cwd_prefix: str = None, limit: int = 20, offset: int = 0, include_children: bool = False,
1193
+ min_message_count: int = 0, project_compression_tips: bool = True,
1194
+ order_by_last_active: bool = False, include_archived: bool = False, archived_only: bool = False,
1195
+ id_query: str = None, search_query: str = None, compact_rows: bool = False,
1196
+ include_pinned: bool = False, session_key: str = None, include_hidden: bool = False,
1197
+ ) -> List[Dict[str, Any]]:
1198
+ """List sessions with preview and ``last_active`` in one query. ``order_by_last_active`` sorts
1199
+ by the chain TIP via a recursive CTE (the only path honouring ``id_query`` / ``search_query``);
1200
+ ``include_pinned`` back-fills pins the page missed, still obeying the other filters."""
1201
+ self.flush_token_counts() # rows carry token/cost totals
1202
+ where_clauses, params = _session_filter_where(
1203
+ exclude_children=not include_children, source=source, sources=sources, session_key=session_key,
1204
+ exclude_sources=exclude_sources, cwd_prefix=cwd_prefix, min_message_count=min_message_count,
1205
+ archived_only=archived_only, include_archived=include_archived,
1206
+ )
1207
+ if not include_hidden:
1208
+ where_clauses.append("s.hidden = 0")
1209
+ where_sql = _where_sql(where_clauses)
1210
+ base_where_params = list(params) # pinned back-fill reuses the WHERE before LIMIT/OFFSET
1211
+ # Shared projection head of the three list queries (whitespace is part of the SQL text).
1212
+ select_head = (
1213
+ f"SELECT {self._compact_session_cols() if compact_rows else 's.*'}"
1214
+ + ("" if compact_rows else ", COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved")
1215
+ + f",\n {_PREVIEW_COL_SQL},\n "
1216
+ )
1217
+ prompt_join = (
1218
+ "" if compact_rows else "LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash"
1219
+ )
1220
+ from_sessions = f"FROM sessions s\n {prompt_join}"
1221
+ if order_by_last_active:
1222
+ # The CTE walks compression-continuation edges forward from the admitted
1223
+ # rows; MAX over the chain gives effective_last_active in SQL. Do NOT
1224
+ # require child.started_at >= parent.ended_at: races insert the
1225
+ # continuation before ended_at is written.
1226
+ outer_where, id_params = self._chain_search_where(
1227
+ where_sql, (id_query or "").strip().lower(), (search_query or "").strip().lower(),
1228
+ )
1229
+ query = f"""
1230
+ WITH RECURSIVE chain(root_id, cur_id) AS (
1231
+ SELECT s.id, s.id FROM sessions s {where_sql}
1232
+ UNION ALL
1233
+ SELECT c.root_id, child.id
1234
+ FROM chain c
1235
+ JOIN sessions parent ON parent.id = c.cur_id
1236
+ JOIN sessions child ON child.parent_session_id = c.cur_id
1237
+ WHERE parent.end_reason = 'compression'
1238
+ AND {_sql_json_extract('child.model_config', '$._branched_from')} IS NULL
1239
+ AND {_sql_json_extract('child.model_config', '$._delegate_from')} IS NULL
1240
+ AND COALESCE(child.source, '') != 'tool'
1241
+ ),
1242
+ chain_max AS (
1243
+ SELECT
1244
+ root_id,
1245
+ MAX({_sql_session_last_active_by_id("cur_id")}) AS effective_last_active
1246
+ FROM chain
1247
+ GROUP BY root_id
1248
+ )
1249
+ {select_head}{_sql_session_last_active("s")} AS last_active,
1250
+ COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active
1251
+ FROM sessions s
1252
+ LEFT JOIN chain_max cm ON cm.root_id = s.id
1253
+ {prompt_join}
1254
+ {outer_where}
1255
+ ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC
1256
+ LIMIT ? OFFSET ?
1257
+ """
1258
+ params = params + params + id_params + [limit, offset] # WHERE binds twice (seed + outer)
1259
+ else:
1260
+ query = f"""
1261
+ {select_head}{_sql_session_last_active("s")} AS last_active
1262
+ {from_sessions}
1263
+ {where_sql}
1264
+ ORDER BY s.started_at DESC
1265
+ LIMIT ? OFFSET ?
1266
+ """
1267
+ params.extend([limit, offset])
1268
+ sessions = [self._list_row(row) for row in self._read_all(query, params)]
1269
+ # Pinned back-fill runs BEFORE compression projection so a back-filled root
1270
+ # projects to its tip like any other row.
1271
+ if include_pinned:
1272
+ seen_ids = {s["id"] for s in sessions}
1273
+ pinned_where = f"{where_sql} AND s.pinned = 1" if where_sql else "WHERE s.pinned = 1"
1274
+ pinned_query = f"""
1275
+ {select_head}COALESCE(
1276
+ (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
1277
+ s.started_at
1278
+ ) AS last_active
1279
+ {from_sessions}
1280
+ {pinned_where}
1281
+ ORDER BY s.started_at DESC
1282
+ """
1283
+ for row in self._read_all(pinned_query, base_where_params):
1284
+ s = self._list_row(row)
1285
+ if s["id"] not in seen_ids:
1286
+ seen_ids.add(s["id"])
1287
+ sessions.append(s)
1288
+ if project_compression_tips and not include_children:
1289
+ sessions = self._project_compression_tips(sessions, compact_rows)
1290
+ # last_read_at is lineage-stamped, so root and tip watermarks agree.
1291
+ for s in sessions:
1292
+ s["unread"] = self.session_unread(s)
1293
+ return sessions
1294
+
1295
+ def session_lifecycle_statuses(self, session_ids: List[str]) -> Dict[str, str]:
1296
+ """``{session_id: status}`` from each session's LAST message row (``'empty'`` when none); one
1297
+ query, MAX(id) per session joined back — never scans transcripts."""
1298
+ ids = [sid for sid in (session_ids or []) if sid]
1299
+ if not ids:
1300
+ return {}
1301
+ statuses: Dict[str, str] = {sid: "empty" for sid in ids}
1302
+ rows = self._read_all(f"""
1303
+ SELECT m.session_id, m.role,
1304
+ m.tool_calls IS NOT NULL AS has_tool_calls,
1305
+ m.finish_reason
1306
+ FROM messages m
1307
+ JOIN (
1308
+ SELECT session_id, MAX(id) AS max_id
1309
+ FROM messages
1310
+ WHERE session_id IN ({_session_ids_placeholders(ids)})
1311
+ GROUP BY session_id
1312
+ ) latest ON m.id = latest.max_id
1313
+ """, ids)
1314
+ for row in rows:
1315
+ statuses[row["session_id"]] = classify_session_status(
1316
+ role=row["role"], has_tool_calls=bool(row["has_tool_calls"]),
1317
+ finish_reason=row["finish_reason"],
1318
+ )
1319
+ return statuses
1320
+
1321
+ def assert_export_safe(self, session_id: str, max_messages: Optional[int] = None) -> int:
1322
+ """Active row count of this segment, or raise SessionExportTooLargeError (the LIMITed subquery
1323
+ stops once the bound is exceeded). ``None`` resolves ``sessions.max_export_messages``; 0 disables
1324
+ the guard."""
1325
+ from hermes_state import SessionExportTooLargeError, resolved_max_export_messages
1326
+ if max_messages is None:
1327
+ max_messages = resolved_max_export_messages()
1328
+ if max_messages < 0:
1329
+ raise ValueError("max_messages must be non-negative")
1330
+ if max_messages == 0:
1331
+ return 0
1332
+ row = self._read_one(
1333
+ "SELECT COUNT(*) FROM (SELECT 1 FROM messages WHERE session_id = ? AND active = 1 LIMIT ?)",
1334
+ (session_id, max_messages + 1),
1335
+ )
1336
+ message_count = int(row[0] if row else 0)
1337
+ if message_count > max_messages:
1338
+ raise SessionExportTooLargeError(session_id, message_count, max_messages)
1339
+ return message_count
1340
+
1341
+ def _is_explicit_branch_session(self, session_id: str) -> bool:
1342
+ """Copied user-facing branch (``_branched_from``)? Branches own a copied transcript;
1343
+ compression continuations need the parent's archived rows."""
1344
+ if not session_id:
1345
+ return False
1346
+ row = self._read_one("SELECT model_config FROM sessions WHERE id = ?", (session_id,))
1347
+ return row is not None and bool(_parse_model_config(row[0]).get("_branched_from"))
1348
+
1349
+ def _session_lineage_root_to_tip(self, session_id: str) -> List[str]:
1350
+ if not session_id:
1351
+ return [session_id]
1352
+ chain: List[str] = []
1353
+ current = session_id
1354
+ with self._read_ctx() as conn:
1355
+ while current and current not in chain and len(chain) < 100:
1356
+ chain.append(current)
1357
+ row = conn.execute(
1358
+ "SELECT parent_session_id FROM sessions WHERE id = ?", (current,),
1359
+ ).fetchone()
1360
+ if row is None:
1361
+ break
1362
+ current = row[0]
1363
+ return list(reversed(chain)) or [session_id]
1364
+
1365
+ def search_sessions(
1366
+ self, source: str = None, limit: int = 20, offset: int = 0, workspace_key: str = None,
1367
+ ) -> List[Dict[str, Any]]:
1368
+ """Sessions MRU-first with a computed ``last_active``; ``workspace_key`` scopes to one workspace
1369
+ so ``hermes -c``/``--resume`` picks its last session."""
1370
+ where_clauses = []
1371
+ params: list = []
1372
+ if source:
1373
+ where_clauses.append("s.source = ?")
1374
+ params.append(source)
1375
+ if workspace_key:
1376
+ ws_clause, ws_params = _workspace_key_clause(workspace_key)
1377
+ where_clauses.append(ws_clause)
1378
+ params.extend(ws_params)
1379
+ return [self._session_row_dict(row) for row in self._read_all(
1380
+ "SELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved, "
1381
+ f"{_sql_session_last_active('s')} AS last_active "
1382
+ "FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash "
1383
+ f"{_where_sql(where_clauses, ' ')} "
1384
+ "ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
1385
+ [*params, limit, offset],
1386
+ )]
1387
+
1388
+ def session_count(
1389
+ self, source: str = None, sources: List[str] = None, cwd_prefix: str = None,
1390
+ min_message_count: int = 0, include_archived: bool = False, archived_only: bool = False,
1391
+ exclude_children: bool = False, exclude_sources: List[str] = None,
1392
+ ) -> int:
1393
+ """Count sessions with list_sessions_rich's filters so a paired "load more" total matches."""
1394
+ where_clauses, params = _session_filter_where(
1395
+ exclude_children=exclude_children, source=source, sources=sources,
1396
+ exclude_sources=exclude_sources, cwd_prefix=cwd_prefix, min_message_count=min_message_count,
1397
+ archived_only=archived_only, include_archived=include_archived,
1398
+ )
1399
+ return self._read_one(f"SELECT COUNT(*) FROM sessions s{_where_sql(where_clauses, ' ')}", params)[0]
1400
+
1401
+ def session_count_ge(self, n: int = 1) -> bool:
1402
+ """At least N sessions exist (archived included); LIMIT short-circuits session_count()'s scan."""
1403
+ return len(self._read_all("SELECT 1 FROM sessions LIMIT ?", (n,))) >= n
1404
+
1405
+ def session_count_by_source(
1406
+ self, *, include_archived: bool = False, archived_only: bool = False,
1407
+ exclude_children: bool = False,
1408
+ ) -> Dict[str, int]:
1409
+ """``{source: count}`` via one GROUP BY; ``exclude_children`` mirrors listing visibility."""
1410
+ where_clauses, params = _session_filter_where(
1411
+ exclude_children=exclude_children, archived_only=archived_only,
1412
+ include_archived=include_archived,
1413
+ )
1414
+ with self._read_ctx() as conn:
1415
+ if self._conn is None:
1416
+ raise RuntimeError("SessionDB connection is closed")
1417
+ rows = conn.execute(
1418
+ "SELECT COALESCE(NULLIF(s.source, ''), 'cli') AS source, COUNT(*) AS count "
1419
+ f"FROM sessions s{_where_sql(where_clauses, ' ')} "
1420
+ "GROUP BY COALESCE(NULLIF(s.source, ''), 'cli') ORDER BY count DESC", params,
1421
+ ).fetchall()
1422
+ return {str(row["source"]): int(row["count"] or 0) for row in rows}
1423
+
1424
+ def declared_scope_identity(self, session_id: str) -> Tuple[bool, str]:
1425
+ """(is_fork_child, source) in ONE read (prompt_cache_scope needs both from the same row).
1426
+ Missing row → (False, ""); DB errors propagate (fail closed).
1427
+
1428
+ ``agent/prompt_cache_scope.py`` needs both to resolve a host-declared conversation scope, and both
1429
+ live on the same ``sessions`` row; asking for them separately read that row twice per resolution
1430
+ (@teknium1 on 98811). The marker rules stay here, beside :meth:`is_explicit_fork_child`, instead of
1431
+ being re-implemented by the caller. See #98811.
1432
+ """
1433
+ session = self.get_session(session_id)
1434
+ if not session:
1435
+ return False, ""
1436
+ return self._is_explicit_fork_child_row(session), str(session.get("source") or "").strip()
1437
+
1438
+ @staticmethod
1439
+ def _remove_session_files(sessions_dir: Optional[Path], session_id: str) -> None:
1440
+ """Remove ``<id>.json``/``.jsonl`` and gateway ``request_dump_<id>_*.json``; OSError is swallowed
1441
+ so a filesystem hiccup never blocks a DB operation."""
1442
+ if sessions_dir is None:
1443
+ return
1444
+ targets = [sessions_dir / f"{session_id}{suffix}" for suffix in (".json", ".jsonl")]
1445
+ try:
1446
+ targets.extend(sessions_dir.glob(f"request_dump_{session_id}_*.json"))
1447
+ except OSError:
1448
+ pass
1449
+ for p in targets:
1450
+ try:
1451
+ p.unlink(missing_ok=True)
1452
+ except OSError:
1453
+ pass
1454
+
1455
+ def get_session_delete_targets(self, session_id: str) -> List[str]:
1456
+ """Rows :meth:`delete_session` would remove: the session, then its recursive delegate children
1457
+ (branch/compression children are orphaned, not deleted)."""
1458
+ with self._read_ctx() as conn:
1459
+ if not conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone():
1460
+ return []
1461
+ # Use the borrowed read connection, never self._conn: handing the shared writer connection to a
1462
+ # helper here executes on it without self._lock — the same unsynchronized-read class as
1463
+ # #99349/#90734.
1464
+ delegate_ids = _collect_delegate_child_ids(conn, [session_id])
1465
+ return [session_id, *sorted(delegate_ids)]
1466
+
1467
+ def delete_session(
1468
+ self, session_id: str, sessions_dir: Optional[Path] = None,
1469
+ expected_delete_ids: Optional[List[str]] = None,
1470
+ ) -> bool:
1471
+ """Delete a session and its messages; delegate children cascade, branch/compression children
1472
+ are orphaned. *expected_delete_ids*: proceed only if parent + delegate cascade still equals that
1473
+ set (re-walked inside the transaction on purpose: export-before-delete fails closed)."""
1474
+ removed_ids: List[str] = []
1475
+ expected_ids = set(expected_delete_ids) if expected_delete_ids is not None else None
1476
+ def _do(conn):
1477
+ if conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone() is None:
1478
+ return False
1479
+ if expected_ids is not None and expected_ids != {
1480
+ session_id, *_collect_delegate_child_ids(conn, [session_id])
1481
+ }:
1482
+ return False
1483
+ removed_ids.extend(_delete_delegate_children(conn, [session_id]))
1484
+ conn.execute( # orphan remaining children (branches) so FK is satisfied
1485
+ "UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = ?", (session_id,),
1486
+ )
1487
+ conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
1488
+ conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
1489
+ self._delete_unreferenced_system_prompts(conn)
1490
+ removed_ids.append(session_id)
1491
+ return True
1492
+ deleted = self._execute_write(_do)
1493
+ for sid in removed_ids:
1494
+ self._remove_session_files(sessions_dir, sid)
1495
+ return bool(deleted)
1496
+
1497
+ def delete_session_if_empty(self, session_id: str, sessions_dir: Optional[Path] = None) -> bool:
1498
+ """Delete *session_id* only if it has no messages, no title and no children; check and delete
1499
+ share one transaction so a concurrent flush can't be lost."""
1500
+ def _do(conn):
1501
+ cursor = conn.execute(
1502
+ """
1503
+ DELETE FROM sessions
1504
+ WHERE id = ?
1505
+ AND title IS NULL
1506
+ AND NOT EXISTS (
1507
+ SELECT 1 FROM messages WHERE messages.session_id = sessions.id
1508
+ )
1509
+ AND NOT EXISTS (
1510
+ SELECT 1 FROM sessions child
1511
+ WHERE child.parent_session_id = sessions.id
1512
+ )
1513
+ """,
1514
+ (session_id,),
1515
+ )
1516
+ if cursor.rowcount > 0:
1517
+ self._delete_unreferenced_system_prompts(conn)
1518
+ return cursor.rowcount > 0
1519
+ deleted = self._execute_write(_do)
1520
+ if deleted:
1521
+ self._remove_session_files(sessions_dir, session_id)
1522
+ return deleted
1523
+
1524
+ def delete_sessions(self, session_ids: List[str], sessions_dir: Optional[Path] = None) -> int:
1525
+ """Bulk delete with :meth:`delete_session` semantics per row, in ONE transaction. Unknown ids
1526
+ are skipped (UI selection can race another tab's delete). Returns the number deleted."""
1527
+ unique_ids = list({sid for sid in session_ids or () if isinstance(sid, str) and sid})
1528
+ if not unique_ids:
1529
+ return 0
1530
+ removed_ids: list[str] = []
1531
+ def _do(conn):
1532
+ existing = [row["id"] for chunk in _id_chunks(unique_ids) for row in conn.execute(
1533
+ f"SELECT id FROM sessions WHERE id IN ({_session_ids_placeholders(chunk)})", chunk,
1534
+ ).fetchall()]
1535
+ if not existing:
1536
+ return 0
1537
+ removed_ids.extend(_delete_delegate_children(conn, existing))
1538
+ for chunk in _id_chunks(existing):
1539
+ ph = _session_ids_placeholders(chunk)
1540
+ conn.execute( # orphan children whose parent is in the kill list (FK)
1541
+ f"UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id IN ({ph})", chunk,
1542
+ )
1543
+ conn.execute(f"DELETE FROM messages WHERE session_id IN ({ph})", chunk)
1544
+ conn.execute(f"DELETE FROM sessions WHERE id IN ({ph})", chunk)
1545
+ self._delete_unreferenced_system_prompts(conn)
1546
+ removed_ids.extend(existing)
1547
+ return len(existing)
1548
+ count = self._execute_write(_do)
1549
+ for sid in removed_ids:
1550
+ self._remove_session_files(sessions_dir, sid)
1551
+ return count
1552
+
1553
+ # Shared by count_empty_sessions / delete_empty_sessions so badge and sweep agree. message_count
1554
+ # counts live rows only (rewind/compaction keep dropped turns as active = 0): NOT EXISTS is authority.
1555
+ # The ``NOT EXISTS`` probe is the authority; : ``message_count = 0`` stays as a cheap prefilter. Same
1556
+ # shape as every : other emptiness guard in this module. (#95868)
1557
+ _EMPTY_SESSION_WHERE = (
1558
+ "message_count = 0 AND ended_at IS NOT NULL AND archived = 0 AND NOT EXISTS ("
1559
+ "SELECT 1 FROM messages WHERE messages.session_id = sessions.id)"
1560
+ )
1561
+
1562
+ def count_empty_sessions(self) -> int:
1563
+ """Count of empty, ended, non-archived sessions; ended_at guards a fresh session's first message."""
1564
+ return self._read_one(f"SELECT COUNT(*) FROM sessions WHERE {self._EMPTY_SESSION_WHERE}")[0]
1565
+
1566
+ def delete_empty_sessions(self, sessions_dir: Optional[Path] = None) -> int:
1567
+ """Delete every empty, ended, non-archived session in one transaction, orphaning (not cascading)
1568
+ children; transcript files are swept too."""
1569
+ removed_ids: list[str] = []
1570
+ def _do(conn):
1571
+ session_ids = {row["id"] for row in conn.execute(
1572
+ f"SELECT id FROM sessions WHERE {self._EMPTY_SESSION_WHERE}"
1573
+ ).fetchall()}
1574
+ if not session_ids:
1575
+ return 0
1576
+ for chunk in _id_chunks(session_ids):
1577
+ ph = _session_ids_placeholders(chunk)
1578
+ conn.execute(f"UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id IN ({ph})", chunk)
1579
+ # DELETE FROM messages: a row inserted between the SELECT and here
1580
+ # would otherwise dangle (clean FK state).
1581
+ conn.execute(f"DELETE FROM messages WHERE session_id IN ({ph})", chunk)
1582
+ conn.execute(f"DELETE FROM sessions WHERE id IN ({ph})", chunk)
1583
+ removed_ids.extend(chunk)
1584
+ self._delete_unreferenced_system_prompts(conn)
1585
+ return len(session_ids)
1586
+ count = self._execute_write(_do)
1587
+ for sid in removed_ids:
1588
+ self._remove_session_files(sessions_dir, sid)
1589
+ return count
1590
+
1591
+ def archive_sessions(
1592
+ self, older_than_days: Optional[float] = None, source: str = None, **filters,
1593
+ ) -> int:
1594
+ """Bulk soft-hide with prune_sessions' filter surface, via set_session_archived so each lineage
1595
+ flips as a unit; idempotent. Returns matches."""
1596
+ filters.setdefault("archived", False)
1597
+ rows = self.list_prune_candidates(older_than_days=older_than_days, source=source, **filters)
1598
+ for row in rows:
1599
+ self.set_session_archived(row["id"], True)
1600
+ return len(rows)
1601
+
1602
+ def maybe_auto_archive(
1603
+ self, idle_days: float = 3, min_interval_hours: int = 24, exclude_pinned: bool = True,
1604
+ ) -> Dict[str, Any]:
1605
+ """Idempotent, non-destructive auto-archive of sessions idle for ``idle_days``; state_meta
1606
+ ``last_auto_archive`` gates runs within ``min_interval_hours``. Never raises."""
1607
+ result: Dict[str, Any] = {"skipped": False, "archived": 0}
1608
+ try:
1609
+ now = time.time()
1610
+ try:
1611
+ last = float(self.get_meta("last_auto_archive") or 0.0)
1612
+ except (TypeError, ValueError):
1613
+ last = 0.0 # corrupt meta; treat as no prior run
1614
+ if last and now - last < min_interval_hours * 3600:
1615
+ result["skipped"] = True
1616
+ return result
1617
+ archived = result["archived"] = self.archive_stale_sessions(idle_days, exclude_pinned=exclude_pinned)
1618
+ # Record even a zero-archive run so we don't re-sweep every call.
1619
+ self.set_meta("last_auto_archive", str(now))
1620
+ if archived > 0:
1621
+ logger.info(
1622
+ "state.db auto-archive: archived %d session(s) idle >= %s days", archived, idle_days,
1623
+ )
1624
+ except Exception as exc:
1625
+ logger.warning("state.db auto-archive failed: %s", exc)
1626
+ result["error"] = str(exc)
1627
+ return result
hermes_state_telegram.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Telegram DM topic-mode mixin for :class:`hermes_state.SessionDB`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ import sqlite3
8
+ import time
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from hermes_state_common import _PREVIEW_ELIGIBLE_SQL, _PREVIEW_RAW_SELECT, _sql_session_last_active
12
+
13
+ # caplog tests pin the "hermes_state" logger name.
14
+ logger = logging.getLogger("hermes_state")
15
+
16
+
17
+ def _normalize_telegram_topic_profile_name(profile_name: Optional[str] = None) -> str:
18
+ """Empty/missing → ``"default"`` (single namespace for non-multiplexed gateways).
19
+ Multiplexed callers must pass the *routed* profile (``source.profile``), never the
20
+ process-global active profile."""
21
+ name = str(profile_name or "").strip()
22
+ return name if name else "default"
23
+
24
+
25
+ # (table, column list, DDL body). profile_name leads the PK: a private chat_id is the
26
+ # user id, identical across bots sharing one state.db.
27
+ _TOPIC_TABLES = (
28
+ (
29
+ "telegram_dm_topic_mode",
30
+ "profile_name, chat_id, user_id, enabled, activated_at, updated_at, "
31
+ "has_topics_enabled, allows_users_to_create_topics, capability_checked_at, intro_message_id, pinned_message_id",
32
+ """
33
+ profile_name TEXT NOT NULL DEFAULT 'default',
34
+ chat_id TEXT NOT NULL,
35
+ user_id TEXT NOT NULL,
36
+ enabled INTEGER NOT NULL DEFAULT 1,
37
+ activated_at REAL NOT NULL,
38
+ updated_at REAL NOT NULL,
39
+ has_topics_enabled INTEGER,
40
+ allows_users_to_create_topics INTEGER,
41
+ capability_checked_at REAL,
42
+ intro_message_id TEXT,
43
+ pinned_message_id TEXT,
44
+ PRIMARY KEY (profile_name, chat_id)
45
+ """,
46
+ ),
47
+ (
48
+ "telegram_dm_topic_bindings",
49
+ "profile_name, chat_id, thread_id, user_id, session_key, session_id, managed_mode, linked_at, updated_at",
50
+ """
51
+ profile_name TEXT NOT NULL DEFAULT 'default',
52
+ chat_id TEXT NOT NULL,
53
+ thread_id TEXT NOT NULL,
54
+ user_id TEXT NOT NULL,
55
+ session_key TEXT NOT NULL,
56
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
57
+ managed_mode TEXT NOT NULL DEFAULT 'auto',
58
+ linked_at REAL NOT NULL,
59
+ updated_at REAL NOT NULL,
60
+ PRIMARY KEY (profile_name, chat_id, thread_id)
61
+ """,
62
+ ),
63
+ )
64
+
65
+ # Shared SELECT for the unlinked-session listing; the profile/bindings clauses are
66
+ # spliced in only when the bindings table exists.
67
+ _UNLINKED_SELECT_HEAD = f"""
68
+ SELECT s.*,
69
+ COALESCE(sp.prompt, s.system_prompt)
70
+ AS _system_prompt_resolved,
71
+ COALESCE(
72
+ (SELECT {_PREVIEW_RAW_SELECT}
73
+ FROM messages m
74
+ WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
75
+ AND {_PREVIEW_ELIGIBLE_SQL}
76
+ ORDER BY m.timestamp, m.id LIMIT 1),
77
+ ''
78
+ ) AS _preview_raw,
79
+ {_sql_session_last_active("s")} AS last_active
80
+ FROM sessions s
81
+ LEFT JOIN system_prompts sp
82
+ ON sp.hash = s.system_prompt_hash
83
+ WHERE s.source = 'telegram'
84
+ AND s.user_id = ?
85
+ """
86
+ _UNLINKED_SELECT_TAIL = """ ORDER BY last_active DESC, s.started_at DESC
87
+ LIMIT ?
88
+ """
89
+ # sessions.profile_name is NULL/empty for legacy rows → treat as default.
90
+ _UNLINKED_SCOPE_CLAUSES = """ AND COALESCE(NULLIF(TRIM(s.profile_name), ''), 'default') = ?
91
+ AND NOT EXISTS (
92
+ SELECT 1 FROM telegram_dm_topic_bindings b
93
+ WHERE b.session_id = s.id
94
+ )
95
+ """
96
+
97
+
98
+ class SessionTelegramTopicsMixin:
99
+ """Telegram DM topic-mode tables, bindings and lookups. Read paths tolerate absent
100
+ tables (nobody ran ``/topic``) by returning their empty value; only
101
+ ``enable``/``bind`` run the migration."""
102
+
103
+ def _topic_read_one(self, sql: str, params):
104
+ """``fetchone`` that treats an unmigrated table as None."""
105
+ try:
106
+ return self._read_one(sql, params)
107
+ except sqlite3.OperationalError:
108
+ return None
109
+
110
+ def apply_telegram_topic_migration(self) -> None:
111
+ """Create Telegram DM topic-mode tables on explicit /topic opt-in. Deliberately NOT
112
+ part of startup reconciliation: operators can upgrade and keep the old bot
113
+ behavior until a user runs /topic. Schema versions: v1 initial; v2 session_id FK
114
+ ON DELETE CASCADE (pruning clears bindings); v3 ``profile_name`` on both tables so
115
+ multiplexed gateways sharing one state.db isolate topic state per profile.
116
+
117
+ See #76423.
118
+ """
119
+ def _do(conn):
120
+ for table, columns, ddl in _TOPIC_TABLES:
121
+ conn.execute(f"CREATE TABLE IF NOT EXISTS {table} ({ddl})")
122
+ have = {row[1] for row in conn.execute(f"PRAGMA table_info('{table}')")}
123
+ if "profile_name" in have:
124
+ continue
125
+ # v1/v2 → v3. SQLite can't ALTER a PK or FK, so rebuild (also supplies v2's
126
+ # ON DELETE CASCADE). Legacy rows land in "default" only.
127
+ legacy_columns = columns.replace("profile_name, ", "", 1)
128
+ conn.executescript(f"""
129
+ CREATE TABLE {table}_new ({ddl});
130
+ INSERT INTO {table}_new ({columns})
131
+ SELECT 'default', {legacy_columns} FROM {table};
132
+ DROP TABLE {table};
133
+ ALTER TABLE {table}_new RENAME TO {table};
134
+ """)
135
+ # Indexes after any rebuild: the user index needs profile_name.
136
+ conn.executescript("""
137
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_session
138
+ ON telegram_dm_topic_bindings(session_id);
139
+
140
+ CREATE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_user
141
+ ON telegram_dm_topic_bindings(profile_name, user_id, chat_id);
142
+ """)
143
+ conn.execute(
144
+ "INSERT INTO state_meta (key, value) VALUES (?, ?) "
145
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
146
+ ("telegram_dm_topic_schema_version", "3"),
147
+ )
148
+ self._execute_write(_do)
149
+
150
+ def enable_telegram_topic_mode(
151
+ self, *, chat_id: str, user_id: str, profile_name: str="default", has_topics_enabled: Optional[bool]=None,
152
+ allows_users_to_create_topics: Optional[bool]=None,
153
+ ) -> None:
154
+ """Enable Telegram DM topic mode for one private chat/user. Owns the explicit topic
155
+ migration; SessionDB startup must not create these tables.
156
+
157
+ ``profile_name`` namespaces rows under a shared multiplex ``state.db`` (issue #76423). Callers
158
+ handling a multiplexed event must pass the routed profile from ``source.profile``, not the
159
+ process-global active profile.
160
+ """
161
+ self.apply_telegram_topic_migration()
162
+ now = time.time()
163
+ profile_name = _normalize_telegram_topic_profile_name(profile_name)
164
+
165
+ def _to_int(value: Optional[bool]) -> Optional[int]:
166
+ return None if value is None else (1 if value else 0)
167
+
168
+ self._write_sql("""
169
+ INSERT INTO telegram_dm_topic_mode (
170
+ profile_name, chat_id, user_id, enabled, activated_at, updated_at,
171
+ has_topics_enabled, allows_users_to_create_topics,
172
+ capability_checked_at
173
+ ) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)
174
+ ON CONFLICT(profile_name, chat_id) DO UPDATE SET
175
+ user_id = excluded.user_id,
176
+ enabled = 1,
177
+ updated_at = excluded.updated_at,
178
+ has_topics_enabled = excluded.has_topics_enabled,
179
+ allows_users_to_create_topics = excluded.allows_users_to_create_topics,
180
+ capability_checked_at = excluded.capability_checked_at
181
+ """, (profile_name, str(chat_id), str(user_id), now, now,
182
+ _to_int(has_topics_enabled), _to_int(allows_users_to_create_topics), now))
183
+
184
+ def disable_telegram_topic_mode(
185
+ self, *, chat_id: str, profile_name: str = "default", clear_bindings: bool = True
186
+ ) -> None:
187
+ """Disable Telegram DM topic mode for one private chat. ``clear_bindings`` also drops
188
+ the chat's bindings so a later re-enable starts clean. Never creates the tables;
189
+ absent tables are a no-op."""
190
+ profile_name = _normalize_telegram_topic_profile_name(profile_name)
191
+
192
+ def _do(conn):
193
+ with contextlib.suppress(sqlite3.OperationalError):
194
+ conn.execute(
195
+ "UPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? "
196
+ "WHERE profile_name = ? AND chat_id = ?",
197
+ (time.time(), profile_name, str(chat_id)),
198
+ )
199
+ if clear_bindings:
200
+ conn.execute(
201
+ "DELETE FROM telegram_dm_topic_bindings WHERE profile_name = ? AND chat_id = ?",
202
+ (profile_name, str(chat_id)),
203
+ )
204
+ self._execute_write(_do)
205
+
206
+ def is_telegram_topic_mode_enabled(self, *, chat_id: str, user_id: str, profile_name: str = "default") -> bool:
207
+ """Return whether Telegram DM topic mode is enabled for this chat/user."""
208
+ profile_name = _normalize_telegram_topic_profile_name(profile_name)
209
+ row = self._topic_read_one("""
210
+ SELECT enabled FROM telegram_dm_topic_mode
211
+ WHERE profile_name = ? AND chat_id = ? AND user_id = ?
212
+ """, (profile_name, str(chat_id), str(user_id)))
213
+ return bool(row[0]) if row is not None else False
214
+
215
+ def get_telegram_topic_binding(
216
+ self, *, chat_id: str, thread_id: str, profile_name: str = "default"
217
+ ) -> Optional[Dict[str, Any]]:
218
+ """Return the session binding for a Telegram DM topic, if present."""
219
+ profile_name = _normalize_telegram_topic_profile_name(profile_name)
220
+ row = self._topic_read_one("""
221
+ SELECT * FROM telegram_dm_topic_bindings
222
+ WHERE profile_name = ? AND chat_id = ? AND thread_id = ?
223
+ """, (profile_name, str(chat_id), str(thread_id)))
224
+ return dict(row) if row else None
225
+
226
+ def list_telegram_topic_bindings_for_chat(
227
+ self, *, chat_id: str, profile_name: str = "default"
228
+ ) -> List[Dict[str, Any]]:
229
+ """All bindings for one chat, newest first ([] when the table is absent)."""
230
+ profile_name = _normalize_telegram_topic_profile_name(profile_name)
231
+ try:
232
+ rows = self._read_all(
233
+ "SELECT * FROM telegram_dm_topic_bindings WHERE profile_name = ? AND chat_id = ? ORDER BY updated_at DESC",
234
+ (profile_name, str(chat_id)),
235
+ )
236
+ except sqlite3.OperationalError:
237
+ return []
238
+ return [dict(row) for row in rows]
239
+
240
+ def get_telegram_topic_binding_by_session(self, *, session_id: str) -> Optional[Dict[str, Any]]:
241
+ """Reverse lookup via the UNIQUE INDEX on session_id; None when unbound."""
242
+ row = self._topic_read_one("""
243
+ SELECT * FROM telegram_dm_topic_bindings
244
+ WHERE session_id = ?
245
+ """, (str(session_id),))
246
+ return dict(row) if row else None
247
+
248
+ def delete_telegram_topic_binding(self, *, chat_id: str, thread_id: str, profile_name: str = "default") -> int:
249
+ """Remove the binding row for one (chat, thread) pair. Called when the Bot API confirms
250
+ a topic was deleted externally (``Thread not found`` after the same-thread retry
251
+ failed); otherwise ``gateway.run._recover_telegram_topic_thread_id`` keeps
252
+ redirecting inbound messages to the dead topic. If this removes the chat's *last*
253
+ binding, ``telegram_dm_topic_mode`` is flipped to ``enabled = 0`` in the same
254
+ transaction, or a user who disabled topics in the Telegram client (not via
255
+ ``/topic off``) stays stuck. Returns the number of rows deleted; absent binding or
256
+ unmigrated tables are silent no-ops (never raise from a cleanup hot path).
257
+
258
+ Without this prune, the stale row keeps living in ``telegram_dm_topic_bindings`` and the recovery
259
+ logic in ``gateway.run._recover_telegram_topic_thread_id`` cheerfully redirects future inbound
260
+ messages to the deleted topic, causing tool progress, approvals, and replies to land in the wrong
261
+ place. Issue #31501.
262
+ """
263
+ chat_id, thread_id = str(chat_id), str(thread_id)
264
+ profile_name = _normalize_telegram_topic_profile_name(profile_name)
265
+
266
+ def _do(conn) -> int:
267
+ try:
268
+ deleted = conn.execute("""
269
+ DELETE FROM telegram_dm_topic_bindings
270
+ WHERE profile_name = ? AND chat_id = ? AND thread_id = ?
271
+ """, (profile_name, chat_id, thread_id)).rowcount or 0
272
+ except sqlite3.OperationalError:
273
+ return 0
274
+ if not deleted:
275
+ return 0
276
+ # Last binding gone → disable topic mode in the same transaction (no
277
+ # read-after-prune race). telegram_dm_topic_mode absent — binding prune still stands.
278
+ with contextlib.suppress(sqlite3.OperationalError):
279
+ remaining = conn.execute("""
280
+ SELECT 1 FROM telegram_dm_topic_bindings
281
+ WHERE profile_name = ? AND chat_id = ? LIMIT 1
282
+ """, (profile_name, chat_id)).fetchone()
283
+ if remaining is None:
284
+ conn.execute(
285
+ "UPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? "
286
+ "WHERE profile_name = ? AND chat_id = ?",
287
+ (time.time(), profile_name, chat_id),
288
+ )
289
+ return deleted
290
+
291
+ return self._execute_write(_do)
292
+
293
+ def bind_telegram_topic(
294
+ self, *, chat_id: str, thread_id: str, user_id: str, session_key: str,
295
+ session_id: str, managed_mode: str = "auto", profile_name: str = "default",
296
+ ) -> None:
297
+ """Bind one Telegram DM topic thread to one Hermes session. A session may be linked to
298
+ only one topic: rebinding the same pair is idempotent; linking the session to a
299
+ different topic raises ValueError."""
300
+ self.apply_telegram_topic_migration()
301
+ now = time.time()
302
+ chat_id, thread_id, user_id = str(chat_id), str(thread_id), str(user_id)
303
+ session_key, session_id = str(session_key), str(session_id)
304
+ profile_name = _normalize_telegram_topic_profile_name(profile_name)
305
+
306
+ def _do(conn):
307
+ existing_session = conn.execute("""
308
+ SELECT profile_name, chat_id, thread_id
309
+ FROM telegram_dm_topic_bindings
310
+ WHERE session_id = ?
311
+ """, (session_id,)).fetchone()
312
+ if existing_session is not None:
313
+ linked_profile, linked_chat, linked_thread = existing_session
314
+ if (str(linked_profile), str(linked_chat), str(linked_thread)) != (profile_name, chat_id, thread_id):
315
+ raise ValueError("session is already linked to another Telegram topic")
316
+ conn.execute("""
317
+ INSERT INTO telegram_dm_topic_bindings (
318
+ profile_name, chat_id, thread_id, user_id, session_key, session_id,
319
+ managed_mode, linked_at, updated_at
320
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
321
+ ON CONFLICT(profile_name, chat_id, thread_id) DO UPDATE SET
322
+ user_id = excluded.user_id,
323
+ session_key = excluded.session_key,
324
+ session_id = excluded.session_id,
325
+ managed_mode = excluded.managed_mode,
326
+ updated_at = excluded.updated_at
327
+ """, (profile_name, chat_id, thread_id, user_id, session_key, session_id, managed_mode, now, now))
328
+ self._execute_write(_do)
329
+
330
+ def is_telegram_session_linked_to_topic(self, *, session_id: str) -> bool:
331
+ """True if the session is bound to any Telegram DM topic (absent tables → False)."""
332
+ row = self._topic_read_one("""
333
+ SELECT 1 FROM telegram_dm_topic_bindings
334
+ WHERE session_id = ?
335
+ LIMIT 1
336
+ """, (str(session_id),))
337
+ return row is not None
338
+
339
+ def list_unlinked_telegram_sessions_for_user(
340
+ self, *, chat_id: str, user_id: str, profile_name: str = "default", limit: int = 10
341
+ ) -> List[Dict[str, Any]]:
342
+ """This user's Telegram sessions not bound to a topic. Read-only: if the bindings table
343
+ is absent, every session is unlinked and the profile-unscoped query is used.
344
+ Scoped by ``profile_name`` so multiplexed profiles do not surface each other.
345
+
346
+ See #76423.
347
+ """
348
+ profile_name = _normalize_telegram_topic_profile_name(profile_name)
349
+ with self._read_ctx() as conn:
350
+ try:
351
+ rows = conn.execute(
352
+ _UNLINKED_SELECT_HEAD + _UNLINKED_SCOPE_CLAUSES + _UNLINKED_SELECT_TAIL,
353
+ (str(user_id), profile_name, int(limit)),
354
+ ).fetchall()
355
+ except sqlite3.OperationalError:
356
+ rows = conn.execute(
357
+ _UNLINKED_SELECT_HEAD + _UNLINKED_SELECT_TAIL, (str(user_id), int(limit)),
358
+ ).fetchall()
359
+ return [self._rich_row(row) for row in rows]
hermes_state_titles.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Session title mixin for SessionDB: sanitizing, auto/user provenance ranking, and
2
+ lineage-aware lookups."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import re
8
+ from typing import Any, Dict, Optional
9
+
10
+ from agent.message_sanitization import _sanitize_surrogates
11
+ from hermes_state_common import _COMPRESSION_CHILD_SQL, escape_like as _escape_like
12
+
13
+ # caplog tests pin the "hermes_state" logger name.
14
+ logger = logging.getLogger("hermes_state")
15
+
16
+ # ASCII controls (keeping \t \n \r for the whitespace collapse), then zero-width,
17
+ # bidi override, object-replacement and interlinear-annotation code points.
18
+ _TITLE_CONTROL_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]')
19
+ _TITLE_INVISIBLE_RE = re.compile(r'[\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff\ufffc\ufff9-\ufffb]')
20
+ _NUMBERED_TITLE_RE = re.compile(r'^(.*?) #(\d+)$')
21
+
22
+
23
+ class SessionTitlesMixin:
24
+ """Sanitizing, ranking auto/user titles, lineage-aware lookups."""
25
+
26
+ @classmethod
27
+ def _title_rank(cls, source: Optional[str]) -> int:
28
+ """Rank a stored title_source. NULL (pre-provenance rows) is indistinguishable from a
29
+ manual ``/title`` of that era, so it ranks as ``user``."""
30
+ rank = cls._TITLE_SOURCE_RANK
31
+ return rank[cls.TITLE_SOURCE_USER] if source is None else rank.get(str(source), 0)
32
+
33
+ @staticmethod
34
+ def sanitize_title(title: Optional[str]) -> Optional[str]:
35
+ """Strip control/zero-width/bidi chars (and lone surrogates sqlite3 cannot bind),
36
+ collapse whitespace, normalize empty to None. ValueError past MAX_TITLE_LENGTH."""
37
+ from hermes_state import SessionDB
38
+ if not title:
39
+ return None
40
+ cleaned = _TITLE_INVISIBLE_RE.sub('', _TITLE_CONTROL_RE.sub('', _sanitize_surrogates(title)))
41
+ cleaned = re.sub(r'\s+', ' ', cleaned).strip()
42
+ if not cleaned:
43
+ return None
44
+ if len(cleaned) > SessionDB.MAX_TITLE_LENGTH:
45
+ raise ValueError(f"Title too long ({len(cleaned)} chars, max {SessionDB.MAX_TITLE_LENGTH})")
46
+ return cleaned
47
+
48
+ def _is_compression_ancestor(self, conn, *, ancestor_id: str, descendant_id: str) -> bool:
49
+ """True if *ancestor_id* is a compression predecessor of *descendant_id*, via the
50
+ canonical continuation edge ``_COMPRESSION_CHILD_SQL`` (excludes delegate/branch
51
+ children that also carry ``parent_session_id``)."""
52
+ if not ancestor_id or not descendant_id or ancestor_id == descendant_id:
53
+ return False
54
+ edge = _COMPRESSION_CHILD_SQL.format(a="child")
55
+ return conn.execute(f"""
56
+ WITH RECURSIVE ancestors(id) AS (
57
+ SELECT ?
58
+ UNION
59
+ SELECT parent.id
60
+ FROM ancestors a
61
+ JOIN sessions child ON child.id = a.id
62
+ JOIN sessions parent ON parent.id = child.parent_session_id
63
+ WHERE {edge}
64
+ )
65
+ SELECT 1 FROM ancestors WHERE id = ? AND id != ? LIMIT 1
66
+ """, (descendant_id, ancestor_id, descendant_id)).fetchone() is not None
67
+
68
+ def _set_session_title(self, session_id: str, title: str, *, source: str) -> bool:
69
+ """Write a title, enforcing provenance precedence. A ``user`` write always lands;
70
+ ``derived``/``llm`` land only when the row is untitled or holds strictly lower
71
+ authority (derived upgrades to llm exactly once, nothing overwrites a user name,
72
+ re-running the titler on an llm row is a no-op). No writer may move a hidden
73
+ canonical Bot Chat off its title. Read and write are one compare-and-swap
74
+ transaction, so a manual ``/title`` racing an in-flight generation is not clobbered."""
75
+ title = self.sanitize_title(title)
76
+ is_user = source == self.TITLE_SOURCE_USER
77
+ new_rank = self._title_rank(source) if not is_user else None
78
+
79
+ def _do(conn):
80
+ current = conn.execute(
81
+ "SELECT title, title_source, hidden FROM sessions WHERE id = ?", (session_id,),
82
+ ).fetchone()
83
+ if current is None:
84
+ return 0
85
+ # The canonical Bot Chat's NAME is its identity (Bot Mode resolves it by
86
+ # exact-title lookup on every open), so a rename orphans the conversation. Hidden
87
+ # is the discriminator: canonical chats are born hidden; a visible session merely
88
+ # named "Bot Chat" stays renameable. Provenance-blind.
89
+ if ((current["title"] or "") == self.CANONICAL_BOT_CHAT_TITLE and bool(current["hidden"])
90
+ and title != self.CANONICAL_BOT_CHAT_TITLE):
91
+ if is_user:
92
+ raise ValueError("This is the bot's canonical Bot Chat — its name is its "
93
+ "identity, and renaming it would orphan the conversation. "
94
+ "To start fresh, create a new bot instead.")
95
+ return 0
96
+ if not is_user and current["title"] is not None and self._title_rank(current["title_source"]) >= new_rank:
97
+ return 0
98
+ if title:
99
+ conflict = conn.execute(
100
+ "SELECT id FROM sessions WHERE title = ? AND id != ?", (title, session_id),
101
+ ).fetchone()
102
+ if conflict:
103
+ conflict_id = conflict["id"]
104
+ # A hidden compressed ancestor holding the title cannot be freed by the
105
+ # user, so transfer it onto the tip (uniqueness + lineage kept).
106
+ if self._is_compression_ancestor(conn, ancestor_id=conflict_id, descendant_id=session_id):
107
+ conn.execute("UPDATE sessions SET title = NULL WHERE id = ?", (conflict_id,))
108
+ else:
109
+ raise ValueError(f"Title '{title}' is already in use by session {conflict_id}")
110
+ # CAS on the values just read (``IS`` is NULL-safe): a concurrent write between
111
+ # the SELECT and here loses instead of being overwritten.
112
+ return conn.execute(
113
+ "UPDATE sessions SET title = ?, title_source = ? WHERE id = ? AND title IS ? AND title_source IS ?",
114
+ (title, source if title else None, session_id, current["title"], current["title_source"]),
115
+ ).rowcount
116
+
117
+ return self._execute_write(_do) > 0
118
+
119
+ def set_session_title(self, session_id: str, title: str) -> bool:
120
+ """Set a title on the user's behalf (``user`` provenance). Empty clears it. Raises
121
+ ValueError on conflict or validation failure."""
122
+ return self._set_session_title(session_id, title, source=self.TITLE_SOURCE_USER)
123
+
124
+ def set_auto_title(self, session_id: str, title: str, *, source: str) -> bool:
125
+ """Set an automatic title; False (untouched) when a higher-authority title holds the row."""
126
+ if source not in (self.TITLE_SOURCE_DERIVED, self.TITLE_SOURCE_LLM):
127
+ raise ValueError(f"invalid automatic title source: {source!r}")
128
+ return self._set_session_title(session_id, title, source=source)
129
+
130
+ def get_session_title(self, session_id: str) -> Optional[str]:
131
+ """Get the title for a session, or None."""
132
+ row = self._read_one("SELECT title FROM sessions WHERE id = ?", (session_id,))
133
+ return row["title"] if row else None
134
+
135
+ def get_session_title_source(self, session_id: str) -> Optional[str]:
136
+ """Get the provenance of a session's title, or None when untitled."""
137
+ row = self._read_one("SELECT title, title_source FROM sessions WHERE id = ?", (session_id,))
138
+ return row["title_source"] if row and row["title"] is not None else None
139
+
140
+ def set_session_title_source(self, session_id: str, source: str) -> bool:
141
+ """Overwrite a title's provenance without touching the text (a title copied across a
142
+ compression rotation keeps the original's authority)."""
143
+ if source not in self._TITLE_SOURCE_RANK:
144
+ raise ValueError(f"invalid title source: {source!r}")
145
+ return self._write_rowcount(
146
+ "UPDATE sessions SET title_source = ? WHERE id = ? AND title IS NOT NULL", (source, session_id)
147
+ ) > 0
148
+
149
+ def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
150
+ """Look up a session by exact title. Returns session dict or None."""
151
+ row = self._read_one(
152
+ "SELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved "
153
+ "FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash "
154
+ "WHERE s.title = ?", (title,))
155
+ return self._session_row_dict(row) if row else None
156
+
157
+ def resolve_session_by_title(self, title: str) -> Optional[str]:
158
+ """Resolve a title to a session ID, preferring the latest "title #N" continuation."""
159
+ exact = self.get_session_by_title(title)
160
+ # Escape LIKE wildcards so "%"/"_" in titles cannot false-match.
161
+ numbered = self._read_all(
162
+ "SELECT id, title, started_at FROM sessions "
163
+ "WHERE title LIKE ? ESCAPE '\\' ORDER BY started_at DESC",
164
+ (f"{_escape_like(title)} #%",))
165
+ return numbered[0]["id"] if numbered else (exact["id"] if exact else None)
166
+
167
+ def get_next_title_in_lineage(self, base_title: str) -> str:
168
+ """Next title in a lineage ("my session" -> "my session #2"): strip any " #N" suffix,
169
+ then increment the highest existing number."""
170
+ match = _NUMBERED_TITLE_RE.match(base_title)
171
+ base = match.group(1) if match else base_title
172
+ rows = self._read_all(
173
+ "SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'",
174
+ (base, f"{_escape_like(base)} #%"))
175
+ if not rows:
176
+ return base
177
+ # The unnumbered original counts as #1.
178
+ numbers = [int(m.group(2)) for m in (_NUMBERED_TITLE_RE.match(row["title"]) for row in rows) if m]
179
+ return f"{base} #{max([1, *numbers]) + 1}"
hermes_state_wal.py ADDED
@@ -0,0 +1,622 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLite journal-mode and PRAGMA policy for state.db (split from hermes_state).
2
+
3
+ Patchable helpers are looked up as module globals at call time, so tests patch ``hermes_state_wal.<name>``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import functools
10
+ import logging
11
+ import os
12
+ import sqlite3
13
+ import sys
14
+ import threading
15
+ import time
16
+ from typing import Any, Dict, Optional
17
+
18
+ from hermes_cli.sqlite_runtime import is_sqlite_wal_reset_vulnerable as _is_sqlite_wal_reset_vulnerable
19
+
20
+ # Log-record parity with the origin module (caplog tests pin "hermes_state").
21
+ logger = logging.getLogger("hermes_state")
22
+
23
+
24
+ # WAL needs mmap shared memory + fcntl byte-range locks. Network filesystems (NFS, SMB/CIFS, some FUSE, WSL1) raise
25
+ # ``locking protocol``; ZFS corrupts the -shm file under concurrent bursts (COW + mmap) -> ``disk I/O error``.
26
+ # Either would silently break everything on state.db/kanban.db, so fall back to DELETE (readers block on writes).
27
+ # "not authorized": some FUSE mounts block the WAL pragma outright.
28
+ _WAL_INCOMPAT_MARKERS = ("locking protocol", "not authorized", "disk i/o error")
29
+ # SQLite's default journal_size_limit is -1 (unlimited); see _apply_wal_size_limit.
30
+ _WAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024 # 64 MiB
31
+
32
+ # Once-per-process-per-db_label dedup sets (kanban_db.connect() runs on every kanban operation, so an undeduped
33
+ # line would repeat per connection). Tests clear these via ``hermes_state_wal.<name>``.
34
+ _wal_fallback_warned_paths: set[str] = set()
35
+ _wal_fallback_warned_lock = threading.Lock()
36
+
37
+ # Dedup for the both-pragmas-failed WARNING (WAL *and* DELETE rejected, e.g. APFS external SSDs under contention).
38
+ _wal_delete_fallback_failed_paths: set[str] = set()
39
+ _wal_delete_fallback_failed_lock = threading.Lock()
40
+
41
+ # Dedup for the probe-unknown WARNING (on-disk journal mode unreadable, nothing touched).
42
+ _wal_probe_unknown_paths: set[str] = set()
43
+ _wal_probe_unknown_lock = threading.Lock()
44
+ _wal_reset_bug_warned_paths: set[str] = set()
45
+ _wal_reset_bug_warned_lock = threading.Lock()
46
+ _delete_overridden_warned_paths: set[str] = set()
47
+ _delete_overridden_warned_lock = threading.Lock()
48
+ _journal_upgrade_warned_paths: set = set()
49
+ _journal_upgrade_warned_lock = threading.Lock()
50
+
51
+ _CANNOT_VERIFY_DELETE_MSG = ("could not verify journal mode before applying configured journal_mode=delete (database "
52
+ "is locked — possible concurrent openers); refusing to downgrade a database this process "
53
+ "does not exclusively own")
54
+
55
+
56
+ def _mode_from_row(row) -> str:
57
+ """Lower-cased mode from a ``PRAGMA journal_mode`` row, ``""`` if no row."""
58
+ return str(row[0]).strip().lower() if row and row[0] is not None else ""
59
+
60
+
61
+ def _on_disk_journal_mode(conn: sqlite3.Connection) -> Optional[str]:
62
+ """Read the journal mode from the DB header; ``None`` if undeterminable (new DB, or PRAGMA failed) ->
63
+ callers take their fail-closed "refuse to downgrade" branch. ``disk i/o error`` can be transient on
64
+ virtualized block devices (XFS on cloud hosts), so it is retried a few times first."""
65
+ for _ in range(4):
66
+ try:
67
+ row = conn.execute("PRAGMA journal_mode").fetchone()
68
+ except sqlite3.OperationalError as exc:
69
+ if "disk i/o error" not in str(exc).lower():
70
+ return None
71
+ last_exc = exc
72
+ time.sleep(0.05)
73
+ continue
74
+ mode = row[0] if row else None
75
+ if isinstance(mode, bytes): # defensive: sqlite3 occasionally returns bytes
76
+ try:
77
+ mode = mode.decode("ascii")
78
+ except UnicodeDecodeError:
79
+ return None
80
+ return str(mode).strip().lower() if mode is not None else None
81
+ logger.debug("_on_disk_journal_mode: retries exhausted on disk read (%s)", last_exc)
82
+ return None
83
+
84
+
85
+ def _apply_wal_size_limit(conn: sqlite3.Connection) -> None:
86
+ """Bound the WAL so it returns space after big transactions. With the default (-1) a checkpointed WAL is
87
+ reused in place, never truncated, so ``state.db-wal`` keeps the high-water mark of the largest
88
+ transaction ever (a 3 GB optimize left a 3 GB WAL). Best-effort: failure only costs disk slack."""
89
+ try:
90
+ conn.execute(f"PRAGMA journal_size_limit={_WAL_SIZE_LIMIT_BYTES}")
91
+ except sqlite3.OperationalError as exc: # pragma: no cover - defensive
92
+ logger.debug("journal_size_limit not applied: %s", exc)
93
+
94
+
95
+ def _darwin_pragma(conn: sqlite3.Connection, pragma: str) -> None:
96
+ """Best-effort PRAGMA on macOS only (no-op elsewhere, never raises)."""
97
+ if sys.platform == "darwin":
98
+ with contextlib.suppress(sqlite3.OperationalError):
99
+ conn.execute(pragma)
100
+
101
+
102
+ def _apply_macos_checkpoint_barrier(conn: sqlite3.Connection) -> None:
103
+ """Enable ``PRAGMA checkpoint_fullfsync`` on macOS. Apple's ``fsync(2)`` guarantees neither data-on-platter nor
104
+ ordering, so without ``F_FULLFSYNC`` a launchd shutdown can turn a "durable" checkpoint into a malformed
105
+ ``state.db``. Checkpoint boundaries only (~+0.1 ms/commit vs ~+4 ms for ``fullfsync=1``).
106
+
107
+ During a launchd *system* shutdown/reboot the OS page cache is dropped (effectively a power-loss event
108
+ for in-flight pages), so a WAL checkpoint whose ``fsync()`` "reported" durable may never have hit the
109
+ platter — corrupting ``state.db`` with a malformed image. This is the trigger in issue #30636 ("SIGTERM
110
+ during launchd shutdown under high load"), distinct from a plain in-session kill (which the page cache
111
+ survives and SQLite recovers from).
112
+ """
113
+ _darwin_pragma(conn, "PRAGMA checkpoint_fullfsync=1")
114
+
115
+
116
+ def _enforce_macos_synchronous_full(conn: sqlite3.Connection) -> None:
117
+ """Enforce ``PRAGMA synchronous=FULL`` on macOS: with NORMAL a WAL checkpoint
118
+ racing process termination leaves half-written btree pages (``btreeInitPage
119
+ error 11``). Called after every WAL activation so a prior NORMAL never sticks."""
120
+ _darwin_pragma(conn, "PRAGMA synchronous=FULL")
121
+
122
+
123
+ def _apply_wal_companions(conn: sqlite3.Connection) -> None:
124
+ """The settings every WAL activation carries: size limit + macOS barriers."""
125
+ _apply_wal_size_limit(conn)
126
+ _apply_macos_checkpoint_barrier(conn)
127
+ _enforce_macos_synchronous_full(conn)
128
+
129
+
130
+ def is_sqlite_wal_reset_vulnerable(version_info: Optional[tuple] = None) -> bool:
131
+ """True when the linked SQLite has the WAL-reset bug (3.7.0–3.51.2; fixed 3.51.3+, backports 3.50.7 /
132
+ 3.44.6). Pre-WAL libraries are safe. https://sqlite.org/wal.html#walresetbug"""
133
+ return _is_sqlite_wal_reset_vulnerable(sqlite3.sqlite_version_info if version_info is None else version_info)
134
+
135
+
136
+ def sqlite_source_id() -> str:
137
+ """Return ``sqlite_source_id()``, or an empty string when unavailable."""
138
+ try:
139
+ with contextlib.closing(sqlite3.connect(":memory:")) as conn:
140
+ row = conn.execute("SELECT sqlite_source_id()").fetchone()
141
+ except sqlite3.Error:
142
+ return ""
143
+ return str(row[0]) if row and row[0] is not None else ""
144
+
145
+
146
+ def _database_has_content(conn: sqlite3.Connection) -> bool:
147
+ """Whether the file already holds pages (existing vs brand-new DB); lock-free header read. Fail-quiet False: the
148
+ only caller gates a warning on this and an unknown-answer warning would fire on every fresh database."""
149
+ try:
150
+ row = conn.execute("PRAGMA page_count").fetchone()
151
+ return bool(row) and row[0] is not None and int(row[0]) > 0
152
+ except (sqlite3.Error, TypeError, ValueError):
153
+ return False
154
+
155
+
156
+ def resolve_journal_mode() -> str:
157
+ """The configured ``database.journal_mode`` (``wal`` default; ``delete`` for filesystems without WAL-safe
158
+ durability: macOS virtiofs, NFS, SMB). Invalid values fail safe to ``wal``."""
159
+ try:
160
+ from hermes_cli.config import load_config_readonly
161
+ database = (load_config_readonly() or {}).get("database", {})
162
+ raw = database.get("journal_mode", "wal") if isinstance(database, dict) else "wal"
163
+ except Exception:
164
+ return "wal"
165
+ mode = raw.strip().lower() if isinstance(raw, str) else ""
166
+ return mode if mode in ("wal", "delete") else "wal"
167
+
168
+
169
+ class WalUnsupportedError(sqlite3.OperationalError):
170
+ """Raised by :func:`apply_wal_with_fallback` under ``require_wal=True`` when
171
+ the filesystem cannot provide WAL (SQLITE_PROTOCOL raised, or macOS-NFS silent
172
+ refusal) or the on-disk mode cannot be verified (probe blocked by a concurrent
173
+ opener). Subclasses ``OperationalError`` so DB-init handlers still catch it."""
174
+
175
+
176
+ # Docker Desktop / OrbStack / Podman expose host bind mounts to the guest as ``fuse.virtiofs`` or ``9p``. WAL's
177
+ # -shm file must be coherent shared memory for every opener; across the VM boundary it is not, and under write
178
+ # pressure the failure is SILENT (zero-filled pages), so the reactive ``_WAL_INCOMPAT_MARKERS`` fallback in
179
+ # ``_enable_wal`` never gets a signal — WAL must be refused before the pragma. Port of openclaw/openclaw#120597.
180
+ # Detection is Linux ``/proc/self/mountinfo`` only (statvfs carries no fstype); elsewhere, or when the table is
181
+ # unreadable, the answer is False and behaviour is unchanged. Nothing else (ext4/btrfs/xfs/zfs/tmpfs/overlay/nfs)
182
+ # is ever flagged here — those keep the existing reactive paths.
183
+ _CROSS_VM_FSTYPES = frozenset({"virtiofs", "fuse.virtiofs", "9p", "9p2000", "9p2000.l", "9p2000.u"})
184
+ _cross_vm_fs_cache: Dict[str, bool] = {} # per DB directory; kanban_db.connect() opens per operation
185
+ _cross_vm_fs_cache_lock = threading.Lock()
186
+ _cross_vm_warned_paths: set[str] = set()
187
+ _cross_vm_warned_lock = threading.Lock()
188
+
189
+
190
+ def _mountinfo_fstype(directory: str, mountinfo_path: str = "/proc/self/mountinfo") -> str:
191
+ """fstype of the longest mount point that is a prefix of ``directory`` (``""`` if unreadable / no match)."""
192
+ try:
193
+ with open(mountinfo_path, "r", encoding="utf-8", errors="replace") as fh:
194
+ lines = fh.read().splitlines()
195
+ except OSError:
196
+ return ""
197
+ best_len, best_fstype = -1, ""
198
+ for line in lines:
199
+ # proc(5): <id> <parent> <maj:min> <root> <mount point> <opts> [optional...] - <fstype> <source> <super opts>
200
+ fields, _, tail = line.partition(" - ")
201
+ parts = fields.split()
202
+ if len(parts) < 5 or not tail:
203
+ continue
204
+ mount_point = parts[4]
205
+ if "\\" in mount_point: # octal escapes (\040 = space)
206
+ mount_point = mount_point.encode("latin-1", "ignore").decode("unicode_escape")
207
+ if directory == mount_point or directory.startswith(mount_point.rstrip("/") + "/"):
208
+ if len(mount_point) > best_len:
209
+ best_len, best_fstype = len(mount_point), tail.split()[0]
210
+ return best_fstype.lower()
211
+
212
+
213
+ def _detect_cross_vm_fs(directory: str, mountinfo_path: str = "/proc/self/mountinfo") -> bool:
214
+ """True only when ``directory`` sits on a virtiofs/9p mount per ``mountinfo_path``."""
215
+ if sys.platform != "linux":
216
+ return False
217
+ return _mountinfo_fstype(directory, mountinfo_path) in _CROSS_VM_FSTYPES
218
+
219
+
220
+ def _path_on_cross_vm_fs(path: str) -> bool:
221
+ """True when ``path`` resides on a virtiofs/9p (cross-VM) filesystem; cached per resolved directory."""
222
+ try:
223
+ directory = os.path.dirname(os.path.realpath(path)) or "/"
224
+ except (OSError, ValueError):
225
+ return False
226
+ with _cross_vm_fs_cache_lock:
227
+ cached = _cross_vm_fs_cache.get(directory)
228
+ if cached is None:
229
+ cached = _detect_cross_vm_fs(directory)
230
+ with _cross_vm_fs_cache_lock:
231
+ _cross_vm_fs_cache[directory] = cached
232
+ return cached
233
+
234
+
235
+ def _connection_db_file(conn: sqlite3.Connection) -> str:
236
+ """Filesystem path of the ``main`` database, ``""`` for in-memory / unknown."""
237
+ try:
238
+ row = conn.execute("PRAGMA database_list").fetchone()
239
+ return str(row[2]) if row and row[2] else ""
240
+ except (sqlite3.OperationalError, IndexError, TypeError):
241
+ return ""
242
+
243
+
244
+ def _verify_configured_delete(actual: str) -> str:
245
+ """Raise unless SQLite reported ``delete`` for an explicit operator request."""
246
+ if actual != "delete":
247
+ raise sqlite3.OperationalError(f"could not set configured journal_mode=delete (got {actual or 'no result'})")
248
+ return actual
249
+
250
+
251
+ def apply_wal_with_fallback(conn: sqlite3.Connection, *, db_label: str = "state.db", require_wal: bool = False) -> str:
252
+ """Set ``journal_mode=WAL`` on ``conn``, falling back to DELETE on failure.
253
+
254
+ Returns the mode actually set — or, when the read-only mode probe is blocked by a concurrent opener, ``"wal"``
255
+ as the assumed mode with nothing touched (``require_wal=True`` raises instead). Shared by :class:`SessionDB`
256
+ and ``hermes_cli.kanban_db_connect.connect``. WAL-incompatible filesystems either raise ``OperationalError`` ("locking protocol" / "disk I/O error") or —
257
+ macOS NFS / SMB / AgentFS — silently refuse and stay in DELETE; either way log ERROR once per process per
258
+ ``db_label`` and fall back. ``require_wal=True`` raises :class:`WalUnsupportedError` instead. WAL-reset-bug
259
+ builds (https://sqlite.org/wal.html#walresetbug) never enable WAL on non-WAL files; an already-WAL DB keeps WAL
260
+ with a warning. Gate deliberately RETAINED: re-measured on the bundled 3.50.4 there is no evidence WAL is safer.
261
+
262
+ Invariant on every path: never downgrade to DELETE if the on-disk header reports WAL or cannot be read — other
263
+ gateway/cron/worker connections may hold the DB open, and a live downgrade destroys their uncheckpointed
264
+ commits.
265
+
266
+ An earlier revision of the lock-cancellation fix (#71724) reverted it on the theory that DELETE was "the
267
+ mode that corrupts", but that comparison was confounded: the clean WAL result came from SQLite 3.53.1,
268
+ which carries BOTH the WAL-reset fix AND 3.51.0's defenses against close()-broken POSIX locks, so it
269
+ says nothing about 3.50.4. Re-measured on the actually-bundled 3.50.4 with the lock fix in place, WAL
270
+ and DELETE are both clean (0/3 each) — i.e. there is no evidence that WAL is safer here, and upstream
271
+ still documents the WAL-reset bug as real through 3.51.2 with serious consequences. Until a fixed
272
+ runtime is delivered, keep new databases out of WAL.
273
+ """
274
+ configured = resolve_journal_mode()
275
+
276
+ # Vulnerable SQLite: never enable WAL on non-WAL files (configured mode resolved first so an explicit DELETE
277
+ # request is still verified).
278
+ if is_sqlite_wal_reset_vulnerable():
279
+ return _apply_delete_for_wal_reset_bug(conn, db_label=db_label, require_delete=configured == "delete")
280
+ # Read-only probe (no flock/checkpoint/WAL-SHM unlink): WAL-init must not unlink files other connections hold.
281
+ current_mode = _on_disk_journal_mode(conn)
282
+ if current_mode == "wal":
283
+ if configured == "delete":
284
+ # Never-live-downgrade keeps WAL; tell the operator their delete did not apply.
285
+ _log_configured_delete_overridden_once(db_label)
286
+ _apply_wal_companions(conn)
287
+ return "wal"
288
+
289
+ if configured == "delete":
290
+ # #68545: honor the canonical database.journal_mode setting. Existing on-disk WAL databases were
291
+ # returned above and are never live-downgraded.
292
+ if current_mode is None:
293
+ # Probe failed (locked/busy): ownership not provably exclusive. Fail loudly.
294
+ raise sqlite3.OperationalError(_CANNOT_VERIFY_DELETE_MSG)
295
+ return _verify_configured_delete(_set_journal_mode_no_wait(conn, "DELETE"))
296
+ if current_mode is None:
297
+ # Probe failed (locked/busy): ownership not provably exclusive, same as the DELETE branch above. A fresh
298
+ # 0-page DB probes "delete" cleanly, so this is a real file some other connection holds — running WAL-init
299
+ # would unlink its -wal/-shm sidecars. Touch nothing: the connection inherits whatever mode the header has.
300
+ if require_wal:
301
+ raise WalUnsupportedError("could not verify the on-disk journal mode (database is locked — possible "
302
+ "concurrent openers); cannot guarantee WAL")
303
+ _log_once("wal_probe_unknown", db_label)
304
+ return "wal"
305
+ # Cross-VM bind mount (virtiofs/9p): WAL corrupts silently there, so refuse to ENABLE it. On-disk WAL
306
+ # databases were returned above (never live-downgrade); a 0-page / DELETE file just stays DELETE.
307
+ db_file = _connection_db_file(conn)
308
+ if db_file and _path_on_cross_vm_fs(db_file):
309
+ if require_wal:
310
+ raise WalUnsupportedError("journal_mode=WAL refused: database is on a cross-VM filesystem (virtiofs/9p) "
311
+ "where WAL shared-memory silently corrupts")
312
+ _log_once("cross_vm_fs", db_label)
313
+ return _set_journal_mode_no_wait(conn, "DELETE") or "delete"
314
+ return _enable_wal(conn, db_label, require_wal, current_mode)
315
+
316
+
317
+ def _enable_wal(conn: sqlite3.Connection, db_label: str, require_wal: bool, current_mode: Optional[str]) -> str:
318
+ """Flip a non-WAL, non-vulnerable connection to WAL, or fall back to DELETE."""
319
+ # Decide BEFORE the flip whether it overwrites a mode somebody chose (probe and page_count are only readable
320
+ # while the file is untouched). A 0-page DB has no prior choice, and every caller reaches this before schema.
321
+ upgrading_existing_db = current_mode is not None and current_mode != "wal" and _database_has_content(conn)
322
+
323
+ def _wal_activated() -> str:
324
+ if upgrading_existing_db:
325
+ _log_journal_mode_upgrade_once(db_label, current_mode)
326
+ _apply_wal_companions(conn)
327
+ return "wal"
328
+
329
+ try:
330
+ # ``PRAGMA journal_mode=WAL`` RETURNS the resulting mode: macOS NFS, SMB/CIFS and the AgentFS overlay
331
+ # refuse WITHOUT raising. Trust the row, not the absence of an exception.
332
+ mode = _mode_from_row(conn.execute("PRAGMA journal_mode=WAL").fetchone())
333
+ if mode == "wal":
334
+ return _wal_activated()
335
+ silent_exc = WalUnsupportedError(f"journal_mode=WAL refused without raising (still {mode!r})")
336
+ if require_wal:
337
+ raise silent_exc
338
+ _log_wal_fallback_once(db_label, silent_exc)
339
+ return mode or "delete"
340
+ except WalUnsupportedError:
341
+ raise # the require_wal silent-refusal raise above — propagate unchanged
342
+ except sqlite3.OperationalError as exc:
343
+ msg = str(exc).lower()
344
+ if not any(marker in msg for marker in _WAL_INCOMPAT_MARKERS):
345
+ raise # unrelated OperationalError — don't silently swallow
346
+ # ``disk i/o error`` is ambiguous: on ZFS / APFS-CoW it is a deterministic WAL-incompatibility (SHM
347
+ # corruption under concurrent connection bursts — #55305, #71498), but it can also be a one-shot
348
+ # transient EIO (page-cache pressure, brief lock contention). Treating a transient EIO as a
349
+ # permanent downgrade signal produced the mixed-journal-mode corruption pattern fixed in 5c49cd0ed0
350
+ # (process A downgrades to DELETE while sibling processes set WAL). Disambiguate by retrying the
351
+ # pragma a couple of times: transient EIO clears and we return "wal"; the deterministic filesystem
352
+ # cases keep failing and fall through to the guarded DELETE fallback.
353
+ if "disk i/o error" in msg:
354
+ # Retry twice: EIO is either deterministic WAL-incompatibility (ZFS / APFS-CoW) or a one-shot transient,
355
+ # and treating a transient as a permanent downgrade produced mixed-mode corruption (A downgrades to
356
+ # DELETE while siblings set WAL). A non-EIO retry error propagates.
357
+ for _ in range(2):
358
+ time.sleep(0.05)
359
+ try:
360
+ row = conn.execute("PRAGMA journal_mode=WAL").fetchone()
361
+ except sqlite3.OperationalError as retry_exc:
362
+ if "disk i/o error" not in str(retry_exc).lower():
363
+ raise
364
+ exc = retry_exc
365
+ continue
366
+ if _mode_from_row(row) == "wal":
367
+ return _wal_activated()
368
+ break
369
+ # Never downgrade if WAL is on disk or the mode cannot be read (probe blocked by a concurrent opener) —
370
+ # ownership is not provably exclusive either way.
371
+ if _on_disk_journal_mode(conn) in ("wal", None):
372
+ raise
373
+ if require_wal:
374
+ raise WalUnsupportedError(str(exc)) from exc
375
+ _log_wal_fallback_once(db_label, exc)
376
+ try:
377
+ _set_journal_mode_no_wait(conn, "DELETE")
378
+ except sqlite3.OperationalError as delete_exc:
379
+ # Filesystems that reject BOTH pragmas (APFS external SSDs under heavy contention raise
380
+ # "disk I/O error" on DELETE too). The connection keeps its current mode — typically the
381
+ # SQLite default DELETE — and stays usable for reads/writes, so propagating would crash
382
+ # every DB-init caller (SessionDB, kanban_db, ResponseStore) for no benefit. Log once
383
+ # per db_label and return the mode actually in effect, read back rather than guessed.
384
+ _log_once("wal_delete_fallback_failed", db_label, exc, delete_exc)
385
+ try:
386
+ read_back = _mode_from_row(conn.execute("PRAGMA journal_mode").fetchone())
387
+ except sqlite3.OperationalError:
388
+ read_back = ""
389
+ return read_back or "delete"
390
+ return "delete"
391
+
392
+
393
+ def _set_journal_mode_no_wait(conn: sqlite3.Connection, mode: str) -> str:
394
+ """Execute ``PRAGMA journal_mode=<mode>`` without waiting on other openers.
395
+
396
+ The ONLY place a non-WAL journal-mode switch may be issued. ``busy_timeout=0`` turns SQLite's exclusivity
397
+ requirement into a concurrent-opener detector: leaving WAL needs exclusive access, so if ANY other connection
398
+ holds the DB the pragma fails immediately with ``database is locked`` instead of sneaking the flip between a
399
+ writer's transactions (how uncheckpointed WAL commits die). Callers must treat a raised ``OperationalError`` as
400
+ "not exclusively owned: leave the mode alone", never as retryable. Returns the reported mode, ``""`` if no row."""
401
+ try:
402
+ row = conn.execute("PRAGMA busy_timeout").fetchone()
403
+ previous_timeout = int(row[0]) if row and row[0] is not None else 0
404
+ except (sqlite3.OperationalError, TypeError, ValueError):
405
+ previous_timeout = 0
406
+ conn.execute("PRAGMA busy_timeout=0")
407
+ try:
408
+ return _mode_from_row(conn.execute(f"PRAGMA journal_mode={mode}").fetchone())
409
+ finally:
410
+ with contextlib.suppress(sqlite3.OperationalError):
411
+ conn.execute(f"PRAGMA busy_timeout={previous_timeout}")
412
+
413
+
414
+ def _apply_delete_for_wal_reset_bug(conn: sqlite3.Connection, *, db_label: str, require_delete: bool = False) -> str:
415
+ """Avoid enabling WAL when the linked SQLite has the WAL-reset bug.
416
+
417
+ Already-WAL on disk: keep WAL (no live downgrade) and warn. Mode unreadable (probe blocked by a concurrent
418
+ opener): not provably exclusive — leave it and warn; treating "could not read" as "not WAL" once flipped a live
419
+ WAL state.db to DELETE under a writer, destroying its uncheckpointed commits. Otherwise set DELETE without
420
+ waiting out openers and warn; an explicit operator request additionally verifies SQLite accepted DELETE."""
421
+ current = _on_disk_journal_mode(conn)
422
+ if current == "wal":
423
+ _log_wal_reset_bug_once(db_label, kept_wal=True)
424
+ if require_delete:
425
+ # Upgrading SQLite doesn't help here; emit the actionable message last.
426
+ _log_configured_delete_overridden_once(db_label)
427
+ _apply_wal_companions(conn)
428
+ return "wal"
429
+ if current is None:
430
+ if require_delete:
431
+ raise sqlite3.OperationalError(_CANNOT_VERIFY_DELETE_MSG)
432
+ _log_wal_reset_bug_once(db_label, kept_wal=True, indeterminate=True)
433
+ return "wal"
434
+ actual = ""
435
+ try:
436
+ actual = _set_journal_mode_no_wait(conn, "DELETE")
437
+ except sqlite3.OperationalError as exc:
438
+ if require_delete:
439
+ raise
440
+ if "locked" in str(exc).lower() or "busy" in str(exc).lower():
441
+ # A concurrent opener appeared between probe and flip: leave the mode as is.
442
+ _log_wal_reset_bug_once(db_label, kept_wal=True, indeterminate=True)
443
+ return current or "delete"
444
+ # Best-effort otherwise: DELETE is already the default for new file-backed DBs.
445
+ if require_delete:
446
+ _verify_configured_delete(actual)
447
+ _log_wal_reset_bug_once(db_label, kept_wal=False)
448
+ return "delete"
449
+
450
+
451
+ def _wal_reset_repair_hint() -> str:
452
+ """Repair hint matching what ``hermes update`` can actually do for this install type.
453
+
454
+ See #75153.
455
+ """
456
+ try:
457
+ from hermes_cli.config import detect_install_method, get_project_root, recommended_update_command_for_method
458
+ method = detect_install_method(get_project_root())
459
+ cmd = recommended_update_command_for_method(method)
460
+ if method in {"git", "unknown"}:
461
+ return f"Hermes-managed installs can repair the embedded runtime with `{cmd}`"
462
+ return f"update the container image with `{cmd}`" if method == "docker" else cmd # else nix/nixos
463
+ except Exception:
464
+ return "install a Python build bundled with SQLite 3.51.3+ (or backports 3.50.7 / 3.44.6) and restart Hermes"
465
+
466
+
467
+ # Once-per-(process, db_label) log table. Levels are deliberate: falling back to DELETE and an ignored
468
+ # ``journal_mode: delete`` are real losses (ERROR); a non-WAL -> WAL flip is normally desirable (WARNING).
469
+ _WAL_RESET_BUG_ACTIONS = {
470
+ "indeterminate": ("journal mode could not be verified or exclusively switched (database is locked — possible "
471
+ "concurrent openers); leaving the journal mode untouched (no live downgrade under concurrent "
472
+ "openers)"),
473
+ "kept_wal": "is already in WAL mode — leaving WAL in place (no live downgrade under concurrent openers)",
474
+ "delete": "using journal_mode=DELETE instead of enabling WAL",
475
+ }
476
+ _ONCE_LOGS = {
477
+ "wal_reset_bug": (_wal_reset_bug_warned_lock, "_wal_reset_bug_warned_paths", logging.WARNING,
478
+ # Install-type-aware so the warning never promises a repair path that doesn't exist for git/pip installs.
479
+ "%s: linked SQLite %s (interpreter %s) is vulnerable to the WAL-reset corruption bug "
480
+ "(https://sqlite.org/wal.html#walresetbug) — %s. Upgrade to SQLite 3.51.3+ (or backports 3.50.7 / 3.44.6); "
481
+ "%s. See `hermes doctor`. This warning fires once per process per database."),
482
+ "journal_upgrade": (_journal_upgrade_warned_lock, "_journal_upgrade_warned_paths", logging.WARNING,
483
+ # journal_mode is a property of the FILE: switching an existing DB to WAL rewrites its header and outlives
484
+ # the process. Operators set DELETE on the file (the WAL-reset-bug mitigation) and nothing told them the
485
+ # next open would silently put WAL back.
486
+ "%s: on-disk journal_mode was %s and has been switched to WAL. This rewrites the database header and "
487
+ "persists after this process exits. If %s was a deliberate choice (for example the mitigation for the SQLite "
488
+ "WAL-reset bug, or a WAL-unsafe filesystem), setting it with PRAGMA on the file will not survive -- every "
489
+ "open re-applies the configured mode. Set `database.journal_mode: delete` in config.yaml to make it stick. "
490
+ "This message fires once per process per database."),
491
+ "wal_fallback": (_wal_fallback_warned_lock, "_wal_fallback_warned_paths", logging.ERROR,
492
+ # Under kanban dispatcher + workers a DELETE-mode write blocks readers as SQLITE_BUSY.
493
+ "%s: WAL journal_mode unsupported on this filesystem (%s) — falling back to journal_mode=DELETE (slower "
494
+ "rollback-journal mode; reduces concurrency but works on NFS/SMB/FUSE/ZFS). See "
495
+ "https://www.sqlite.org/wal.html for details. This message fires once per process per database."),
496
+ "wal_delete_fallback_failed": (_wal_delete_fallback_failed_lock, "_wal_delete_fallback_failed_paths", logging.WARNING,
497
+ # Both pragmas rejected (observed on APFS external SSDs under heavy contention): the connection keeps
498
+ # whatever mode it has (typically the SQLite default DELETE) and stays usable for reads/writes, so this
499
+ # is WARNING, not ERROR — propagating would crash every DB-init caller. Deduped per db_label because
500
+ # kanban_db.connect() runs on every kanban operation.
501
+ "%s: both WAL and DELETE journal_mode failed (WAL: %s; DELETE: %s) — continuing with the connection's "
502
+ "current journal mode (reads/writes still work). Typically seen on APFS external SSDs under heavy "
503
+ "contention. This message fires once per process per database."),
504
+ "delete_overridden": (_delete_overridden_warned_lock, "_delete_overridden_warned_paths", logging.ERROR,
505
+ # Never-live-downgrade keeps WAL; without this the operator never learns their delete had no effect.
506
+ "%s: database.journal_mode=delete is configured but the on-disk database is already WAL; keeping WAL (a live "
507
+ "downgrade under open connections can corrupt the DB). To apply journal_mode=DELETE, stop all connections to "
508
+ "this DB and run a one-time offline 'PRAGMA journal_mode=DELETE' on the file. This message fires once per "
509
+ "process per database."),
510
+ "wal_probe_unknown": (_wal_probe_unknown_lock, "_wal_probe_unknown_paths", logging.WARNING,
511
+ # WARNING, not ERROR: the connection inherits the header's mode, so an already-WAL file (the common case)
512
+ # keeps working; only a true-DELETE file stays DELETE for this connection.
513
+ "%s: could not verify the on-disk journal mode (database is locked / busy); not issuing a journal-mode "
514
+ "set-pragma while another connection may hold the file (it could unlink the -wal/-shm sidecars that "
515
+ "connection still uses). Leaving the file untouched; this connection inherits the on-disk mode. This message fires once per process per database."),
516
+ "cross_vm_fs": (_cross_vm_warned_lock, "_cross_vm_warned_paths", logging.WARNING,
517
+ # The DB keeps working in DELETE mode; the loss is concurrency, so WARNING with the actionable fix.
518
+ "%s: database directory is on a cross-VM filesystem (virtiofs/9p — typical for Docker Desktop / OrbStack / "
519
+ "Podman host bind mounts). SQLite WAL shared-memory is not coherent across the VM boundary and can silently "
520
+ "corrupt the database, so journal_mode=DELETE is used instead. To restore WAL concurrency, move the database "
521
+ "onto a native volume (e.g. a named Docker volume) instead of a host bind mount. This message fires once per "
522
+ "process per database."),
523
+ }
524
+
525
+
526
+ def _log_once(kind: str, db_label: str, *args: Any) -> None:
527
+ """Emit ``_ONCE_LOGS[kind]`` once per (process, db_label). Callable *args* are
528
+ resolved only after the dedupe check, so install-method probes run once."""
529
+ lock, set_name, level, message = _ONCE_LOGS[kind]
530
+ seen = globals()[set_name]
531
+ with lock:
532
+ if db_label in seen:
533
+ return
534
+ seen.add(db_label)
535
+ logger.log(level, message, db_label, *(a() if callable(a) else a for a in args))
536
+
537
+
538
+ def _log_wal_reset_bug_once(db_label: str, *, kept_wal: bool, indeterminate: bool = False) -> None:
539
+ """Log once per (process, db_label) about the WAL-reset vulnerability path."""
540
+ action = _WAL_RESET_BUG_ACTIONS["indeterminate" if indeterminate else "kept_wal" if kept_wal else "delete"]
541
+ _log_once("wal_reset_bug", db_label, sqlite3.sqlite_version, sys.executable, action, _wal_reset_repair_hint)
542
+
543
+
544
+ def _log_journal_mode_upgrade_once(db_label: str, previous_mode: str) -> None:
545
+ """Single WARNING per (process, db_label) about a non-WAL -> WAL flip."""
546
+ _log_once("journal_upgrade", db_label, previous_mode, previous_mode)
547
+
548
+
549
+ # Single ERROR per (process, db_label): WAL fallback / configured delete ignored (DB already WAL).
550
+ _log_wal_fallback_once = functools.partial(_log_once, "wal_fallback")
551
+ _log_configured_delete_overridden_once = functools.partial(_log_once, "delete_overridden")
552
+
553
+
554
+ # Operators write synchronous as a name; mapped so a typo becomes a warning, not a silently different level.
555
+ _SYNCHRONOUS_LEVELS: Dict[str, int] = {"OFF": 0, "NORMAL": 1, "FULL": 2, "EXTRA": 3}
556
+ _SYNCHRONOUS_NAMES: Dict[int, str] = {v: k for k, v in _SYNCHRONOUS_LEVELS.items()}
557
+ _SYNCHRONOUS_FULL = 2
558
+
559
+
560
+ def resolve_synchronous_level(raw_value: Any) -> Optional[int]:
561
+ """Map ``database.synchronous`` (``OFF``/``NORMAL``/``FULL``/``EXTRA`` any
562
+ case, or ``0``-``3``) to its PRAGMA integer; None for anything else so the
563
+ caller warns and leaves the level untouched (guessing at durability is worse)."""
564
+ if isinstance(raw_value, bool):
565
+ # bool is an int subclass and YAML turns bare `on`/`off` into one; "off" is a real choice, True meaningless.
566
+ return 0 if raw_value is False else None
567
+ if isinstance(raw_value, int):
568
+ return raw_value if raw_value in _SYNCHRONOUS_NAMES else None
569
+ text = str(raw_value).strip()
570
+ if text.upper() in _SYNCHRONOUS_LEVELS:
571
+ return _SYNCHRONOUS_LEVELS[text.upper()]
572
+ with contextlib.suppress(TypeError, ValueError):
573
+ return int(text) if int(text) in _SYNCHRONOUS_NAMES else None
574
+ return None
575
+
576
+
577
+ def _apply_synchronous_pragma(conn: sqlite3.Connection, raw_value: Any, *, db_label: str) -> None:
578
+ """Set ``PRAGMA synchronous`` from config, never below FULL on macOS.
579
+
580
+ Kept out of the integer loop in :func:`apply_database_pragmas`: this PRAGMA decides whether a commit is on
581
+ the platter, so an unrecognised value must not fall through to "SQLite default" the way a bad
582
+ ``cache_size`` can. Darwin floor: this runs after :func:`_enforce_macos_synchronous_full`, so a configured
583
+ ``NORMAL`` would silently undo the btree protection — raising is allowed, lowering is refused out loud."""
584
+ if (level := resolve_synchronous_level(raw_value)) is None:
585
+ logger.warning("%s: ignoring unrecognized database.synchronous=%r (expected OFF, NORMAL, FULL, EXTRA, or 0-3)",
586
+ db_label, raw_value)
587
+ return
588
+ if sys.platform == "darwin" and level < _SYNCHRONOUS_FULL:
589
+ logger.warning("%s: refusing database.synchronous=%s on macOS; keeping FULL. Darwin's fsync() does not "
590
+ "guarantee write ordering, so a lower level readmits the half-written btree pages FULL exists "
591
+ "to prevent.", db_label, _SYNCHRONOUS_NAMES[level])
592
+ return
593
+ with contextlib.suppress(sqlite3.OperationalError):
594
+ conn.execute(f"PRAGMA synchronous={level}")
595
+
596
+
597
+ def apply_database_pragmas(conn: sqlite3.Connection, *, db_label: str = "state.db") -> None:
598
+ """Apply optional performance and WAL-sizing PRAGMAs from ``config.yaml``.
599
+
600
+ Journal mode is NOT handled here (owned by :func:`apply_wal_with_fallback`). ``database:`` keys: ``cache_size``
601
+ (negative = KiB, positive = pages), ``mmap_size`` (bytes, 0 = off), ``temp_store`` (0-3), ``wal_autocheckpoint``
602
+ (pages), ``journal_size_limit`` (bytes), ``synchronous`` (unset leaves the compile-time default, which differs
603
+ between bundled/distro/Homebrew builds). Best-effort: failures are ignored so DB init never breaks on a
604
+ malformed section. Applied to ALL connection types: writer, read_only, WAL readers."""
605
+ try:
606
+ from hermes_cli.config import cfg_get, load_config_readonly # local: avoids a circular import
607
+ cfg = load_config_readonly()
608
+ except Exception:
609
+ return
610
+ for pragma_name in ("cache_size", "mmap_size", "temp_store", "wal_autocheckpoint", "journal_size_limit"):
611
+ if (raw_value := cfg_get(cfg, "database", pragma_name, default=None)) is None:
612
+ continue
613
+ try:
614
+ value = int(str(raw_value).strip())
615
+ except (TypeError, ValueError):
616
+ logger.warning("%s: ignoring non-integer database.%s=%r", db_label, pragma_name, raw_value)
617
+ continue
618
+ with contextlib.suppress(sqlite3.OperationalError):
619
+ conn.execute(f"PRAGMA {pragma_name}={value}")
620
+ # Last: sizing pragmas cannot change durability (see _apply_synchronous_pragma).
621
+ if (raw_synchronous := cfg_get(cfg, "database", "synchronous", default=None)) is not None:
622
+ _apply_synchronous_pragma(conn, raw_synchronous, db_label=db_label)
optional-skills/DESCRIPTION.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Optional Skills
2
+
3
+ Official skills maintained by Nous Research that are **not activated by default**.
4
+
5
+ These skills ship with the hermes-agent repository but are not copied to
6
+ `~/.hermes/skills/` during setup. They are discoverable via the Skills Hub:
7
+
8
+ ```bash
9
+ hermes skills browse # browse all skills, official shown first
10
+ hermes skills browse --source official # browse only official optional skills
11
+ hermes skills search <query> # finds optional skills labeled "official"
12
+ hermes skills install <identifier> # copies to ~/.hermes/skills/ and activates
13
+ ```
14
+
15
+ ## Why optional?
16
+
17
+ Some skills are useful but not broadly needed by every user:
18
+
19
+ - **Niche integrations** — specific paid services, specialized tools
20
+ - **Experimental features** — promising but not yet proven
21
+ - **Heavyweight dependencies** — require significant setup (API keys, installs)
22
+
23
+ By keeping them optional, we keep the default skill set lean while still
24
+ providing curated, tested, official skills for users who want them.