Merge origin/main into bb/gui (2026-05-24)

Bring 313 commits of upstream main into the bb/gui dashboard
refactor branch.  Eight conflicts resolved by hand, the rest
auto-merged.  One missing class (_StreamErrorEvent) restored from
main after the auto-merger dropped it.

Conflict resolutions:

  apps/dashboard/README.md          take HEAD: main's text described
                                    the pre-rename web/ layout that
                                    bb/gui refactored away.

  apps/dashboard/package.json       combine: keep HEAD's @hermes/shared
                                    workspace dep, take main's
                                    @nous-research/ui 0.16.0 bump.

  apps/dashboard/package-lock.json  regenerate via
                                    npm install --package-lock-only.
                                    Root lock also regenerated; only
                                    dashboard and apps/desktop entries
                                    moved (apps/desktop version 0.0.1 →
                                    0.0.2 to match bb/gui's
                                    package.json bump).

  apps/dashboard/src/pages/         take main (4 hunks): text-xs
    EnvPage.tsx                     replaces text-[0.65rem] per the
                                    typography rule HEAD's own README
                                    documents.

  hermes_cli/gateway.py             take main (2 hunks): Discord
                                    setup metadata moved to plugin
                                    (architectural migration); s6
                                    service-manager dispatch helpers
                                    additive.

  hermes_cli/main.py                combine (2 hunks): take main's
                                    Termux-aware
                                    _sync_bundled_skills_for_startup;
                                    combine gui + portal subcommands
                                    in the known-subcommand list.

  hermes_cli/web_server.py          mixed (10 hunks):
                                    - take main on _PUBLIC_API_PATHS
                                      (bb/gui's own test asserts the
                                      rescan endpoint must require auth)
                                    - combine WS helpers: keep HEAD's
                                      _ws_client_label + main's
                                      Host/Origin guard + composing
                                      _ws_request_is_allowed
                                    - take HEAD's debug-level broadcast
                                      drop log (matches the comment
                                      "subscriber went away mid-send")
                                    - take main's _safe_plugin_api_relpath
                                      GHSA-5qr3-c538-wm9j fix and the
                                      paired discovery-time validation
                                    - take main's {name:path} route
                                      converter for plugin visibility

  tui_gateway/server.py             take main: PR #31379's verbose-
                                    args gating supersedes HEAD's
                                    unconditional args dump on
                                    tool.start.

Post-merge restoration:

  run_agent.py                      restored class _StreamErrorEvent
                                    (40 lines, from origin/main:288).
                                    Auto-merge silently dropped it,
                                    breaking imports in
                                    agent/codex_runtime.py and three
                                    test files
                                    (test_codex_xai_oauth_recovery.py,
                                    test_streaming.py).  Restored
                                    verbatim from main.

Sanity checks:

  * git diff --check / --cached --check: clean (no stray markers)
  * ast.parse + import on all touched .py files: clean
  * targeted pytest on resolved files: 756 passed, 1 pre-existing
    Windows-curses failure unrelated to the merge
  * full pytest_parallel run: 105 files / 391 failures vs baseline
    98 files / 346.  Differential vs origin/bb/gui shows all 11
    "new" failure files come from main's added tests/code and
    reproduce identically against origin/main on the same Windows
    host (pure Windows path-separator / perms / git-bash issues
    in upstream tests, not merge regressions).  4 baseline
    failures fixed: 3 in test_codex_xai_oauth_recovery (the
    _StreamErrorEvent restoration), 1 each in test_pairing,
    test_runner_startup_failures, test_stream_consumer.
  * sentinel-token sweep on main's eight largest commits:
    every audited symbol present in the merged tree at expected
    counts (TTSProvider 61, NtfyAdapter 29, S6ServiceManager 70,
    install_bws 12, security_audit 16, register_image_gen_provider
    23, list_profile_gateways 22, DISCORD_FREE_RESPONSE_CHANNELS
    48, …).
  * byte-diff sweep: 30/30 sampled main-only-modified files
    byte-identical to origin/main; the four bb/gui-only files
    that drifted (i18n/types.ts, i18n/ru.ts, ThemeSwitcher.tsx,
    ToolCall.tsx) correctly absorbed main's web/ → apps/dashboard/
    edits through git's rename detection (main's added lines all
    present, removed lines all absent).
This commit is contained in:
Jeffrey Quesnelle
2026-05-25 00:39:46 -04:00
521 changed files with 40613 additions and 3961 deletions
+5 -2
View File
@@ -29,9 +29,13 @@ runs:
- name: hermes --help
shell: bash
run: |
# Use the image's real ENTRYPOINT (/init + main-wrapper.sh) so
# this exercises the actual production startup path. PR #30136
# review caught that an --entrypoint override here had been
# silently neutered by the s6-overlay migration — stage2-hook
# ignores its CMD args, so the smoke test was a no-op.
docker run --rm \
-v /tmp/hermes-test:/opt/data \
--entrypoint /opt/hermes/docker/entrypoint.sh \
"${{ inputs.image }}" --help
- name: hermes dashboard --help
@@ -43,5 +47,4 @@ runs:
# installed package.
docker run --rm \
-v /tmp/hermes-test:/opt/data \
--entrypoint /opt/hermes/docker/entrypoint.sh \
"${{ inputs.image }}" dashboard --help
+68
View File
@@ -0,0 +1,68 @@
name: Docker / shell lint
# Lints the container build inputs: Dockerfile (via hadolint) and any shell
# scripts under docker/ (via shellcheck). These catch the class of regression
# the behavioral docker-publish smoke test can't — unquoted variable
# expansions, silently-failing RUN commands, etc.
#
# Rules and ignores are documented in .hadolint.yaml at the repo root.
# shellcheck severity is pinned to `error` so SC1091-style "can't follow
# sourced script" info-level warnings don't fail the job — the .venv
# activate script doesn't exist at lint time.
on:
push:
branches: [main]
paths:
- Dockerfile
- docker/**
- .hadolint.yaml
- .github/workflows/docker-lint.yml
pull_request:
branches: [main]
paths:
- Dockerfile
- docker/**
- .hadolint.yaml
- .github/workflows/docker-lint.yml
permissions:
contents: read
concurrency:
group: docker-lint-${{ github.ref }}
cancel-in-progress: true
jobs:
hadolint:
name: Lint Dockerfile (hadolint)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: hadolint
uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf # v3.1.0
with:
dockerfile: Dockerfile
config: .hadolint.yaml
failure-threshold: warning
shellcheck:
name: Lint docker/ shell scripts (shellcheck)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: shellcheck
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # v2.0.0
env:
# Severity = error: SC1091 (can't follow sourced script) is info-
# level and would otherwise fail when the venv activate script
# doesn't exist at lint time.
SHELLCHECK_OPTS: --severity=error
with:
scandir: ./docker
+120 -133
View File
@@ -27,9 +27,9 @@ on:
permissions:
contents: read
# Concurrency: push/release runs are NEVER cancelled so every merge gets its
# own SHA-tagged image; :main and :latest are guarded separately by the
# move-main and move-latest jobs. PR runs reuse a PR-scoped group with
# Concurrency: push/release runs are NEVER cancelled so every merge gets
# its own :main or release-tagged image. :latest is guarded separately
# by the move-latest job. PR runs reuse a PR-scoped group with
# cancel-in-progress: true so rapid pushes to the same PR collapse to the
# latest commit.
concurrency:
@@ -80,6 +80,56 @@ jobs:
with:
image: ${{ env.IMAGE_NAME }}:test
# ---------------------------------------------------------------------
# Run the docker-integration test suite against the freshly-built
# image already loaded into the local daemon (`:test`). These tests
# are excluded from the sharded `tests.yml :: test` matrix on purpose
# (see `_SKIP_PARTS` in scripts/run_tests_parallel.py) because each
# shard would otherwise reach the session-scoped ``built_image``
# fixture in ``tests/docker/conftest.py`` and start a 3-7min
# ``docker build`` under a 180s pytest-timeout cap — guaranteed to
# die in fixture setup.
#
# Piggybacking here avoids a second image build: the smoke test
# already proved the image loads + runs, so the daemon has it under
# `${IMAGE_NAME}:test` and we just point ``HERMES_TEST_IMAGE`` at
# that. The fixture's ``HERMES_TEST_IMAGE`` branch (see
# tests/docker/conftest.py:62-63) short-circuits the rebuild.
#
# Why this job and not a standalone one: the image is 5GB+; passing
# it between jobs via ``docker save``/``upload-artifact`` is slower
# than the build itself. Reusing the existing daemon state is the
# cheapest path to coverage on every PR that touches docker code.
# ---------------------------------------------------------------------
- name: Install uv (for docker tests)
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Set up Python 3.11 (for docker tests)
run: uv python install 3.11
- name: Install Python dependencies (for docker tests)
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
# ``dev`` extra pulls in pytest, pytest-asyncio, pytest-timeout —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.
uv pip install -e ".[dev]"
- name: Run docker integration tests
env:
# Skip rebuild; use the image already loaded by the build step.
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
# Match the policy in tests.yml :: test job — no accidental
# real-API calls from inside the harness.
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
run: |
source .venv/bin/activate
python -m pytest tests/docker/ -v --tb=short
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
@@ -92,10 +142,10 @@ jobs:
# pattern for multi-runner multi-platform builds.
#
# We apply the OCI revision label here (and again on arm64) because
# the move-main / move-latest jobs read it off the linux/amd64
# sub-manifest config of the floating tag to decide whether it's safe
# to advance. The label must be on each per-arch image — manifest
# lists themselves don't carry image config labels.
# the move-latest job reads it off the linux/amd64 sub-manifest
# config of the floating tag to decide whether it's safe to advance.
# The label must be on each per-arch image — manifest lists themselves
# don't carry image config labels.
- name: Push amd64 by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
@@ -208,8 +258,14 @@ jobs:
# ---------------------------------------------------------------------------
# Stitch both per-arch digests into a single tagged multi-arch manifest.
# This is a registry-side operation — no building, no layer re-push —
# so it runs in ~30 seconds. On main pushes it produces :sha-<sha>.
# On releases it produces :<release_tag_name>.
# so it runs in ~30 seconds. On main pushes it produces :main; on
# releases it produces :<release_tag_name>.
#
# For main pushes the ancestor check runs BEFORE the manifest push so
# we never overwrite :main with an older commit. The top-level
# concurrency group (`docker-${{ github.ref }}` with
# `cancel-in-progress: false`) already serialises runs per ref; the
# ancestor check is defense-in-depth.
# ---------------------------------------------------------------------------
merge:
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
@@ -217,10 +273,15 @@ jobs:
needs: [build-amd64, build-arm64]
timeout-minutes: 10
outputs:
pushed_sha_tag: ${{ steps.mark_pushed.outputs.pushed }}
pushed_release_tag: ${{ steps.mark_release_pushed.outputs.pushed }}
release_tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Checkout code
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1000
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
@@ -237,120 +298,19 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Compute the tag for this run. Main pushes use sha-<sha> (so every
# commit gets its own immutable tag); releases use the release tag name.
- name: Compute tag
id: tag
run: |
if [ "${{ github.event_name }}" = "release" ]; then
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
else
echo "tag=sha-${{ github.sha }}" >> "$GITHUB_OUTPUT"
fi
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
set -euo pipefail
# Build the arg array from each digest file (filename = the digest
# hex, with no sha256: prefix; empty file content, only the name
# matters). Using an array avoids shellcheck SC2046 and keeps
# every digest a single argv token even under pathological names.
args=()
for digest_file in *; do
args+=("${IMAGE_NAME}@sha256:${digest_file}")
done
docker buildx imagetools create \
-t "${IMAGE_NAME}:${TAG}" \
"${args[@]}"
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
TAG: ${{ steps.tag.outputs.tag }}
- name: Inspect image
run: |
docker buildx imagetools inspect "${IMAGE_NAME}:${TAG}"
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
TAG: ${{ steps.tag.outputs.tag }}
# Signal to move-main that the SHA tag is live. Only on main pushes;
# releases set pushed_release_tag instead.
- name: Mark SHA tag pushed
id: mark_pushed
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: echo "pushed=true" >> "$GITHUB_OUTPUT"
# Signal to move-latest that the release tag is live.
- name: Mark release tag pushed
id: mark_release_pushed
if: github.event_name == 'release'
run: echo "pushed=true" >> "$GITHUB_OUTPUT"
# ---------------------------------------------------------------------------
# Move :main to point at the SHA tag the merge job pushed.
#
# :main is the floating tag that tracks the tip of the main branch. Every
# merge to main retags :main forward. Users who want "latest dev build"
# pull :main; users who want stable releases pull :latest.
#
# The real serialization guarantee comes from the top-level concurrency
# group (`docker-${{ github.ref }}` with `cancel-in-progress: false`),
# which ensures at most one workflow run for this ref executes at a time.
# That means two move-main steps for the same ref cannot overlap.
#
# This job has its own concurrency group as defense-in-depth: if the
# top-level group is ever loosened, queued move-mains will run serially
# in arrival order, each one running the ancestor check below and either
# advancing :main or skipping. `cancel-in-progress: false` matches the
# top-level setting — we don't want rapid pushes to cancel a queued
# move-main, because the ancestor check is the real safety mechanism
# and queueing is cheap (move-main is a ~30s registry op).
#
# Combined with the ancestor check, this means :main only ever moves
# forward in git history.
# ---------------------------------------------------------------------------
move-main:
if: |
github.repository == 'NousResearch/hermes-agent'
&& github.event_name == 'push'
&& github.ref == 'refs/heads/main'
&& needs.merge.outputs.pushed_sha_tag == 'true'
needs: merge
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: docker-move-main-${{ github.ref }}
cancel-in-progress: false
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1000
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Read the git revision label off the current :main manifest, then
# use `git merge-base --is-ancestor` to check whether our commit is a
# descendant of it. If :main doesn't exist yet, or its label is
# missing, we treat that as "safe to publish". If another run already
# advanced :main past us (or diverged), we skip and leave it alone.
# use `git merge-base --is-ancestor` to check whether our commit is
# a descendant of it. If :main doesn't exist yet, or its label is
# missing, we treat that as "safe to publish". If another run
# already advanced :main past us (or diverged), we skip and leave
# it alone.
- name: Decide whether to move :main
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
id: main_check
run: |
set -euo pipefail
image=nousresearch/hermes-agent
# Pull the JSON for the linux/amd64 sub-manifest's config and extract
# the OCI revision label with jq — Go template field access can't
# handle dots in map keys, so using json+jq is the robust route.
image_json=$(
docker buildx imagetools inspect "${image}:main" \
--format '{{ json (index .Image "linux/amd64") }}' \
@@ -383,7 +343,6 @@ jobs:
exit 0
fi
# Make sure we have the :main commit locally for merge-base.
if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
git fetch --no-tags --prune origin \
"+refs/heads/main:refs/remotes/origin/main" \
@@ -396,7 +355,6 @@ jobs:
exit 0
fi
# Our SHA must be a descendant of the current :main to be safe.
if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then
echo "Our commit is a descendant of :main — safe to advance."
echo "push_main=true" >> "$GITHUB_OUTPUT"
@@ -405,19 +363,48 @@ jobs:
echo "push_main=false" >> "$GITHUB_OUTPUT"
fi
# Retag the already-pushed SHA manifest as :main. This is a registry-
# side operation — no rebuild, no layer re-push — so it's quick and
# atomic per-tag. The ancestor check above plus the cancel-in-progress
# concurrency on this job together guarantee we only ever move :main
# forward in git history.
- name: Move :main to this SHA
if: steps.main_check.outputs.push_main == 'true'
# Compute the tag for this run. Main pushes tag directly as :main
# (no per-commit SHA tags); releases use the release tag name.
- name: Compute tag
id: tag
run: |
if [ "${{ github.event_name }}" = "release" ]; then
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
else
echo "tag=main" >> "$GITHUB_OUTPUT"
fi
# Gate the manifest push on the ancestor check for main pushes.
# For releases there is no gate — the check doesn't even run.
- name: Create manifest list and push
if: github.event_name != 'push' || steps.main_check.outputs.push_main == 'true'
working-directory: /tmp/digests
run: |
set -euo pipefail
image=nousresearch/hermes-agent
args=()
for digest_file in *; do
args+=("${IMAGE_NAME}@sha256:${digest_file}")
done
docker buildx imagetools create \
--tag "${image}:main" \
"${image}:sha-${GITHUB_SHA}"
-t "${IMAGE_NAME}:${TAG}" \
"${args[@]}"
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
TAG: ${{ steps.tag.outputs.tag }}
- name: Inspect image
if: github.event_name != 'push' || steps.main_check.outputs.push_main == 'true'
run: |
docker buildx imagetools inspect "${IMAGE_NAME}:${TAG}"
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
TAG: ${{ steps.tag.outputs.tag }}
# Signal to move-latest that the release tag is live.
- name: Mark release tag pushed
id: mark_release_pushed
if: github.event_name == 'release'
run: echo "pushed=true" >> "$GITHUB_OUTPUT"
# ---------------------------------------------------------------------------
# Move :latest to point at the release tag the merge job pushed.
@@ -427,10 +414,10 @@ jobs:
#
# We still run an ancestor check against the existing :latest so that a
# backport release on an older branch (e.g. patching v1.1.5 after v1.2.3
# is out) doesn't drag :latest backwards. The check is the same shape as
# move-main: read the OCI revision label off the current :latest, look up
# that commit in git, and only advance if our release commit is a strict
# descendant.
# is out) doesn't drag :latest backwards. The check is the same shape
# as the ancestor check in the merge job for :main: read the OCI
# revision label off the current :latest, look up that commit in git,
# and only advance if our release commit is a strict descendant.
# ---------------------------------------------------------------------------
move-latest:
if: |
+12 -4
View File
@@ -47,14 +47,17 @@ jobs:
HEAD="${{ github.event.pull_request.head.sha }}"
# Added lines only, excluding lockfiles.
DIFF=$(git diff "$BASE".."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
# Three-dot diff (base...head) diffs from the merge base to HEAD,
# so only changes introduced by this PR are included — not changes
# that landed on main after the PR branched off.
DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
FINDINGS=""
# --- .pth files (auto-execute on Python startup) ---
# The exact mechanism used in the litellm supply chain attack:
# https://github.com/BerriAI/litellm/issues/24512
PTH_FILES=$(git diff --name-only "$BASE".."$HEAD" | grep '\.pth$' || true)
PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
if [ -n "$PTH_FILES" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: .pth file added or modified
@@ -97,7 +100,12 @@ jobs:
# --- Install-hook files (setup.py/sitecustomize/usercustomize/__init__.pth) ---
# These execute during pip install or interpreter startup.
SETUP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(^|/)(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
# Anchored at repo root: only the top-level setup.py/setup.cfg run during
# `pip install`, and only top-level sitecustomize.py/usercustomize.py are
# auto-loaded by the interpreter via site.py. Any nested file with the
# same name (e.g. hermes_cli/setup.py — the CLI setup wizard) is unrelated
# and produced false positives that trained reviewers to ignore the scanner.
SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
if [ -n "$SETUP_HITS" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: Install-hook file added or modified
@@ -158,7 +166,7 @@ jobs:
HEAD="${{ github.event.pull_request.head.sha }}"
# Only check added lines in pyproject.toml
ADDED=$(git diff "$BASE".."$HEAD" -- pyproject.toml | grep '^+' | grep -v '^+++' || true)
ADDED=$(git diff "$BASE"..."$HEAD" -- pyproject.toml | grep '^+' | grep -v '^+++' || true)
if [ -z "$ADDED" ]; then
echo "found=false" >> "$GITHUB_OUTPUT"
+61 -4
View File
@@ -23,11 +23,22 @@ concurrency:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
slice: [1, 2, 3, 4, 5, 6]
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore duration cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: test_durations.json
# Single stable key. main always overwrites, PRs always find it.
key: test-durations
- name: Install ripgrep (prebuilt binary)
run: |
set -euo pipefail
@@ -54,7 +65,7 @@ jobs:
source .venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Run tests
- name: Run tests (slice ${{ matrix.slice }}/6)
# Per-file isolation via scripts/run_tests_parallel.py: discovers
# every test_*.py file under tests/ (excluding integration/ + e2e/),
# then runs `python -m pytest <file>` in a freshly-spawned subprocess
@@ -72,15 +83,61 @@ jobs:
# state across files, which is exactly the leakage we wanted to
# fix. ThreadPoolExecutor + subprocess.run is ~60 lines and does
# the job with cleaner semantics.
#
# Matrix slicing (--slice I/N): files are distributed across 6
# jobs by cached duration (LPT algorithm) so each job gets
# roughly equal wall time. Without a cache, files default to 2s
# estimate and get split roughly evenly by count — still correct,
# just not perfectly balanced.
run: |
source .venv/bin/activate
python scripts/run_tests_parallel.py
python scripts/run_tests_parallel.py --slice ${{ matrix.slice }}/6
env:
# Ensure tests don't accidentally call real APIs
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
- name: Upload per-slice durations
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-durations-slice-${{ matrix.slice }}
path: test_durations.json
retention-days: 1
# Merge per-slice duration data into a single cache, so future runs
# (including PRs) get balanced slicing.
save-durations:
needs: test
if: always() && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Download all slice durations
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: test-durations-slice-*
path: durations
merge-multiple: true
- name: Merge into single durations file
run: |
python3 -c "
import json, glob, os
merged = {}
for f in glob.glob('durations/*test_durations.json'):
with open(f) as fh:
merged.update(json.load(fh))
with open('test_durations.json', 'w') as fh:
json.dump(merged, fh, indent=2, sort_keys=True)
print(f'Merged {len(merged)} file durations')
"
- name: Save merged duration cache
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: test_durations.json
key: test-durations
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -121,4 +178,4 @@ jobs:
env:
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
NOUS_API_KEY: ""
+1
View File
@@ -18,6 +18,7 @@ __pycache__/web_tools.cpython-310.pyc
logs/
data/
.pytest_cache/
test_durations.json
.pytest-cache/
tmp/
temp_vision_images/
+36
View File
@@ -0,0 +1,36 @@
# hadolint configuration for the Hermes Agent Dockerfile.
# See https://github.com/hadolint/hadolint#configure for rules.
#
# We want hadolint to surface NEW Dockerfile lint regressions, but we
# don't want to rewrite the existing image to silence rules that are
# either intentional or pragmatic tradeoffs for this project. Each
# ignore below has a one-line justification.
failure-threshold: warning
ignored:
# Pin versions in apt get install. We intentionally don't pin common
# tools (curl, git, openssh-client, etc.) — security updates flow in
# via the periodic base-image rebuild, and pinning would lock us to
# superseded patch releases. Same rationale as nearly every distro-
# base official image (python, node, debian).
- DL3008
# Use WORKDIR to switch to a directory. The image uses `(cd web && …)`
# / `(cd ../ui-tui && …)` inline subshells for one-off build steps
# because they don't affect later RUN commands; promoting them to
# full WORKDIR switches with restores would obscure intent.
- DL3003
# Multiple consecutive RUN instructions. The `touch README.md` + `uv
# sync` split is intentional — `touch` is cheap, `uv sync` is the
# expensive layer-cached step we want isolated, and merging them
# would invalidate the cache for trivial changes.
- DL3059
# Last USER should not be root. /init (s6-overlay) runs as root so the
# stage2 hook can usermod/groupmod and chown the data volume per
# HERMES_UID at runtime; each supervised service then drops to the
# hermes user via `s6-setuidgid`.
- DL3002
# Require explicit base-image pins (SHA256) — we already do this.
trustedRegistries:
- docker.io
- ghcr.io
+114 -10
View File
@@ -1,5 +1,4 @@
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
FROM tianon/gosu:1.19-trixie@sha256:3b176695959c71e123eb390d427efc665eeb561b1540e82679c15e992006b8b9 AS gosu_source
FROM debian:13.4
# Disable Python stdout buffering to ensure logs are printed immediately
@@ -9,18 +8,68 @@ ENV PYTHONUNBUFFERED=1
# install survives the /opt/data volume overlay at runtime.
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# Install system dependencies in one layer, clear APT cache
# tini reaps orphaned zombie processes (MCP stdio subprocesses, git, bun, etc.)
# that would otherwise accumulate when hermes runs as PID 1. See #15012.
# Install system dependencies in one layer, clear APT cache.
# tini was previously PID 1 to reap orphaned zombie processes (MCP stdio
# subprocesses, git, bun, etc.) that would otherwise accumulate when hermes
# ran as PID 1. See #15012. Phase 2 of the s6-overlay supervision plan
# replaces tini with s6-overlay's /init (PID 1 = s6-svscan), which reaps
# zombies non-blockingly on SIGCHLD and additionally supervises the main
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential curl nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli tini && \
build-essential curl nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
# s6-overlay provides supervision for the main hermes process, the dashboard,
# and per-profile gateways. /init becomes PID 1 below — see ENTRYPOINT.
#
# Multi-arch: BuildKit auto-populates TARGETARCH (amd64 / arm64). s6-overlay
# uses tarball names keyed on the kernel arch string (x86_64 / aarch64), so
# we map between them inline. The noarch + symlinks tarballs are
# architecture-independent and reused as-is.
#
# We use `curl` instead of `ADD` for the per-arch tarball because `ADD`
# evaluates its URL at parse time, before any ARG / TARGETARCH substitution
# — splitting one URL per arch into two ADDs would download both on every
# build and leave dead bytes in the cache. A single curl + arch-keyed URL
# is simpler and cache-friendlier.
#
# Supply-chain integrity: every tarball is checksum-verified against the
# upstream-published SHA256. To bump S6_OVERLAY_VERSION, fetch the four
# `.sha256` files from the corresponding release and update the ARGs. The
# checksum lookup happens during build, so a compromised release artifact
# fails the build loudly instead of silently producing a tampered image.
ARG TARGETARCH
ARG S6_OVERLAY_VERSION=3.2.3.0
ARG S6_OVERLAY_NOARCH_SHA256=b720f9d9340efc8bb07528b9743813c836e4b02f8693d90241f047998b4c53cf
ARG S6_OVERLAY_X86_64_SHA256=a93f02882c6ed46b21e7adb5c0add86154f01236c93cd82c7d682722e8840563
ARG S6_OVERLAY_AARCH64_SHA256=0952056ff913482163cc30e35b2e944b507ba1025d78f5becbb89367bf344581
ARG S6_OVERLAY_SYMLINKS_SHA256=a60dc5235de3ecbcf874b9c1f18d73263ab99b289b9329aa950e8729c4789f0e
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp/
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-symlinks-noarch.tar.xz /tmp/
RUN set -eu; \
case "${TARGETARCH:-amd64}" in \
amd64) s6_arch="x86_64"; s6_arch_sha="${S6_OVERLAY_X86_64_SHA256}" ;; \
arm64) s6_arch="aarch64"; s6_arch_sha="${S6_OVERLAY_AARCH64_SHA256}" ;; \
*) echo "Unsupported TARGETARCH=${TARGETARCH} for s6-overlay" >&2; exit 1 ;; \
esac; \
curl -fsSL --retry 3 -o /tmp/s6-overlay-arch.tar.xz \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${s6_arch}.tar.xz"; \
{ \
printf '%s %s\n' "${S6_OVERLAY_NOARCH_SHA256}" /tmp/s6-overlay-noarch.tar.xz; \
printf '%s %s\n' "${s6_arch_sha}" /tmp/s6-overlay-arch.tar.xz; \
printf '%s %s\n' "${S6_OVERLAY_SYMLINKS_SHA256}" /tmp/s6-overlay-symlinks-noarch.tar.xz; \
} > /tmp/s6-overlay.sha256; \
sha256sum -c /tmp/s6-overlay.sha256; \
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz; \
tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz; \
tar -C / -Jxpf /tmp/s6-overlay-symlinks-noarch.tar.xz; \
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
RUN useradd -u 10000 -m -d /opt/data hermes
COPY --chmod=0755 --from=gosu_source /gosu /usr/local/bin/
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
WORKDIR /opt/hermes
@@ -103,18 +152,73 @@ RUN cd web && npm run build && \
USER root
RUN chmod -R a+rX /opt/hermes && \
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/node_modules
# Start as root so the entrypoint can usermod/groupmod + gosu.
# If HERMES_UID is unset, the entrypoint drops to the default hermes user (10000).
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
# the data volume. Each supervised service then drops to the hermes user via
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
# run as the default hermes user (UID 10000).
# ---------- Link hermes-agent itself (editable) ----------
# Deps are already installed in the cached layer above; `--no-deps` makes
# this a fast (~1s) egg-link creation with no resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."
# ---------- s6-overlay service wiring ----------
# Static services declared at build time: main-hermes + dashboard.
# Per-profile gateway services are registered dynamically at runtime by
# the profile create/delete hooks (Phase 4); they live under
# /run/service/ (tmpfs) and are reconciled on container restart by
# /etc/cont-init.d/02-reconcile-profiles (Phase 4 Task 4.0).
COPY docker/s6-rc.d/ /etc/s6-overlay/s6-rc.d/
# stage2-hook handles UID/GID remap, volume chown, config seeding,
# skills sync — all the work the old entrypoint.sh did before
# `exec hermes`. Wired in as cont-init.d/01- so it
# runs before user services start.
#
# 02-reconcile-profiles re-creates per-profile gateway s6 service
# slots from $HERMES_HOME/profiles/<name>/ after a container restart
# (the /run/service/ scandir is tmpfs and wiped on restart). Phase 4.
RUN mkdir -p /etc/cont-init.d && \
printf '#!/bin/sh\nexec /opt/hermes/docker/stage2-hook.sh\n' \
> /etc/cont-init.d/01-hermes-setup && \
chmod +x /etc/cont-init.d/01-hermes-setup
COPY --chmod=0755 docker/cont-init.d/015-supervise-perms /etc/cont-init.d/015-supervise-perms
COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-reconcile-profiles
# ---------- Runtime ----------
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
ENV HERMES_HOME=/opt/data
ENV PATH="/opt/data/.local/bin:${PATH}"
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
# same for the container's main process, but `docker exec` and our
# cont-init.d scripts don't pass through the wrapper. Expose the venv
# bin globally so `docker exec <container> hermes ...` and any
# subprocess that doesn't activate the venv first still find hermes.
ENV PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
RUN mkdir -p /opt/data
VOLUME [ "/opt/data" ]
ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/opt/hermes/docker/entrypoint.sh" ]
# s6-overlay's /init is PID 1. It sets up the supervision tree, runs
# /etc/cont-init.d/* (our stage2 hook), starts s6-rc services
# declared in /etc/s6-overlay/s6-rc.d/, then exec's its remaining
# argv as the container's "main program" with stdin/stdout/stderr
# inherited (this is what makes interactive --tui work). When the
# main program exits, /init begins stage 3 shutdown and the container
# exits with the program's exit code. Replaces tini — see Phase 2 of
# docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md.
#
# We use the ENTRYPOINT+CMD split rather than CMD alone so the
# wrapper is prepended to user-supplied args automatically:
#
# docker run <image> → /init main-wrapper.sh (CMD default)
# docker run <image> chat -q "hi" → /init main-wrapper.sh chat -q hi
# docker run <image> sleep infinity → /init main-wrapper.sh sleep infinity
# docker run <image> --tui → /init main-wrapper.sh --tui
#
# main-wrapper.sh handles arg routing (bare-exec vs. hermes
# subcommand vs. no-args), drops to the hermes user via s6-setuidgid,
# and exec's the final program so its exit code becomes the container
# exit code. Without the wrapper-as-ENTRYPOINT, leading-dash args
# like `--version` would be intercepted by /init's POSIX shell.
ENTRYPOINT [ "/init", "/opt/hermes/docker/main-wrapper.sh" ]
CMD [ ]
+21
View File
@@ -79,6 +79,27 @@ hermes doctor # Diagnose any issues
📖 **[Full documentation →](https://hermes-agent.nousresearch.com/docs/)**
---
## Skip the API-key collection — Nous Portal
Hermes works with whatever provider you want — that's not changing. But if you'd rather not collect five separate API keys for the model, web search, image generation, TTS, and a cloud browser, **[Nous Portal](https://portal.nousresearch.com)** covers all of them under one subscription:
- **300+ models** — pick any of them with `/model <name>`
- **Tool Gateway** — web search (Firecrawl), image generation (FAL), text-to-speech (OpenAI), cloud browser (Browser Use), all routed through your sub. No extra accounts.
One command from a fresh install:
```bash
hermes setup --portal
```
That logs you in via OAuth, sets Nous as your provider, and turns on the Tool Gateway. Check what's wired up any time with `hermes portal status`. Full details on the [Tool Gateway docs page](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway).
You can still bring your own keys per-tool whenever you want — the gateway is per-backend, not all-or-nothing.
---
## CLI vs Messaging Quick Reference
Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces.
+21
View File
@@ -65,6 +65,27 @@ hermes doctor # 诊断问题
📖 **[完整文档 →](https://hermes-agent.nousresearch.com/docs/)**
---
## 省去到处收集 API Key — Nous Portal
Hermes 始终允许你使用任意服务商,这点不会改变。但如果你不想为模型、网页搜索、图像生成、TTS、云浏览器分别去申请五个不同的 API Key,**[Nous Portal](https://portal.nousresearch.com)** 用一个订阅就能覆盖全部:
- **300+ 模型** — 用 `/model <name>` 随时切换
- **Tool Gateway** — 网页搜索(Firecrawl)、图像生成(FAL)、文本转语音(OpenAI)、云浏览器(Browser Use),全部通过订阅托管。无需额外注册任何账户。
全新安装时一条命令即可:
```bash
hermes setup --portal
```
它会通过 OAuth 登录、把 Nous 设为推理服务商,并启用 Tool Gateway。随时用 `hermes portal status` 查看路由状态。完整说明见 [Tool Gateway 文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway)。
你随时可以按工具单独切回自己的 API Key — Gateway 是按工具粒度生效的,不是一刀切。
---
## CLI 与消息平台 快速对照
Hermes 有两种入口:用 `hermes` 启动终端 UI,或运行网关从 Telegram、Discord、Slack、WhatsApp、Signal 或 Email 与之对话。进入对话后,许多斜杠命令在两种界面中通用。
+5 -1
View File
@@ -1534,7 +1534,11 @@ class HermesACPAgent(acp.Agent):
)
except Exception:
logger.debug("Failed to auto-title ACP session %s", session_id, exc_info=True)
if final_response and conn and not streamed_message:
if final_response and conn and (not streamed_message or result.get("response_transformed")):
# Deliver the final response when streaming did not already send it,
# or when a plugin hook transformed the response after streaming
# finished (e.g. transform_llm_output) — otherwise the appended /
# rewritten text never reaches the client.
update = acp.update_agent_message_text(final_response)
await conn.session_update(session_id, update)
+60 -11
View File
@@ -607,6 +607,31 @@ def init_agent(
# Falling back would send Anthropic credentials to third-party endpoints (Fixes #1739, #minimax-401).
_is_native_anthropic = agent.provider == "anthropic"
effective_key = (api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or "")
# MiniMax OAuth issues short-lived (~15-min) access tokens. The
# Anthropic SDK caches ``api_key`` as a static string at client
# construction time, so a session that resolves the bearer once
# at startup will keep sending the same token until MiniMax
# returns 401 mid-session. Swap the static string for a callable
# token provider — ``build_anthropic_client`` recognizes the
# callable and installs an httpx event hook that mints a fresh
# bearer per outbound request (re-reading auth.json so a refresh
# persisted by another process is visible immediately).
# The cached refresh path is a no-op when the token still has
# ``MINIMAX_OAUTH_REFRESH_SKEW_SECONDS`` of life left, so steady-
# state cost is one file read + one timestamp compare per request.
if agent.provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key:
try:
from hermes_cli.auth import build_minimax_oauth_token_provider
effective_key = build_minimax_oauth_token_provider()
except Exception as _mm_exc: # noqa: BLE001 — never block startup on this
import logging as _logging
_logging.getLogger(__name__).warning(
"MiniMax OAuth: failed to install per-request token provider "
"(%s); falling back to static bearer that will expire ~15min in.",
_mm_exc,
)
agent.api_key = effective_key
agent._anthropic_api_key = effective_key
agent._anthropic_base_url = base_url
@@ -618,7 +643,7 @@ def init_agent(
# that cause 401/403 on their endpoints. Guards #1739 and
# the third-party identity-injection bug.
from agent.anthropic_adapter import _is_oauth_token as _is_oat
agent._is_anthropic_oauth = _is_oat(effective_key) if _is_native_anthropic else False
agent._is_anthropic_oauth = _is_oat(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
agent._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout)
# No OpenAI client needed for Anthropic mode
agent.client = None
@@ -951,16 +976,14 @@ def init_agent(
# Expose session ID to tools (terminal, execute_code) so agents can
# reference their own session for --resume commands, cross-session
# coordination, and logging. Uses the ContextVar system from
# session_context.py for concurrency safety (gateway runs multiple
# sessions in one process). Also writes os.environ as fallback for
# CLI mode where ContextVars aren't used.
os.environ["HERMES_SESSION_ID"] = agent.session_id
# coordination, and logging. Keep the ContextVar and os.environ
# fallback synchronized because different tool paths still read both.
try:
from gateway.session_context import _SESSION_ID
_SESSION_ID.set(agent.session_id)
from gateway.session_context import set_current_session_id
set_current_session_id(agent.session_id)
except Exception:
pass # CLI/test mode — ContextVar not needed
os.environ["HERMES_SESSION_ID"] = agent.session_id
# Session logs go into ~/.hermes/sessions/ alongside gateway sessions
hermes_home = get_hermes_home()
@@ -1125,7 +1148,18 @@ def init_agent(
# through _ra().get_tool_definitions()). Duplicate function names cause
# 400 errors on providers that enforce unique names (e.g. Xiaomi
# MiMo via Nous Portal).
if agent._memory_manager and agent.tools is not None:
#
# Respect the platform's enabled_toolsets configuration (#5544):
# enabled_toolsets is None → no filter, inject (backward compat)
# "memory" in enabled_toolsets → user opted in, inject
# otherwise (incl. []) → user excluded memory, skip injection
#
# Without this gate, `platform_toolsets: telegram: []` still leaks memory
# provider tools (fact_store, etc.) into the tool surface — a 10x latency
# penalty on local models and a frequent trigger of tool-call loops.
if agent._memory_manager and agent.tools is not None and (
agent.enabled_toolsets is None or "memory" in agent.enabled_toolsets
):
_existing_tool_names = {
t.get("function", {}).get("name")
for t in agent.tools
@@ -1393,6 +1427,7 @@ def init_agent(
base_url=agent.base_url,
api_key=getattr(agent, "api_key", ""),
provider=agent.provider,
api_mode=agent.api_mode,
)
if not agent.quiet_mode:
_ra().logger.info("Using context engine: %s", _selected_engine.name)
@@ -1435,8 +1470,22 @@ def init_agent(
# errors. Even with the cache fix, dedup is the right defense
# against plugin paths that may register the same schemas via
# ctx.register_tool(). Mirrors the memory tools dedup above.
#
# Respect the platform's enabled_toolsets configuration (#5544):
# context engine tools follow the same gating pattern as memory
# provider tools — without the gate, `platform_toolsets: telegram: []`
# would still leak lcm_* tools into the tool surface and incur the
# same local-model latency penalty.
agent._context_engine_tool_names: set = set()
if hasattr(agent, "context_compressor") and agent.context_compressor and agent.tools is not None:
if (
hasattr(agent, "context_compressor")
and agent.context_compressor
and agent.tools is not None
and (
agent.enabled_toolsets is None
or "context_engine" in agent.enabled_toolsets
)
):
_existing_tool_names = {
t.get("function", {}).get("name")
for t in agent.tools
+83 -26
View File
@@ -132,7 +132,7 @@ def convert_to_trajectory_format(agent, messages: List[Dict[str, Any]], user_que
except json.JSONDecodeError:
# This shouldn't happen since we validate and retry during conversation,
# but if it does, log warning and use empty dict
logging.warning(f"Unexpected invalid JSON in trajectory conversion: {tool_call['function']['arguments'][:100]}")
logger.warning(f"Unexpected invalid JSON in trajectory conversion: {tool_call['function']['arguments'][:100]}")
arguments = {}
tool_call_json = {
@@ -617,9 +617,28 @@ def recover_with_credential_pool(
# existing entitlement keyword set in ``_is_entitlement_failure``.
# Any 403 against ``xai-oauth`` is treated as entitlement here so
# the refresh loop can't spin in those cases either.
#
# Exception (#29344): xAI's ``[WKE=unauthenticated:...]`` suffix and
# the ``OAuth2 access token could not be validated`` phrasing are
# xAI's authoritative "this is a stale token, not entitlement"
# signal. When either fires we must NOT apply the catch-all
# override — refresh is the recoverable path for these bodies, and
# blanket-classifying them as entitlement was the bug that left
# long-running TUI sessions stuck on stale tokens until the user
# exited and reopened.
is_entitlement = agent._is_entitlement_failure(error_context, status_code)
if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth":
is_entitlement = True
_disambiguator_haystack = " ".join(
str(error_context.get(k) or "").lower()
for k in ("message", "reason", "code", "error")
if isinstance(error_context, dict)
)
_is_xai_auth_failure = (
"[wke=unauthenticated:" in _disambiguator_haystack
or "oauth2 access token could not be validated" in _disambiguator_haystack
)
if not _is_xai_auth_failure:
is_entitlement = True
if is_entitlement:
_ra().logger.info(
"Credential %s — entitlement-shaped 403 from %s; "
@@ -728,7 +747,7 @@ def try_recover_primary_transport(
time.sleep(wait_time)
return True
except Exception as e:
logging.warning("Primary transport recovery failed: %s", e)
logger.warning("Primary transport recovery failed: %s", e)
return False
# ── End provider fallback ──────────────────────────────────────────────
@@ -891,19 +910,20 @@ def restore_primary_runtime(agent) -> bool:
base_url=rt["compressor_base_url"],
api_key=rt["compressor_api_key"],
provider=rt["compressor_provider"],
api_mode=rt.get("compressor_api_mode", ""),
)
# ── Reset fallback chain for the new turn ──
agent._fallback_activated = False
agent._fallback_index = 0
logging.info(
logger.info(
"Primary runtime restored for new turn: %s (%s)",
agent.model, agent.provider,
)
return True
except Exception as e:
logging.warning("Failed to restore primary runtime: %s", e)
logger.warning("Failed to restore primary runtime: %s", e)
return False
# Which error types indicate a transient transport failure worth
@@ -1064,10 +1084,7 @@ def dump_api_request_debug(
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json"
dump_file.write_text(
json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
atomic_json_write(dump_file, dump_payload, default=str)
agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}")
@@ -1077,7 +1094,7 @@ def dump_api_request_debug(
return dump_file
except Exception as dump_error:
if agent.verbose_logging:
logging.warning(f"Failed to dump API request debug payload: {dump_error}")
logger.warning(f"Failed to dump API request debug payload: {dump_error}")
return None
@@ -1352,6 +1369,22 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# API key — falling back would send Anthropic credentials to third-party endpoints.
_is_native_anthropic = new_provider == "anthropic"
effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "")
# MiniMax OAuth: swap static string for a per-request callable token
# provider so the rebuilt client survives 15-min token expiry. See
# the matching block in agent_init.py for the full rationale.
if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key:
try:
from hermes_cli.auth import build_minimax_oauth_token_provider
effective_key = build_minimax_oauth_token_provider()
except Exception as _mm_exc: # noqa: BLE001
import logging as _logging
_logging.getLogger(__name__).warning(
"MiniMax OAuth: failed to install per-request token provider "
"on switch (%s); using static bearer.",
_mm_exc,
)
agent.api_key = effective_key
agent._anthropic_api_key = effective_key
agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None)
@@ -1359,7 +1392,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
effective_key, agent._anthropic_base_url,
timeout=get_provider_request_timeout(agent.provider, agent.model),
)
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if _is_native_anthropic else False
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
agent.client = None
agent._client_kwargs = {}
else:
@@ -1446,6 +1479,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
"compressor_api_key": getattr(_cc, "api_key", "") if _cc else "",
"compressor_provider": getattr(_cc, "provider", agent.provider) if _cc else agent.provider,
"compressor_context_length": _cc.context_length if _cc else 0,
"compressor_api_mode": getattr(_cc, "api_mode", agent.api_mode) if _cc else agent.api_mode,
"compressor_threshold_tokens": _cc.threshold_tokens if _cc else 0,
}
if api_mode == "anthropic_messages":
@@ -1477,7 +1511,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
agent._fallback_chain = fallback_chain
agent._fallback_model = fallback_chain[0] if fallback_chain else None
logging.info(
logger.info(
"Model switched in-place: %s (%s) -> %s (%s)",
old_model, old_provider, new_model, new_provider,
)
@@ -2116,33 +2150,56 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in
def force_close_tcp_sockets(client: Any) -> int:
"""Force-close underlying TCP sockets to prevent CLOSE-WAIT accumulation.
"""Abort in-flight TCP I/O by shutting down sockets WITHOUT closing FDs.
When a provider drops a connection mid-stream, httpx's ``client.close()``
performs a graceful shutdown which leaves sockets in CLOSE-WAIT until the
OS times them out (often minutes). This method walks the httpx transport
pool and issues ``socket.shutdown(SHUT_RDWR)`` + ``socket.close()`` to
force an immediate TCP RST, freeing the file descriptors.
When a provider drops a connection mid-stream — or the user issues an
interrupt — we want to unblock httpx's reader/writer immediately rather
than waiting for the kernel's per-connection timeout. ``shutdown(SHUT_RDWR)``
achieves that: it sends FIN, breaks any pending ``recv``/``send`` with EOF
or ``EPIPE``, but does NOT release the file descriptor.
Returns the number of sockets force-closed.
Historically this helper also called ``socket.close()`` so the FD got
released immediately, but that's unsafe when (as is the case for both the
interrupt-abort path and stale-call kill path) the helper runs on a
different thread than the one driving the request:
* The Python ``socket.socket`` we close here is the SAME object held by
httpx's pool, so closing it via Python sets its ``_fd`` to -1 and
future operations on that Python object fail safely.
* BUT the SSL wrapper (``ssl.SSLSocket``'s underlying OpenSSL ``BIO``)
caches the raw integer FD. Once ``os.close(fd)`` runs, the kernel may
immediately recycle that integer to the next ``open()`` call — e.g.
the kanban dispatcher opening ``kanban.db``.
* The owning worker thread then unwinds httpx, the SSL layer flushes a
pending TLS record, and the encrypted bytes get written into the
wrong file (issue #29507: 24-byte TLS application-data record
clobbering SQLite header bytes 5..28).
The fix is to let the owning thread own the close. ``shutdown()`` from any
thread is FD-safe; ``close()`` is not. The httpx connection's own close
path — which runs from the worker thread when it unwinds — will release
the FD via the same ``socket.socket`` object, and because Python's socket
close atomically swaps ``_fd`` to -1 *before* issuing ``os.close``, there
is no FD-aliasing window when only one thread closes.
Returns the number of sockets shut down. (Field kept as
``tcp_force_closed=N`` in the log line for backwards-compatible parsing.)
"""
import socket as _socket
closed = 0
shutdown_count = 0
try:
for sock in _iter_pool_sockets(client):
try:
sock.shutdown(_socket.SHUT_RDWR)
except OSError:
# Already shut down / not connected / FD invalid — all benign.
pass
try:
sock.close()
except OSError:
pass
closed += 1
# IMPORTANT (#29507): do NOT call sock.close() here. See docstring.
shutdown_count += 1
except Exception as exc:
_ra().logger.debug("Force-close TCP sockets sweep error: %s", exc)
return closed
return shutdown_count
+289 -236
View File
@@ -15,6 +15,8 @@ import json
import logging
import os
import platform
import secrets
import stat
import subprocess
from pathlib import Path
from urllib.parse import urlparse
@@ -1040,11 +1042,34 @@ def _write_claude_code_credentials(
existing["claudeAiOauth"] = oauth_data
cred_path.parent.mkdir(parents=True, exist_ok=True)
_tmp_cred = cred_path.with_suffix(".tmp")
_tmp_cred.write_text(json.dumps(existing, indent=2), encoding="utf-8")
_tmp_cred.replace(cred_path)
# Restrict permissions (credentials file)
cred_path.chmod(0o600)
# Per-process random suffix avoids collisions between concurrent
# writers and stale leftovers from a prior crashed write.
_tmp_cred = cred_path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
try:
# Create the temp file atomically at 0o600. The previous
# write_text + post-replace chmod opened a TOCTOU window where
# both the temp file and the destination briefly inherited the
# process umask (commonly 0o644 = world-readable), exposing
# Claude Code OAuth tokens to other local users between create
# and chmod. Mirrors agent/google_oauth.py (#19673) and
# tools/mcp_oauth.py (#21148). Parent dir (~/.claude/) is
# owned by Claude Code itself, so we leave its mode alone.
fd = os.open(
str(_tmp_cred),
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
stat.S_IRUSR | stat.S_IWUSR,
)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(existing, fh, indent=2)
fh.flush()
os.fsync(fh.fileno())
os.replace(_tmp_cred, cred_path)
except OSError:
try:
_tmp_cred.unlink(missing_ok=True)
except OSError:
pass
raise
except (OSError, IOError) as e:
logger.debug("Failed to write refreshed credentials: %s", e)
@@ -1606,182 +1631,155 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
return out
def convert_messages_to_anthropic(
messages: List[Dict],
base_url: str | None = None,
model: str | None = None,
) -> Tuple[Optional[Any], List[Dict]]:
"""Convert OpenAI-format messages to Anthropic format.
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
"""Convert an assistant message to Anthropic content blocks.
Returns (system_prompt, anthropic_messages).
System messages are extracted since Anthropic takes them as a separate param.
system_prompt is a string or list of content blocks (when cache_control present).
When *base_url* is provided and points to a third-party Anthropic-compatible
endpoint, all thinking block signatures are stripped. Signatures are
Anthropic-proprietary — third-party endpoints cannot validate them and will
reject them with HTTP 400 "Invalid signature in thinking block".
When *model* is provided and matches the Kimi / Moonshot family (or
*base_url* is a Kimi / Moonshot host), unsigned thinking blocks
synthesised from ``reasoning_content`` are preserved on replayed
assistant tool-call messages — Kimi requires the field to exist, even
if empty.
Handles thinking blocks, regular content, tool calls, and
reasoning_content injection for Kimi/DeepSeek endpoints.
"""
system = None
result = []
for m in messages:
role = m.get("role", "user")
content = m.get("content", "")
if role == "system":
if isinstance(content, list):
# Preserve cache_control markers on content blocks
has_cache = any(
p.get("cache_control") for p in content if isinstance(p, dict)
)
if has_cache:
system = [p for p in content if isinstance(p, dict)]
else:
system = "\n".join(
p["text"] for p in content if p.get("type") == "text"
)
else:
system = content
continue
if role == "assistant":
blocks = _extract_preserved_thinking_blocks(m)
if content:
if isinstance(content, list):
converted_content = _convert_content_to_anthropic(content)
if isinstance(converted_content, list):
blocks.extend(converted_content)
else:
blocks.append({"type": "text", "text": str(content)})
for tc in m.get("tool_calls", []):
if not tc or not isinstance(tc, dict):
continue
fn = tc.get("function", {})
args = fn.get("arguments", "{}")
try:
parsed_args = json.loads(args) if isinstance(args, str) else args
except (json.JSONDecodeError, ValueError):
parsed_args = {}
blocks.append({
"type": "tool_use",
"id": _sanitize_tool_id(tc.get("id", "")),
"name": fn.get("name", ""),
"input": parsed_args,
})
# Kimi's /coding endpoint (Anthropic protocol) requires assistant
# tool-call messages to carry reasoning_content when thinking is
# enabled server-side. Preserve it as a thinking block so Kimi
# can validate the message history. See hermes-agent#13848.
#
# Accept empty string "" — _copy_reasoning_content_for_api()
# injects "" as a tier-3 fallback for Kimi tool-call messages
# that had no reasoning. Kimi requires the field to exist, even
# if empty.
#
# Prepend (not append): Anthropic protocol requires thinking
# blocks before text and tool_use blocks.
#
# Guard: only add when reasoning_details didn't already contribute
# thinking blocks. On native Anthropic, reasoning_details produces
# signed thinking blocks — adding another unsigned one from
# reasoning_content would create a duplicate (same text) that gets
# downgraded to a spurious text block on the last assistant message.
reasoning_content = m.get("reasoning_content")
_already_has_thinking = any(
isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
for b in blocks
)
if isinstance(reasoning_content, str) and not _already_has_thinking:
blocks.insert(0, {"type": "thinking", "thinking": reasoning_content})
# Anthropic rejects empty assistant content
effective = blocks or content
if not effective or effective == "":
effective = [{"type": "text", "text": "(empty)"}]
result.append({"role": "assistant", "content": effective})
continue
if role == "tool":
# Sanitize tool_use_id and ensure non-empty content.
# Computer-use (and other multimodal) tool results arrive as
# either a list of OpenAI-style content parts, or a dict
# marked `_multimodal` with an embedded `content` list. Convert
# both into Anthropic `tool_result` inner blocks (text + image).
multimodal_blocks: Optional[List[Dict[str, Any]]] = None
if isinstance(content, dict) and content.get("_multimodal"):
multimodal_blocks = _content_parts_to_anthropic_blocks(
content.get("content") or []
)
# Fallback text if the conversion produced nothing usable.
if not multimodal_blocks and content.get("text_summary"):
multimodal_blocks = [
{"type": "text", "text": str(content["text_summary"])}
]
elif isinstance(content, list):
converted = _content_parts_to_anthropic_blocks(content)
if any(b.get("type") == "image" for b in converted):
multimodal_blocks = converted
# Back-compat: some callers stash blocks under a private key.
if multimodal_blocks is None:
stashed = m.get("_anthropic_content_blocks")
if isinstance(stashed, list) and stashed:
text_content = content if isinstance(content, str) and content.strip() else None
multimodal_blocks = (
[{"type": "text", "text": text_content}] + stashed
if text_content else list(stashed)
)
if multimodal_blocks:
result_content: Any = multimodal_blocks
elif isinstance(content, str):
result_content = content
else:
result_content = json.dumps(content) if content else "(no output)"
if not result_content:
result_content = "(no output)"
tool_result = {
"type": "tool_result",
"tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")),
"content": result_content,
}
if isinstance(m.get("cache_control"), dict):
tool_result["cache_control"] = dict(m["cache_control"])
# Merge consecutive tool results into one user message
if (
result
and result[-1]["role"] == "user"
and isinstance(result[-1]["content"], list)
and result[-1]["content"]
and result[-1]["content"][0].get("type") == "tool_result"
):
result[-1]["content"].append(tool_result)
else:
result.append({"role": "user", "content": [tool_result]})
continue
# Regular user message — validate non-empty content (Anthropic rejects empty)
content = m.get("content", "")
blocks = _extract_preserved_thinking_blocks(m)
if content:
if isinstance(content, list):
converted_blocks = _convert_content_to_anthropic(content)
# Check if all text blocks are empty
if not converted_blocks or all(
b.get("text", "").strip() == ""
for b in converted_blocks
if isinstance(b, dict) and b.get("type") == "text"
):
converted_blocks = [{"type": "text", "text": "(empty message)"}]
result.append({"role": "user", "content": converted_blocks})
converted_content = _convert_content_to_anthropic(content)
if isinstance(converted_content, list):
blocks.extend(converted_content)
else:
# Validate string content is non-empty
if not content or (isinstance(content, str) and not content.strip()):
content = "(empty message)"
result.append({"role": "user", "content": content})
blocks.append({"type": "text", "text": str(content)})
for tc in m.get("tool_calls", []):
if not tc or not isinstance(tc, dict):
continue
fn = tc.get("function", {})
args = fn.get("arguments", "{}")
try:
parsed_args = json.loads(args) if isinstance(args, str) else args
except (json.JSONDecodeError, ValueError):
parsed_args = {}
blocks.append({
"type": "tool_use",
"id": _sanitize_tool_id(tc.get("id", "")),
"name": fn.get("name", ""),
"input": parsed_args,
})
# Kimi's /coding endpoint (Anthropic protocol) requires assistant
# tool-call messages to carry reasoning_content when thinking is
# enabled server-side. Preserve it as a thinking block so Kimi
# can validate the message history. See hermes-agent#13848.
#
# Accept empty string "" — _copy_reasoning_content_for_api()
# injects "" as a tier-3 fallback for Kimi tool-call messages
# that had no reasoning. Kimi requires the field to exist, even
# if empty.
#
# Prepend (not append): Anthropic protocol requires thinking
# blocks before text and tool_use blocks.
#
# Guard: only add when reasoning_details didn't already contribute
# thinking blocks. On native Anthropic, reasoning_details produces
# signed thinking blocks — adding another unsigned one from
# reasoning_content would create a duplicate (same text) that gets
# downgraded to a spurious text block on the last assistant message.
reasoning_content = m.get("reasoning_content")
_already_has_thinking = any(
isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
for b in blocks
)
if isinstance(reasoning_content, str) and not _already_has_thinking:
blocks.insert(0, {"type": "thinking", "thinking": reasoning_content})
# Anthropic rejects empty assistant content
effective = blocks or content
if not effective or effective == "":
effective = [{"type": "text", "text": "(empty)"}]
return {"role": "assistant", "content": effective}
def _convert_tool_message_to_result(
result: List[Dict[str, Any]], m: Dict[str, Any]
) -> None:
"""Convert a tool message to an Anthropic tool_result, merging consecutive
results into one user message.
Mutates ``result`` in place — either appends a new user message or extends
the trailing user message's tool_result list.
"""
content = m.get("content", "")
multimodal_blocks: Optional[List[Dict[str, Any]]] = None
if isinstance(content, dict) and content.get("_multimodal"):
multimodal_blocks = _content_parts_to_anthropic_blocks(
content.get("content") or []
)
# Fallback text if the conversion produced nothing usable.
if not multimodal_blocks and content.get("text_summary"):
multimodal_blocks = [
{"type": "text", "text": str(content["text_summary"])}
]
elif isinstance(content, list):
converted = _content_parts_to_anthropic_blocks(content)
if any(b.get("type") == "image" for b in converted):
multimodal_blocks = converted
# Back-compat: some callers stash blocks under a private key.
if multimodal_blocks is None:
stashed = m.get("_anthropic_content_blocks")
if isinstance(stashed, list) and stashed:
text_content = content if isinstance(content, str) and content.strip() else None
multimodal_blocks = (
[{"type": "text", "text": text_content}] + stashed
if text_content else list(stashed)
)
if multimodal_blocks:
result_content: Any = multimodal_blocks
elif isinstance(content, str):
result_content = content
else:
result_content = json.dumps(content) if content else "(no output)"
if not result_content:
result_content = "(no output)"
tool_result = {
"type": "tool_result",
"tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")),
"content": result_content,
}
if isinstance(m.get("cache_control"), dict):
tool_result["cache_control"] = dict(m["cache_control"])
# Merge consecutive tool results into one user message
if (
result
and result[-1]["role"] == "user"
and isinstance(result[-1]["content"], list)
and result[-1]["content"]
and result[-1]["content"][0].get("type") == "tool_result"
):
result[-1]["content"].append(tool_result)
else:
result.append({"role": "user", "content": [tool_result]})
def _convert_user_message(content: Any) -> Dict[str, Any]:
"""Validate and convert a user message to anthropic format."""
if isinstance(content, list):
converted_blocks = _convert_content_to_anthropic(content)
if not converted_blocks or all(
b.get("text", "").strip() == ""
for b in converted_blocks
if isinstance(b, dict) and b.get("type") == "text"
):
converted_blocks = [{"type": "text", "text": "(empty message)"}]
return {"role": "user", "content": converted_blocks}
else:
if not content or (isinstance(content, str) and not content.strip()):
content = "(empty message)"
return {"role": "user", "content": content}
def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
"""Strip tool_use blocks with no matching tool_result, and vice versa.
Context compression or session truncation can remove either side of a
tool-call pair. Anthropic rejects both orphans with HTTP 400.
Mutates ``result`` in place.
"""
# Strip orphaned tool_use blocks (no matching tool_result follows)
tool_result_ids = set()
for m in result:
@@ -1799,10 +1797,7 @@ def convert_messages_to_anthropic(
if not m["content"]:
m["content"] = [{"type": "text", "text": "(tool call removed)"}]
# Strip orphaned tool_result blocks (no matching tool_use precedes them).
# This is the mirror of the above: context compression or session truncation
# can remove an assistant message containing a tool_use while leaving the
# subsequent tool_result intact. Anthropic rejects these with a 400.
# Strip orphaned tool_result blocks (no matching tool_use precedes them)
tool_use_ids = set()
for m in result:
if m["role"] == "assistant" and isinstance(m["content"], list):
@@ -1819,12 +1814,16 @@ def convert_messages_to_anthropic(
if not m["content"]:
m["content"] = [{"type": "text", "text": "(tool result removed)"}]
# Enforce strict role alternation (Anthropic rejects consecutive same-role messages)
def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Merge consecutive same-role messages to enforce Anthropic alternation.
Returns a new list (caller must rebind ``result``).
"""
fixed = []
for m in result:
if fixed and fixed[-1]["role"] == m["role"]:
if m["role"] == "user":
# Merge consecutive user messages
prev_content = fixed[-1]["content"]
curr_content = m["content"]
if isinstance(prev_content, str) and isinstance(curr_content, str):
@@ -1832,7 +1831,6 @@ def convert_messages_to_anthropic(
elif isinstance(prev_content, list) and isinstance(curr_content, list):
fixed[-1]["content"] = prev_content + curr_content
else:
# Mixed types — wrap string in list
if isinstance(prev_content, str):
prev_content = [{"type": "text", "text": prev_content}]
if isinstance(curr_content, str):
@@ -1855,7 +1853,6 @@ def convert_messages_to_anthropic(
elif isinstance(prev_blocks, str) and isinstance(curr_blocks, str):
fixed[-1]["content"] = prev_blocks + "\n" + curr_blocks
else:
# Mixed types — normalize both to list and merge
if isinstance(prev_blocks, str):
prev_blocks = [{"type": "text", "text": prev_blocks}]
if isinstance(curr_blocks, str):
@@ -1863,37 +1860,34 @@ def convert_messages_to_anthropic(
fixed[-1]["content"] = prev_blocks + curr_blocks
else:
fixed.append(m)
result = fixed
return fixed
# ── Thinking block signature management ──────────────────────────
# Anthropic signs thinking blocks against the full turn content.
# Any upstream mutation (context compression, session truncation,
# orphan stripping, message merging) invalidates the signature,
# causing HTTP 400 "Invalid signature in thinking block".
#
# Signatures are Anthropic-proprietary. Third-party endpoints
# (MiniMax, Microsoft Foundry, self-hosted proxies) cannot validate
# them and will reject them outright. When targeting a third-party
# endpoint, strip ALL thinking/redacted_thinking blocks from every
# assistant message — the third-party will generate its own
# thinking blocks if it supports extended thinking.
#
# For direct Anthropic (strategy following clawdbot/OpenClaw):
# 1. Strip thinking/redacted_thinking from all assistant messages
# EXCEPT the last one — preserves reasoning continuity on the
# current tool-use chain while avoiding stale signature errors.
# 2. Downgrade unsigned thinking blocks (no signature) to text —
# Anthropic can't validate them and will reject them.
# 3. Strip cache_control from thinking/redacted_thinking blocks —
# cache markers can interfere with signature validation.
def _manage_thinking_signatures(
result: List[Dict[str, Any]], base_url: str | None, model: str | None
) -> None:
"""Strip or preserve thinking blocks based on endpoint type.
Anthropic signs thinking blocks against the full turn content.
Any upstream mutation (context compression, session truncation, orphan
stripping, message merging) invalidates the signature, causing HTTP 400
"Invalid signature in thinking block".
Signatures are Anthropic-proprietary. Third-party endpoints (MiniMax,
Azure AI Foundry, AWS Bedrock, self-hosted proxies) cannot validate them
and will reject them outright. Kimi's /coding and DeepSeek's /anthropic
endpoints speak the Anthropic protocol upstream but require unsigned
thinking blocks (synthesised from ``reasoning_content``) to round-trip on
replayed assistant tool-call messages. See hermes-agent#13848 (Kimi) and
hermes-agent#16748 (DeepSeek).
Mutates ``result`` in place.
"""
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
_is_third_party = _is_third_party_anthropic_endpoint(base_url)
# Kimi /coding and DeepSeek /anthropic share a contract: both speak the
# Anthropic Messages protocol upstream but require that thinking blocks
# synthesised from reasoning_content round-trip on subsequent turns when
# thinking is enabled. Signed Anthropic blocks still have to be stripped
# (neither endpoint can validate Anthropic's signatures); unsigned blocks
# are preserved. See hermes-agent#13848 (Kimi) and #16748 (DeepSeek).
# Kimi / DeepSeek share a contract: strip signed Anthropic blocks
# (neither upstream can validate Anthropic signatures), preserve unsigned
# ones synthesised from reasoning_content. See #13848, #16748.
_preserve_unsigned_thinking = (
_is_kimi_family_endpoint(base_url, model)
or _is_deepseek_anthropic_endpoint(base_url)
@@ -1910,26 +1904,19 @@ def convert_messages_to_anthropic(
continue
if _preserve_unsigned_thinking:
# Kimi's /coding and DeepSeek's /anthropic endpoints both enable
# thinking server-side and require unsigned thinking blocks on
# replayed assistant tool-call messages. Strip signed Anthropic
# blocks (neither upstream can validate Anthropic signatures) but
# preserve the unsigned ones we synthesised from reasoning_content.
# Kimi / DeepSeek: strip signed, preserve unsigned.
new_content = []
for b in m["content"]:
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
new_content.append(b)
continue
if b.get("signature") or b.get("data"):
# Anthropic-signed block — upstream can't validate, strip
# Signed (or redacted-with-data) — upstream can't validate, strip.
continue
# Unsigned thinking (synthesised from reasoning_content) —
# keep it: the upstream needs it for message-history validation.
new_content.append(b)
m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
elif _is_third_party or idx != last_assistant_idx:
# Third-party endpoint: strip ALL thinking blocks from every
# assistant message — signatures are Anthropic-proprietary.
# Third-party: strip ALL thinking blocks (signatures are proprietary).
# Direct Anthropic: strip from non-latest assistant messages only.
stripped = [
b for b in m["content"]
@@ -1937,24 +1924,21 @@ def convert_messages_to_anthropic(
]
m["content"] = stripped or [{"type": "text", "text": "(thinking elided)"}]
else:
# Latest assistant on direct Anthropic: keep signed thinking
# blocks for reasoning continuity; downgrade unsigned ones to
# plain text.
# Latest assistant on direct Anthropic: keep signed, downgrade unsigned
# to text so the reasoning isn't lost.
new_content = []
for b in m["content"]:
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
new_content.append(b)
continue
if b.get("type") == "redacted_thinking":
# Redacted blocks use 'data' for the signature payload
# Redacted blocks use 'data' for the signature payload
# drop the block when 'data' is missing (can't be validated).
if b.get("data"):
new_content.append(b)
# else: drop — no data means it can't be validated
elif b.get("signature"):
# Signed thinking block — keep it
new_content.append(b)
else:
# Unsigned thinking — downgrade to text so it's not lost
thinking_text = b.get("thinking", "")
if thinking_text:
new_content.append({"type": "text", "text": thinking_text})
@@ -1966,12 +1950,15 @@ def convert_messages_to_anthropic(
if isinstance(b, dict) and b.get("type") in _THINKING_TYPES:
b.pop("cache_control", None)
# ── Image eviction: keep only the most recent N screenshots ─────
# computer_use screenshots (base64 images) sit inside tool_result
# blocks: they accumulate and are sent with every API call. Each
# costs ~1,465 tokens; after 10+ the conversation becomes slow
# even for simple text queries. Walk backward, keep the most recent
# _MAX_KEEP_IMAGES, replace older ones with a text placeholder.
def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
"""Keep only the most recent ``_MAX_KEEP_IMAGES`` computer-use screenshots.
Base64 images cost ~1,465 tokens each and accumulate across tool calls.
Walk backward, keep the most recent N, replace older ones with a placeholder.
Mutates ``result`` in place.
"""
_MAX_KEEP_IMAGES = 3
_image_count = 0
for msg in reversed(result):
@@ -1998,6 +1985,68 @@ def convert_messages_to_anthropic(
for b in inner
]
def convert_messages_to_anthropic(
messages: List[Dict],
base_url: str | None = None,
model: str | None = None,
) -> Tuple[Optional[Any], List[Dict]]:
"""Convert OpenAI-format messages to Anthropic format.
Returns (system_prompt, anthropic_messages).
System messages are extracted since Anthropic takes them as a separate param.
system_prompt is a string or list of content blocks (when cache_control present).
When *base_url* is provided and points to a third-party Anthropic-compatible
endpoint, all thinking block signatures are stripped. Signatures are
Anthropic-proprietary — third-party endpoints cannot validate them and will
reject them with HTTP 400 "Invalid signature in thinking block".
When *model* is provided and matches the Kimi / Moonshot family (or
*base_url* is a Kimi / Moonshot host), unsigned thinking blocks
synthesised from ``reasoning_content`` are preserved on replayed
assistant tool-call messages — Kimi requires the field to exist, even
if empty.
"""
system = None
result: List[Dict[str, Any]] = []
for m in messages:
role = m.get("role", "user")
content = m.get("content", "")
if role == "system":
if isinstance(content, list):
# Preserve cache_control markers on content blocks
has_cache = any(
p.get("cache_control") for p in content if isinstance(p, dict)
)
if has_cache:
system = [p for p in content if isinstance(p, dict)]
else:
system = "\n".join(
p["text"] for p in content if p.get("type") == "text"
)
else:
system = content
continue
if role == "assistant":
result.append(_convert_assistant_message(m))
continue
if role == "tool":
_convert_tool_message_to_result(result, m)
continue
# Regular user message
result.append(_convert_user_message(content))
_strip_orphaned_tool_blocks(result)
result = _merge_consecutive_roles(result)
_manage_thinking_signatures(result, base_url, model)
_evict_old_screenshots(result)
return system, result
@@ -2098,9 +2147,13 @@ def build_anthropic_kwargs(
block["text"] = text
# 3. Prefix tool names with mcp_ (Claude Code convention)
# Skip names that already begin with the marker — native MCP server
# tools (from mcp_servers: in config.yaml) are registered under their
# full mcp_<server>_<tool> name and would double-prefix otherwise,
# breaking round-trip registry lookup in normalize_response. GH-25255.
if anthropic_tools:
for tool in anthropic_tools:
if "name" in tool:
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
# 4. Prefix tool names in message history (tool_use and tool_result blocks)
+117 -3
View File
@@ -3260,7 +3260,7 @@ def resolve_provider_client(
if client is None:
logger.warning(
"resolve_provider_client: xai-oauth requested but no xAI "
"OAuth token found (run: hermes model -> xAI Grok OAuth — SuperGrok Subscription)"
"OAuth token found (run: hermes model -> xAI Grok OAuth — SuperGrok / Premium+)"
)
return None, None
final_model = _normalize_resolved_model(model or default, provider)
@@ -3730,6 +3730,37 @@ _VISION_AUTO_PROVIDER_ORDER = (
)
def _main_model_supports_vision(provider: str, model: Optional[str]) -> bool:
"""Return True when ``provider``/``model`` is known to accept image input.
Used by the vision auto-detect chain to skip the user's main provider
when it's known to be text-only (e.g. DeepSeek, gpt-oss without vision).
Without this guard, ``resolve_vision_provider_client(provider="auto")``
would happily return the main-provider client and any subsequent image
payload would surface as a cryptic provider-side error
(``unknown variant `image_url`, expected `text```, #31179).
Returns True when capability lookup is unknown preserves the historical
behaviour of attempting the call, so providers we haven't catalogued yet
don't silently regress to text-only.
"""
try:
from agent.image_routing import _lookup_supports_vision
from hermes_cli.config import load_config
except ImportError:
return True
try:
supports = _lookup_supports_vision(provider, model, load_config())
except Exception: # pragma: no cover - defensive
return True
if supports is None:
# No capability data — keep current behaviour and let the call attempt
# happen rather than silently skipping. This avoids false-positive
# skips for new/custom providers.
return True
return bool(supports)
def _normalize_vision_provider(provider: Optional[str]) -> str:
return _normalize_aux_provider(provider)
@@ -3870,6 +3901,23 @@ def resolve_vision_provider_client(
"vision support) — falling through to aggregator chain",
main_provider,
)
elif not _main_model_supports_vision(main_provider, vision_model):
# The main model is known to be text-only (e.g. DeepSeek V4,
# gpt-oss-120b without vision). Building a client and sending
# an image would produce a cryptic provider-side error like
# ``unknown variant `image_url`, expected `text``` (#31179).
# Fall through to the aggregator chain instead.
#
# Only log the provider name (not the model) — mirrors the
# sibling _PROVIDERS_WITHOUT_VISION branch above, and avoids
# CodeQL py/clear-text-logging-sensitive-data heuristic false
# positives on multi-value interpolations.
logger.debug(
"Vision auto-detect: skipping main provider %s "
"(reports no vision capability) — falling through to "
"aggregator chain",
main_provider,
)
else:
rpc_client, rpc_model = resolve_provider_client(
main_provider, vision_model,
@@ -4281,6 +4329,23 @@ def _get_cached_client(
return client, model or default_model
# Aliases that target direct REST APIs not modeled as first-class providers
# in PROVIDER_REGISTRY. Used for ``auxiliary.<task>.provider`` so users can
# write the obvious name and have it resolve to a working ``custom`` endpoint
# without needing to know our internal provider IDs.
#
# Why these specifically: PROVIDER_REGISTRY has ``openai-codex`` (OAuth) and
# ``custom`` (manual base_url + OPENAI_API_KEY) but no plain ``openai`` for
# direct API-key access. Users predictably type ``provider: openai`` and
# expect it to use OPENAI_API_KEY against api.openai.com. Previously this
# silently fell back to the user's main provider, sending OpenAI model names
# to e.g. DeepSeek and producing cryptic ``unknown variant 'image_url'``
# errors (issue #31179).
_AUX_DIRECT_API_BASE_URLS: Dict[str, str] = {
"openai": "https://api.openai.com/v1",
}
def _resolve_task_provider_model(
task: str = None,
provider: str = None,
@@ -4317,6 +4382,25 @@ def _resolve_task_provider_model(
resolved_model = model or cfg_model
resolved_api_mode = cfg_api_mode
# Convenience aliases for direct API-key endpoints that aren't first-class
# providers (e.g. ``provider: openai`` → custom + api.openai.com/v1).
# Applied to both explicit args and config-derived values. When the user
# has already supplied a base_url we keep their endpoint but still rewrite
# the provider to ``custom`` so resolution doesn't hit the
# PROVIDER_REGISTRY-only path (which has no ``openai`` entry).
def _expand_direct_api_alias(prov: Optional[str], existing_base: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
if not prov:
return prov, existing_base
target_base = _AUX_DIRECT_API_BASE_URLS.get(prov.strip().lower())
if target_base is None:
return prov, existing_base
return "custom", existing_base or target_base
if provider:
provider, base_url = _expand_direct_api_alias(provider, base_url)
if cfg_provider:
cfg_provider, cfg_base_url = _expand_direct_api_alias(cfg_provider, cfg_base_url)
if base_url:
return "custom", resolved_model, base_url, api_key, resolved_api_mode
if provider:
@@ -4344,7 +4428,17 @@ _DEFAULT_AUX_TIMEOUT = 30.0
def _get_auxiliary_task_config(task: str) -> Dict[str, Any]:
"""Return the config dict for auxiliary.<task>, or {} when unavailable."""
"""Return the config dict for auxiliary.<task>, or {} when unavailable.
For plugin-registered auxiliary tasks (see
:meth:`hermes_cli.plugins.PluginContext.register_auxiliary_task`) the
plugin's declared *defaults* are layered underneath the user's config
so an unconfigured plugin task still works:
plugin defaults config.yaml auxiliary.<task> (user wins)
Built-in tasks ignore this path (their defaults live in DEFAULT_CONFIG).
"""
if not task:
return {}
try:
@@ -4354,7 +4448,27 @@ def _get_auxiliary_task_config(task: str) -> Dict[str, Any]:
return {}
aux = config.get("auxiliary", {}) if isinstance(config, dict) else {}
task_config = aux.get(task, {}) if isinstance(aux, dict) else {}
return task_config if isinstance(task_config, dict) else {}
if not isinstance(task_config, dict):
task_config = {}
# Layer plugin-declared defaults underneath user config so
# ctx.register_auxiliary_task(defaults={...}) takes effect without
# forcing the user to write config.yaml entries.
try:
from hermes_cli.plugins import get_plugin_auxiliary_tasks
for _entry in get_plugin_auxiliary_tasks():
if _entry.get("key") == task:
_defaults = _entry.get("defaults") or {}
if isinstance(_defaults, dict):
merged = dict(_defaults)
merged.update(task_config)
return merged
break
except Exception:
# Plugin discovery failure must not break aux task config reads.
pass
return task_config
def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float:
+8 -2
View File
@@ -115,7 +115,10 @@ _SKILL_REVIEW_PROMPT = (
"Protected skills (DO NOT edit these):\n"
" • Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n"
" • Hub-installed skills (installed via 'hermes skills install').\n"
"Pinned skills (marked via 'hermes curator pin').\n"
"Pinned skills (marked via 'hermes curator pin') CAN be improved — "
"pin only blocks deletion/archive/consolidation by the curator, not "
"content updates. Patch them when a pitfall or missing step turns up, "
"same as any other agent-created skill.\n"
"If the only skills that need updating are protected, say\n"
"'Nothing to save.' and stop.\n\n"
"Do NOT capture (these become persistent self-imposed constraints "
@@ -198,7 +201,10 @@ _COMBINED_REVIEW_PROMPT = (
"Protected skills (DO NOT edit these):\n"
" • Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n"
" • Hub-installed skills (installed via 'hermes skills install').\n"
"Pinned skills (marked via 'hermes curator pin').\n"
"Pinned skills (marked via 'hermes curator pin') CAN be improved — "
"pin only blocks deletion/archive/consolidation by the curator, not "
"content updates. Patch them when a pitfall or missing step turns up, "
"same as any other agent-created skill.\n"
"If the only skills that need updating are protected, say\n"
"'Nothing to save.' and stop.\n\n"
"Do NOT capture as skills (these become persistent self-imposed "
+118 -22
View File
@@ -91,23 +91,55 @@ def interruptible_api_call(agent, api_kwargs: dict):
provider fallback.
"""
result = {"response": None, "error": None}
request_client_holder = {"client": None}
request_client_holder = {"client": None, "owner_tid": None}
request_client_lock = threading.Lock()
def _set_request_client(client):
with request_client_lock:
request_client_holder["client"] = client
# #29507: stamp the owning thread so a stranger-thread interrupt
# only shuts the connection down rather than racing the worker
# for FD ownership during ``client.close()``.
request_client_holder["owner_tid"] = threading.get_ident()
return client
def _take_request_client():
with request_client_lock:
client = request_client_holder.get("client")
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
return client
def _close_request_client_once(reason: str) -> None:
request_client = _take_request_client()
if request_client is not None:
# #29507: dispatch on the calling thread.
#
# When ``_call`` (the worker) reaches its ``finally`` it owns the
# close and we pop + fully close as before. When a *stranger* thread
# (the interrupt-check loop, the stale-call detector) drives the
# close, only shut the sockets down so the worker's blocked
# ``recv``/``send`` unwinds with an ``EPIPE`` / EOF — and let the
# worker close ``client`` from its own thread on its way out. That
# avoids the FD-recycling race where the kernel reassigned a
# just-closed TLS socket FD to ``kanban.db``, and the still-live SSL
# BIO on the worker thread then wrote a 24-byte TLS application-data
# record into the SQLite header (#29507).
with request_client_lock:
request_client = request_client_holder.get("client")
owner_tid = request_client_holder.get("owner_tid")
stranger_thread = (
request_client is not None
and owner_tid is not None
and owner_tid != threading.get_ident()
)
if not stranger_thread:
# Owning thread (or no recorded owner) → pop and fully close.
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
if request_client is None:
return
if stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)
def _call():
@@ -549,6 +581,17 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
if isinstance(_san_content, str) and _san_content:
_san_content = agent._strip_think_blocks(_san_content).strip()
# Defence-in-depth: redact credentials (PATs, API keys, Bearer tokens)
# from assistant content BEFORE the message enters conversation history.
# If the model accidentally inlines a secret in its natural-language
# response, catch it here at the persistence boundary so it never
# reaches state.db, session_*.json, gateway delivery, or compression.
# Respects HERMES_REDACT_SECRETS via redact_sensitive_text — no-op
# when disabled. (#19798)
if isinstance(_san_content, str) and _san_content:
from agent.redact import redact_sensitive_text
_san_content = redact_sensitive_text(_san_content)
msg = {
"role": "assistant",
"content": _san_content,
@@ -670,6 +713,18 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
"arguments": tool_call.function.arguments
},
}
# Defence-in-depth: redact credentials from tool call arguments
# before they enter conversation history. Tool execution uses the
# raw API response object, not this dict, so redacting the
# persisted shape is safe and only affects storage. Catches the
# case where a model accidentally inlines a secret into a tool
# call (e.g. `terminal(command="curl -H 'Authorization: Bearer
# sk-...'")`). (#19798)
if isinstance(tc_dict["function"]["arguments"], str):
from agent.redact import redact_sensitive_text
tc_dict["function"]["arguments"] = redact_sensitive_text(
tc_dict["function"]["arguments"]
)
# Preserve extra_content (e.g. Gemini thought_signature) so it
# is sent back on subsequent API calls. Without this, Gemini 3
# thinking models reject the request with a 400 error.
@@ -725,7 +780,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
current_base_url = str(getattr(agent, "base_url", "") or "").rstrip("/").lower()
fb_base_url_for_dedup = (fb.get("base_url") or "").strip().rstrip("/").lower()
if fb_provider == current_provider and fb_model == current_model:
logging.warning(
logger.warning(
"Fallback skip: chain entry %s/%s matches current provider/model",
fb_provider, fb_model,
)
@@ -736,7 +791,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
and fb_base_url_for_dedup == current_base_url
and fb_model == current_model
):
logging.warning(
logger.warning(
"Fallback skip: chain entry base_url %s matches current backend",
fb_base_url_for_dedup,
)
@@ -768,7 +823,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
explicit_base_url=fb_base_url_hint,
explicit_api_key=fb_api_key_hint)
if fb_client is None:
logging.warning(
logger.warning(
"Fallback to %s failed: provider not configured",
fb_provider)
return agent._try_activate_fallback() # try next in chain
@@ -776,8 +831,11 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
from hermes_cli.model_normalize import normalize_model_for_provider
fb_model = normalize_model_for_provider(fb_model, fb_provider)
except Exception:
pass
except Exception as _norm_err:
logger.warning(
"Could not normalize fallback model %r for provider %r: %s",
fb_model, fb_provider, _norm_err,
)
# Determine api_mode from provider / base URL / model
fb_api_mode = "chat_completions"
@@ -905,19 +963,20 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
base_url=agent.base_url,
api_key=getattr(agent, "api_key", ""), # callable preserved → call_llm
provider=agent.provider,
api_mode=agent.api_mode,
)
agent._emit_status(
f"🔄 Primary model failed — switching to fallback: "
f"{fb_model} via {fb_provider}"
)
logging.info(
logger.info(
"Fallback activated: %s%s (%s)",
old_model, fb_model, fb_provider,
)
return True
except Exception as e:
logging.error("Failed to activate fallback %s: %s", fb_model, e)
logger.error("Failed to activate fallback %s: %s", fb_model, e)
return agent._try_activate_fallback() # try next in chain
@@ -1133,7 +1192,7 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
final_response = "I reached the iteration limit and couldn't generate a summary."
except Exception as e:
logging.warning(f"Failed to get summary response: {e}")
logger.warning(f"Failed to get summary response: {e}")
final_response = f"I reached the maximum iterations ({agent.max_iterations}) but couldn't summarize. Error: {str(e)}"
return final_response
@@ -1162,12 +1221,12 @@ def cleanup_task_resources(agent, task_id: str) -> None:
_ra().cleanup_vm(task_id)
except Exception as e:
if agent.verbose_logging:
logging.warning(f"Failed to cleanup VM for task {task_id}: {e}")
logger.warning(f"Failed to cleanup VM for task {task_id}: {e}")
try:
_ra().cleanup_browser(task_id)
except Exception as e:
if agent.verbose_logging:
logging.warning(f"Failed to cleanup browser for task {task_id}: {e}")
logger.warning(f"Failed to cleanup browser for task {task_id}: {e}")
@@ -1271,23 +1330,44 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
return result["response"]
result = {"response": None, "error": None, "partial_tool_names": []}
request_client_holder = {"client": None, "diag": None}
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
request_client_lock = threading.Lock()
def _set_request_client(client):
with request_client_lock:
request_client_holder["client"] = client
# See #29507 explanation in the non-streaming variant above.
request_client_holder["owner_tid"] = threading.get_ident()
return client
def _take_request_client():
with request_client_lock:
client = request_client_holder.get("client")
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
return client
def _close_request_client_once(reason: str) -> None:
request_client = _take_request_client()
if request_client is not None:
# See #29507 explanation in the non-streaming variant above. A
# stranger thread (the interrupt-check / stale-stream detector loop)
# only aborts sockets — never pops, never calls ``client.close()`` —
# so the worker thread retains ownership of the FD release.
with request_client_lock:
request_client = request_client_holder.get("client")
owner_tid = request_client_holder.get("owner_tid")
stranger_thread = (
request_client is not None
and owner_tid is not None
and owner_tid != threading.get_ident()
)
if not stranger_thread:
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
if request_client is None:
return
if stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)
first_delta_fired = {"done": False}
@@ -2020,8 +2100,21 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# Streaming failed AFTER some tokens were already delivered to
# the platform. Re-raising would let the outer retry loop make
# a new API call, creating a duplicate message. Return a
# partial "stop" response instead so the outer loop treats this
# turn as complete (no retry, no fallback).
# partial response stub instead and let the outer loop decide:
#
# - text-only partials → finish_reason="length" so the
# conversation loop persists the partial assistant content
# and asks the model to continue from where the stream
# died (issue #30963: partial stop misclassified as a
# clean completion was exiting the loop with budget
# remaining and an unfinished goal).
#
# - partial mid-tool-call → finish_reason="stop" stays.
# The user-visible warning we append says "Ask me to
# retry if you want to continue", so the agent should
# hand control back rather than auto-retry a tool call
# that may have side-effects.
#
# Recover whatever content was already streamed to the user.
# _current_streamed_assistant_text accumulates text fired
# through _fire_stream_delta, so it has exactly what the
@@ -2059,14 +2152,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
"of text; surfaced warning to user: %s",
_partial_names, len(_partial_text or ""), result["error"],
)
_stub_finish_reason = "stop"
else:
logger.warning(
"Partial stream delivered before error; returning stub "
"response with %s chars of recovered content to prevent "
"duplicate messages: %s",
"Partial stream delivered before error; returning "
"length-truncated stub with %s chars of recovered "
"content so the loop can continue from where the "
"stream died: %s",
len(_partial_text or ""),
result["error"],
)
_stub_finish_reason = "length"
_stub_msg = SimpleNamespace(
role="assistant", content=_partial_text, tool_calls=None,
reasoning_content=None,
@@ -2075,7 +2171,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
id="partial-stream-stub",
model=getattr(agent, "model", "unknown"),
choices=[SimpleNamespace(
index=0, message=_stub_msg, finish_reason="stop",
index=0, message=_stub_msg, finish_reason=_stub_finish_reason,
)],
usage=None,
)
+4 -3
View File
@@ -501,6 +501,7 @@ class ContextCompressor(ContextEngine):
"""Update tracked token usage from API response."""
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
self.last_completion_tokens = usage.get("completion_tokens", 0)
self.last_total_tokens = usage.get("total_tokens", self.last_prompt_tokens + self.last_completion_tokens)
def should_compress(self, prompt_tokens: int = None) -> bool:
"""Check if context exceeds the compression threshold.
@@ -789,7 +790,7 @@ class ContextCompressor(ContextEngine):
into the warning log.
"""
self._summary_model_fallen_back = True
logging.warning(
logger.warning(
"Summary model '%s' %s (%s). "
"Falling back to main model '%s' for compression.",
self.summary_model, reason, e, self.model,
@@ -978,7 +979,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
# No provider configured — long cooldown, unlikely to self-resolve
self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS
self._last_summary_error = "no auxiliary LLM provider configured"
logging.warning("Context compression: no provider available for "
logger.warning("Context compression: no provider available for "
"summary. Middle turns will be dropped without summary "
"for %d seconds.",
_SUMMARY_FAILURE_COOLDOWN_SECONDS)
@@ -1074,7 +1075,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
if len(err_text) > 220:
err_text = err_text[:217].rstrip() + "..."
self._last_summary_error = err_text
logging.warning(
logger.warning(
"Failed to generate context summary: %s. "
"Further summary attempts paused for %d seconds.",
e,
+1
View File
@@ -200,6 +200,7 @@ class ContextEngine(ABC):
base_url: str = "",
api_key: str = "",
provider: str = "",
api_mode: str = "",
) -> None:
"""Called when the user switches models or on fallback activation.
+4 -4
View File
@@ -381,12 +381,12 @@ def compress_context(
agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
os.environ["HERMES_SESSION_ID"] = agent.session_id
try:
from gateway.session_context import _SESSION_ID
_SESSION_ID.set(agent.session_id)
from gateway.session_context import set_current_session_id
set_current_session_id(agent.session_id)
except Exception:
pass
os.environ["HERMES_SESSION_ID"] = agent.session_id
agent._session_db_created = False
agent._session_db.create_session(
session_id=agent.session_id,
+190 -26
View File
@@ -46,6 +46,7 @@ from agent.message_sanitization import (
_strip_non_ascii,
)
from agent.model_metadata import (
MINIMUM_CONTEXT_LENGTH,
estimate_messages_tokens_rough,
estimate_request_tokens_rough,
get_next_probe_tier,
@@ -73,6 +74,50 @@ from utils import base_url_host_matches, env_var_enabled
logger = logging.getLogger(__name__)
def _ollama_context_limit_error(agent: Any, request_tokens: int) -> Optional[str]:
"""Return a user-facing error when Ollama is loaded with too little context."""
if not getattr(agent, "tools", None):
return None
runtime_ctx = getattr(agent, "_ollama_num_ctx", None)
if not isinstance(runtime_ctx, int) or runtime_ctx <= 0:
return None
if runtime_ctx >= MINIMUM_CONTEXT_LENGTH:
return None
model = getattr(agent, "model", "") or "the selected model"
base_url = getattr(agent, "base_url", "") or "unknown base URL"
provider = getattr(agent, "provider", "") or "unknown"
tool_count = len(getattr(agent, "tools", None) or [])
logger.warning(
"Ollama runtime context too small for Hermes tool use: "
"model=%s provider=%s base_url=%s runtime_context=%d "
"minimum_context=%d estimated_request_tokens=%d tool_count=%d "
"session=%s",
model,
provider,
base_url,
runtime_ctx,
MINIMUM_CONTEXT_LENGTH,
request_tokens,
tool_count,
getattr(agent, "session_id", None) or "none",
)
return (
f"Ollama loaded `{model}` with only {runtime_ctx:,} tokens of runtime "
f"context, but Hermes needs at least {MINIMUM_CONTEXT_LENGTH:,} tokens "
"for reliable tool use.\n\n"
"Increase the Ollama context for this model and restart/reload the "
"model before trying again. A known-good starting point is 65,536 "
"tokens. In Hermes config, set `model.ollama_num_ctx: 65536` "
"(and `model.context_length: 65536` if you also override the displayed "
"model context). If you manage the model through an Ollama Modelfile, "
"set `PARAMETER num_ctx 65536` there instead."
)
def _ra():
"""Lazy reference to ``run_agent`` so callers can patch
``run_agent.handle_function_call`` / ``run_agent._set_interrupt`` /
@@ -527,6 +572,7 @@ def run_conversation(
api_call_count = 0
final_response = None
interrupted = False
failed = False
codex_ack_continuations = 0
length_continue_retries = 0
truncated_tool_call_retries = 0
@@ -883,6 +929,26 @@ def run_conversation(
# Calculate approximate request size for logging
total_chars = sum(len(str(msg)) for msg in api_messages)
approx_tokens = estimate_messages_tokens_rough(api_messages)
approx_request_tokens = estimate_request_tokens_rough(
api_messages, tools=agent.tools or None
)
_runtime_context_error = _ollama_context_limit_error(
agent, approx_request_tokens
)
if _runtime_context_error:
final_response = _runtime_context_error
failed = True
_turn_exit_reason = "ollama_runtime_context_too_small"
messages.append({"role": "assistant", "content": final_response})
agent._emit_status("❌ Ollama runtime context is too small for Hermes tool use")
api_call_count -= 1
agent._api_call_count = api_call_count
try:
agent.iteration_budget.refund()
except Exception:
pass
break
# Thinking spinner for quiet mode (animated during API call)
thinking_spinner = None
@@ -923,6 +989,7 @@ def run_conversation(
copilot_auth_retry_attempted=False
thinking_sig_retry_attempted = False
image_shrink_retry_attempted = False
multimodal_tool_content_retry_attempted = False
oauth_1m_beta_retry_attempted = False
llama_cpp_grammar_retry_attempted = False
has_retried_429 = False
@@ -1116,7 +1183,7 @@ def run_conversation(
else str(_codex_error_obj) if _codex_error_obj
else f"Responses API returned status '{_codex_resp_status}'"
)
logging.warning(
logger.warning(
"Codex response status='%s' (error=%s). Routing to fallback. %s",
_codex_resp_status, _codex_error_msg,
agent._client_log_context(),
@@ -1268,7 +1335,7 @@ def run_conversation(
primary_recovery_attempted = False
continue
agent._emit_status(f"❌ Max retries ({max_retries}) exceeded for invalid responses. Giving up.")
logging.error(f"{agent.log_prefix}Invalid API response after {max_retries} retries.")
logger.error(f"{agent.log_prefix}Invalid API response after {max_retries} retries.")
agent._persist_session(messages, conversation_history)
return {
"messages": messages,
@@ -1281,7 +1348,7 @@ def run_conversation(
# Backoff before retry — jittered exponential: 5s base, 120s cap
wait_time = jittered_backoff(retry_count, base_delay=5.0, max_delay=120.0)
agent._vprint(f"{agent.log_prefix}⏳ Retrying in {wait_time:.1f}s ({_failure_hint})...", force=True)
logging.warning(f"Invalid API response (retry {retry_count}/{max_retries}): {', '.join(error_details)} | Provider: {provider_name}")
logger.warning(f"Invalid API response (retry {retry_count}/{max_retries}): {', '.join(error_details)} | Provider: {provider_name}")
# Sleep in small increments to stay responsive to interrupts
sleep_end = time.time() + wait_time
@@ -1347,7 +1414,18 @@ def run_conversation(
finish_reason = "length"
if finish_reason == "length":
agent._vprint(f"{agent.log_prefix}⚠️ Response truncated (finish_reason='length') - model hit max output tokens", force=True)
if getattr(response, "id", "") == "partial-stream-stub":
agent._vprint(
f"{agent.log_prefix}⚠️ Stream interrupted by network error "
f"(finish_reason='length' on partial-stream-stub)",
force=True,
)
else:
agent._vprint(
f"{agent.log_prefix}⚠️ Response truncated "
f"(finish_reason='length') - model hit max output tokens",
force=True,
)
# Normalize the truncated response to a single OpenAI-style
# message shape so text-continuation and tool-call retry
@@ -1440,17 +1518,40 @@ def run_conversation(
truncated_response_parts.append(assistant_message.content)
if length_continue_retries < 3:
agent._vprint(
f"{agent.log_prefix}↻ Requesting continuation "
f"({length_continue_retries}/3)..."
# Distinguish a real output-token truncation
# from a partial-stream-stub network error
# (#30963). Same continuation machinery,
# but the prompt has to tell the truth or
# the model goes off rails ("I wasn't
# truncated, I'm done").
_is_partial_stream_stub = (
getattr(response, "id", "") == "partial-stream-stub"
)
continue_msg = {
"role": "user",
"content": (
if _is_partial_stream_stub:
agent._vprint(
f"{agent.log_prefix}↻ Stream interrupted — "
f"requesting continuation "
f"({length_continue_retries}/3)..."
)
_continue_content = (
"[System: The previous response was cut off by a "
"network error mid-stream. Continue exactly where "
"you left off. Do not restart or repeat prior text. "
"Finish the answer directly.]"
)
else:
agent._vprint(
f"{agent.log_prefix}↻ Requesting continuation "
f"({length_continue_retries}/3)..."
)
_continue_content = (
"[System: Your previous response was truncated by the output "
"length limit. Continue exactly where you left off. Do not "
"restart or repeat prior text. Finish the answer directly.]"
),
)
continue_msg = {
"role": "user",
"content": _continue_content,
}
messages.append(continue_msg)
agent._session_messages = messages
@@ -1994,6 +2095,31 @@ def run_conversation(
"or shrink didn't reduce size; surfacing original error."
)
# Multimodal-tool-content recovery: providers that follow
# the OpenAI spec strictly (tool message content must be a
# string) reject our list-type content with a 400. Strip
# image parts from any list-type tool messages, mark the
# (provider, model) as no-list-tool-content for the rest
# of this session so future tool results preemptively
# downgrade, and retry once. See issue #27344.
if (
classified.reason == FailoverReason.multimodal_tool_content_unsupported
and not multimodal_tool_content_retry_attempted
):
multimodal_tool_content_retry_attempted = True
if agent._try_strip_image_parts_from_tool_messages(api_messages):
agent._vprint(
f"{agent.log_prefix}📐 Provider rejected list-type tool content — "
f"downgraded screenshots to text and retrying...",
force=True,
)
continue
else:
logger.info(
"multimodal-tool-content recovery: no list-type tool "
"messages with image parts found; surfacing original error."
)
# Anthropic OAuth subscription rejected the 1M-context beta
# header ("long context beta is not yet available for this
# subscription"). Disable the beta for the rest of this
@@ -2133,7 +2259,7 @@ def run_conversation(
f"stripped all thinking blocks, retrying...",
force=True,
)
logging.warning(
logger.warning(
"%sThinking block signature recovery: stripped "
"reasoning_details from %d messages",
agent.log_prefix, len(messages),
@@ -2158,7 +2284,7 @@ def run_conversation(
from tools.schema_sanitizer import strip_pattern_and_format
_, _stripped = strip_pattern_and_format(agent.tools)
except Exception as _strip_exc: # pragma: no cover — defensive
logging.warning(
logger.warning(
"%sllama.cpp grammar recovery: strip helper failed: %s",
agent.log_prefix, _strip_exc,
)
@@ -2169,7 +2295,7 @@ def run_conversation(
f"stripped {_stripped} pattern/format keyword(s), retrying...",
force=True,
)
logging.warning(
logger.warning(
"%sllama.cpp grammar recovery: stripped %d "
"pattern/format keyword(s) from tool schemas",
agent.log_prefix, _stripped,
@@ -2177,7 +2303,7 @@ def run_conversation(
continue
# No keywords found to strip — fall through to normal
# retry path rather than loop forever on the same error.
logging.warning(
logger.warning(
"%sllama.cpp grammar error but no pattern/format "
"keywords to strip — falling through to normal retry",
agent.log_prefix,
@@ -2278,6 +2404,7 @@ def run_conversation(
base_url=agent.base_url,
api_key=getattr(agent, "api_key", ""),
provider=agent.provider,
api_mode=agent.api_mode,
)
# Context probing flags — only set on built-in
# compressor (plugin engines manage their own).
@@ -2391,7 +2518,7 @@ def run_conversation(
error_context=error_context,
)
else:
logging.info(
logger.info(
"Nous 429 looks like upstream capacity "
"(no exhausted bucket in headers or "
"last-known state) -- not tripping "
@@ -2451,7 +2578,7 @@ def run_conversation(
if compression_attempts > max_compression_attempts:
agent._vprint(f"{agent.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached for payload-too-large error.", force=True)
agent._vprint(f"{agent.log_prefix} 💡 Try /new to start a fresh conversation, or /compress to retry compression.", force=True)
logging.error(f"{agent.log_prefix}413 compression failed after {max_compression_attempts} attempts.")
logger.error(f"{agent.log_prefix}413 compression failed after {max_compression_attempts} attempts.")
agent._persist_session(messages, conversation_history)
return {
"messages": messages,
@@ -2482,7 +2609,7 @@ def run_conversation(
else:
agent._vprint(f"{agent.log_prefix}❌ Payload too large and cannot compress further.", force=True)
agent._vprint(f"{agent.log_prefix} 💡 Try /new to start a fresh conversation, or /compress to retry compression.", force=True)
logging.error(f"{agent.log_prefix}413 payload too large. Cannot compress further.")
logger.error(f"{agent.log_prefix}413 payload too large. Cannot compress further.")
agent._persist_session(messages, conversation_history)
return {
"messages": messages,
@@ -2535,7 +2662,7 @@ def run_conversation(
if compression_attempts > max_compression_attempts:
agent._vprint(f"{agent.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached.", force=True)
agent._vprint(f"{agent.log_prefix} 💡 Try /new to start a fresh conversation, or /compress to retry compression.", force=True)
logging.error(f"{agent.log_prefix}Context compression failed after {max_compression_attempts} attempts.")
logger.error(f"{agent.log_prefix}Context compression failed after {max_compression_attempts} attempts.")
agent._persist_session(messages, conversation_history)
return {
"messages": messages,
@@ -2587,6 +2714,7 @@ def run_conversation(
base_url=agent.base_url,
api_key=getattr(agent, "api_key", ""),
provider=agent.provider,
api_mode=agent.api_mode,
)
# Context probing flags — only set on built-in
# compressor (plugin engines manage their own).
@@ -2608,7 +2736,7 @@ def run_conversation(
if compression_attempts > max_compression_attempts:
agent._vprint(f"{agent.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached.", force=True)
agent._vprint(f"{agent.log_prefix} 💡 Try /new to start a fresh conversation, or /compress to retry compression.", force=True)
logging.error(f"{agent.log_prefix}Context compression failed after {max_compression_attempts} attempts.")
logger.error(f"{agent.log_prefix}Context compression failed after {max_compression_attempts} attempts.")
agent._persist_session(messages, conversation_history)
return {
"messages": messages,
@@ -2641,7 +2769,7 @@ def run_conversation(
# Can't compress further and already at minimum tier
agent._vprint(f"{agent.log_prefix}❌ Context length exceeded and cannot compress further.", force=True)
agent._vprint(f"{agent.log_prefix} 💡 The conversation has accumulated too much content. Try /new to start fresh, or /compress to manually trigger compression.", force=True)
logging.error(f"{agent.log_prefix}Context length exceeded: {approx_tokens:,} tokens. Cannot compress further.")
logger.error(f"{agent.log_prefix}Context length exceeded: {approx_tokens:,} tokens. Cannot compress further.")
agent._persist_session(messages, conversation_history)
return {
"messages": messages,
@@ -2678,6 +2806,21 @@ def run_conversation(
# retryable=True mapping takes effect instead.
and not isinstance(api_error, ssl.SSLError)
)
# ``FailoverReason.billing`` (HTTP 402) is NOT in this
# exclusion set. By the time we reach this block:
# • credential-pool rotation (line ~2031) has already
# fired for billing and either ``continue``d or
# returned (False, ...) — pool is exhausted or absent.
# • the eager-fallback branch above (line ~2422) also
# fires on billing and ``continue``s if a fallback
# provider is configured.
# Falling through to here means BOTH recovery paths
# gave up. Treating 402 as retryable from this point
# just burns more paid requests against a depleted
# balance with no recovery mechanism left — see #31273
# (real-world: ~$40 in 48h on a 24/7 gateway). Aborting
# mirrors how 401/403 (also ``should_fallback=True``)
# already behave once their recovery paths have failed.
is_client_error = (
is_local_validation_error
or (
@@ -2685,7 +2828,6 @@ def run_conversation(
and not classified.should_compress
and classified.reason not in {
FailoverReason.rate_limit,
FailoverReason.billing,
FailoverReason.overloaded,
FailoverReason.context_overflow,
FailoverReason.payload_too_large,
@@ -2725,7 +2867,7 @@ def run_conversation(
agent._vprint(f"{agent.log_prefix} 2. Then run `hermes auth` to re-authenticate.", force=True)
else:
agent._vprint(f"{agent.log_prefix} 💡 xAI OAuth token was rejected (HTTP 401). To fix:", force=True)
agent._vprint(f"{agent.log_prefix} re-authenticate with xAI Grok OAuth (SuperGrok Subscription) from `hermes model`.", force=True)
agent._vprint(f"{agent.log_prefix} re-authenticate with xAI Grok OAuth (SuperGrok / Premium+) from `hermes model`.", force=True)
else:
agent._vprint(f"{agent.log_prefix} 💡 Your API key was rejected by the provider. Check:", force=True)
agent._vprint(f"{agent.log_prefix} • Is the key valid? Run: hermes setup", force=True)
@@ -2734,7 +2876,7 @@ def run_conversation(
agent._vprint(f"{agent.log_prefix} • Check credits: https://openrouter.ai/settings/credits", force=True)
else:
agent._vprint(f"{agent.log_prefix} 💡 This type of error won't be fixed by retrying.", force=True)
logging.error(f"{agent.log_prefix}Non-retryable client error: {api_error}")
logger.error(f"{agent.log_prefix}Non-retryable client error: {api_error}")
# Skip session persistence when the error is likely
# context-overflow related (status 400 + large session).
# Persisting the failed user message would make the
@@ -2811,7 +2953,7 @@ def run_conversation(
force=True,
)
logging.error(
logger.error(
"%sAPI call failed after %s retries. %s | provider=%s model=%s msgs=%s tokens=~%s",
agent.log_prefix, max_retries, _final_summary,
_provider, _model, len(api_messages), f"{approx_tokens:,}",
@@ -3342,6 +3484,19 @@ def run_conversation(
f"⚠️ Tool guardrail halted {decision.tool_name}: {decision.code}"
)
messages.append({"role": "assistant", "content": final_response})
# Emit the halt message to the client so it's not
# indistinguishable from a crash. The stream display
# was flushed (callback(None)) before tool execution,
# but the callback is still alive — fire the text
# through it so SSE/TUI clients see the explanation.
if final_response:
agent._safe_print(f"\n{final_response}\n")
if agent.stream_delta_callback:
try:
agent.stream_delta_callback(final_response)
agent.stream_delta_callback(None)
except Exception:
pass
break
# Reset per-turn retry counters after successful tool
@@ -3848,7 +4003,11 @@ def run_conversation(
)
# Determine if conversation completed successfully
completed = final_response is not None and api_call_count < agent.max_iterations
completed = (
final_response is not None
and api_call_count < agent.max_iterations
and not failed
)
# Save trajectory if enabled. ``user_message`` may be a multimodal
# list of parts; the trajectory format wants a plain string.
@@ -3933,6 +4092,8 @@ def run_conversation(
except Exception as _ver_err:
logger.debug("file-mutation verifier footer failed: %s", _ver_err)
_response_transformed = False
# Plugin hook: transform_llm_output
# Fired once per turn after the tool-calling loop completes.
# Plugins can transform the LLM's output text before it's returned.
@@ -3950,6 +4111,7 @@ def run_conversation(
for _hook_result in _transform_results:
if isinstance(_hook_result, str) and _hook_result:
final_response = _hook_result
_response_transformed = True
break # First non-empty string wins
except Exception as exc:
logger.warning("transform_llm_output hook failed: %s", exc)
@@ -3998,8 +4160,10 @@ def run_conversation(
"api_calls": api_call_count,
"completed": completed,
"turn_exit_reason": _turn_exit_reason,
"failed": failed,
"partial": False, # True only when stopped due to invalid tool calls
"interrupted": interrupted,
"response_transformed": _response_transformed,
"response_previewed": getattr(agent, "_response_was_previewed", False),
"model": agent.model,
"provider": agent.provider,
+1 -1
View File
@@ -285,7 +285,7 @@ def _remove_xai_oauth_loopback_pkce(provider: str, removed) -> RemovalResult:
if _clear_auth_store_provider(provider):
result.cleaned.append(f"Cleared {provider} OAuth tokens from auth store")
result.hints.append(
"Run `hermes model` → xAI Grok OAuth (SuperGrok Subscription) to re-authenticate if needed."
"Run `hermes model` → xAI Grok OAuth (SuperGrok / Premium+) to re-authenticate if needed."
)
return result
+4 -1
View File
@@ -50,6 +50,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from hermes_constants import get_hermes_home
from agent.skill_utils import is_excluded_skill_path
logger = logging.getLogger(__name__)
@@ -176,7 +177,9 @@ def get_keep() -> int:
def _count_skill_files(base: Path) -> int:
try:
return sum(1 for _ in base.rglob("SKILL.md"))
return sum(
1 for p in base.rglob("SKILL.md") if not is_excluded_skill_path(p)
)
except OSError:
return 0
+56 -6
View File
@@ -787,33 +787,65 @@ class KawaiiSpinner:
# Cute tool message (completion line that replaces the spinner)
# =========================================================================
_ERROR_SUFFIX_MAX_LEN = 48
def _trim_error(msg: str) -> str:
"""Shrink an error message for inline display in a tool status line.
Strips overly long absolute paths down to just the filename so the
suffix stays readable on narrow terminals.
"""
msg = msg.strip()
# Common case: "File not found: /very/long/absolute/path/foo.py"
if "File not found:" in msg:
_, _, tail = msg.partition("File not found:")
tail = tail.strip()
if "/" in tail:
msg = f"File not found: {tail.rsplit('/', 1)[-1]}"
if len(msg) > _ERROR_SUFFIX_MAX_LEN:
msg = msg[: _ERROR_SUFFIX_MAX_LEN - 3] + "..."
return msg
def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]:
"""Inspect a tool result string for signs of failure.
Returns ``(is_failure, suffix)`` where *suffix* is an informational tag
like ``" [exit 1]"`` for terminal failures, or ``" [error]"`` for generic
failures. On success, returns ``(False, "")``.
Returns ``(is_failure, suffix)`` where *suffix* is a short informational
tag like ``" [exit 1]"`` for terminal failures, ``" [full]"`` for memory
overflow, or a trimmed error message (``" [File not found: foo.py]"``).
On success returns ``(False, "")``.
"""
if result is None:
return False, ""
if file_mutation_result_landed(tool_name, result):
return False, ""
data = safe_json_loads(result)
# Terminal: non-zero exit code is the canonical failure signal.
if tool_name == "terminal":
data = safe_json_loads(result)
if isinstance(data, dict):
exit_code = data.get("exit_code")
if exit_code is not None and exit_code != 0:
err_msg = data.get("error")
if err_msg:
return True, f" [{_trim_error(str(err_msg))}]"
return True, f" [exit {exit_code}]"
return False, ""
# Memory-specific: distinguish "full" from real errors
# Memory: distinguish "store full" from real errors.
if tool_name == "memory":
data = safe_json_loads(result)
if isinstance(data, dict):
if data.get("success") is False and "exceed the limit" in data.get("error", ""):
return True, " [full]"
# Structured error in JSON result (any tool that surfaces {"error": ...}).
if isinstance(data, dict):
err = data.get("error") or data.get("message")
if err and (data.get("success") is False or "error" in data):
return True, f" [{_trim_error(str(err))}]"
# Generic heuristic for non-terminal tools
# Multimodal tool results (dicts with _multimodal=True) are not strings —
# treat them as successes since failures would be JSON-encoded strings.
@@ -921,11 +953,29 @@ def get_cute_tool_message(
if tool_name == "todo":
todos_arg = args.get("todos")
merge = args.get("merge", False)
# Parse result for completion progress
total = 0
done = 0
if result:
try:
data = safe_json_loads(result)
if data:
s = data.get("summary", {})
total = s.get("total", 0)
done = s.get("completed", 0)
except Exception:
pass
if todos_arg is None:
if total > 0:
return _wrap(f"┊ 📋 plan {done}/{total} task(s) {dur}")
return _wrap(f"┊ 📋 plan reading tasks {dur}")
elif merge:
if total > 0 and done > 0:
return _wrap(f"┊ 📋 plan update {done}/{total}{dur}")
return _wrap(f"┊ 📋 plan update {len(todos_arg)} task(s) {dur}")
else:
if total > 0 and done > 0:
return _wrap(f"┊ 📋 plan {done}/{total} task(s) {dur}")
return _wrap(f"┊ 📋 plan {len(todos_arg)} task(s) {dur}")
if tool_name == "session_search":
return _wrap(f"┊ 🔍 recall \"{_trunc(args.get('query', ''), 35)}\" {dur}")
+82
View File
@@ -50,6 +50,7 @@ class FailoverReason(enum.Enum):
# Request format
format_error = "format_error" # 400 bad request — abort or strip + retry
multimodal_tool_content_unsupported = "multimodal_tool_content_unsupported" # Provider rejected list-type content in tool messages (e.g. Xiaomi MiMo) — downgrade to text and retry
# Provider-specific
thinking_signature = "thinking_signature" # Anthropic thinking block sig invalid
@@ -165,6 +166,32 @@ _IMAGE_TOO_LARGE_PATTERNS = [
# the likely culprit; we still try the shrink path before giving up.
]
# Providers that follow the OpenAI spec strictly require tool message
# ``content`` to be a string. Some (Anthropic native, Codex Responses,
# Gemini native, first-party OpenAI) extend this to accept a content-parts
# list (text + image_url) so screenshots from computer_use survive. Others
# (Xiaomi MiMo, some Alibaba endpoints, a long tail of OpenAI-compatible
# providers) reject the list with a 400 — the patterns below are the most
# common error shapes we see. Recovery: strip image parts from tool
# messages in-place, record the (provider, model) for the rest of the
# session so we don't waste another call learning the same lesson, retry.
#
# See: https://github.com/NousResearch/hermes-agent/issues/27344
_MULTIMODAL_TOOL_CONTENT_PATTERNS = [
# Xiaomi MiMo: {"error":{"code":"400","message":"Param Incorrect","param":"text is not set"}}
"text is not set",
# Generic "tool message must be string" shapes
"tool message content must be a string",
"tool content must be a string",
"tool message must be a string",
# OpenAI-compat servers that reject list-type tool content with a
# schema-validation message
"expected string, got list",
"expected string, got array",
# Alibaba/DashScope variant
"tool_call.content must be string",
]
# Context overflow patterns
_CONTEXT_OVERFLOW_PATTERNS = [
"context length",
@@ -213,6 +240,24 @@ _MODEL_NOT_FOUND_PATTERNS = [
"unsupported model",
]
# Request-validation patterns — the request is malformed and will fail
# identically on every retry. Some OpenAI-compatible gateways (notably
# codex.nekos.me) return these as 5xx instead of the standard 4xx, which
# makes the generic "5xx → retryable server_error" rule misfire: the retry
# loop hammers the same deterministic rejection 3+ times, then the
# transport-recovery path resets the counter and does it again, producing
# a request flood. When a 5xx body carries one of these unambiguous
# request-validation signals, classify as a non-retryable format_error so
# the loop fails fast and falls back instead of looping.
_REQUEST_VALIDATION_PATTERNS = [
"unknown parameter",
"unsupported parameter",
"unrecognized request argument",
"invalid_request_error",
"unknown_parameter",
"unsupported_parameter",
]
# OpenRouter aggregator policy-block patterns.
#
# When a user's OpenRouter account privacy setting (or a per-request
@@ -718,6 +763,23 @@ def _classify_by_status(
)
if status_code in {500, 502}:
# Some OpenAI-compatible gateways return request-validation errors
# with a 5xx status (codex.nekos.me returns 502 for unknown/
# unsupported parameters). These are deterministic — every retry
# gets the identical rejection — so the generic "5xx → retryable
# server_error" rule turns one bad request into a retry flood.
# Detect the unambiguous request-validation signals (in either the
# message text or the structured error code) and fail fast.
if (
any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS)
or error_code.lower() in {"invalid_request_error", "unknown_parameter",
"unsupported_parameter"}
):
return result_fn(
FailoverReason.format_error,
retryable=False,
should_fallback=True,
)
return result_fn(FailoverReason.server_error, retryable=True)
if status_code in {503, 529}:
@@ -781,6 +843,19 @@ def _classify_400(
) -> ClassifiedError:
"""Classify 400 Bad Request — context overflow, format error, or generic."""
# Multimodal tool content rejected from 400. Must be checked BEFORE
# image_too_large because the recovery is different (strip image parts
# from tool messages, mark the model as no-list-tool-content for the
# rest of the session) and BEFORE context_overflow because some of the
# patterns ("text is not set") are ambiguous in isolation but become
# specific when combined with a 400 on a request known to contain
# multimodal tool content.
if any(p in error_msg for p in _MULTIMODAL_TOOL_CONTENT_PATTERNS):
return result_fn(
FailoverReason.multimodal_tool_content_unsupported,
retryable=True,
)
# Image-too-large from 400 (Anthropic's 5 MB per-image check fires this way).
# Must be checked BEFORE context_overflow because messages can trip both
# patterns ("exceeds" + "image") and image-shrink is a cheaper recovery.
@@ -922,6 +997,13 @@ def _classify_by_message(
should_compress=True,
)
# Multimodal tool content patterns (from message text when no status_code)
if any(p in error_msg for p in _MULTIMODAL_TOOL_CONTENT_PATTERNS):
return result_fn(
FailoverReason.multimodal_tool_content_unsupported,
retryable=True,
)
# Image-too-large patterns (from message text when no status_code)
if any(p in error_msg for p in _IMAGE_TOO_LARGE_PATTERNS):
return result_fn(
+301 -11
View File
@@ -41,6 +41,11 @@ def build_write_denied_paths(home: str) -> set[str]:
# Top-level .env, even when running under a profile — overwriting it
# leaks credentials across every profile that inherits from root (#15981).
str(hermes_root / ".env"),
# Active profile Anthropic PKCE credential store.
str(hermes_home / ".anthropic_oauth.json"),
# Top-level Anthropic PKCE credential store remains sensitive even
# when a profile is active; default/non-profile sessions still read it.
str(hermes_root / ".anthropic_oauth.json"),
os.path.join(home, ".bashrc"),
os.path.join(home, ".zshrc"),
os.path.join(home, ".profile"),
@@ -50,6 +55,7 @@ def build_write_denied_paths(home: str) -> set[str]:
os.path.join(home, ".pgpass"),
os.path.join(home, ".npmrc"),
os.path.join(home, ".pypirc"),
os.path.join(home, ".git-credentials"),
"/etc/sudoers",
"/etc/passwd",
"/etc/shadow",
@@ -71,6 +77,7 @@ def build_write_denied_prefixes(home: str) -> list[str]:
os.path.join(home, ".docker"),
os.path.join(home, ".azure"),
os.path.join(home, ".config", "gh"),
os.path.join(home, ".config", "gcloud"),
]
]
@@ -97,6 +104,43 @@ def is_write_denied(path: str) -> bool:
if resolved.startswith(prefix):
return True
# Hermes control-plane files: block both the ACTIVE profile's view
# (hermes_home) AND the global root view. Without the root pass, a
# profile-mode session leaves <root>/auth.json + <root>/config.yaml
# writable — letting a prompt-injected write_file overwrite the global
# files that every profile inherits from (same shape as #15981).
control_file_names = ("auth.json", "config.yaml", "webhook_subscriptions.json")
mcp_tokens_dir_name = "mcp-tokens"
hermes_dirs = []
for base in (_hermes_home_path(), _hermes_root_path()):
try:
real = os.path.realpath(base)
if real not in hermes_dirs:
hermes_dirs.append(real)
except Exception:
continue
for base_real in hermes_dirs:
for name in control_file_names:
try:
if resolved == os.path.realpath(os.path.join(base_real, name)):
return True
except Exception:
continue
try:
mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):
return True
except Exception:
pass
try:
pairing_real = os.path.realpath(os.path.join(base_real, "pairing"))
if resolved == pairing_real or resolved.startswith(pairing_real + os.sep):
return True
except Exception:
pass
safe_root = get_safe_write_root()
if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)):
return True
@@ -105,21 +149,267 @@ def is_write_denied(path: str) -> bool:
def get_read_block_error(path: str) -> Optional[str]:
"""Return an error message when a read targets internal Hermes cache files."""
"""Return an error message when a read targets a denied Hermes path.
Two categories are blocked:
* Internal Hermes cache files under ``HERMES_HOME/skills/.hub``
readable metadata that an attacker could use as a prompt-injection
carrier.
* Credential / secret stores under HERMES_HOME and the global Hermes
root: ``auth.json``, ``auth.lock``, ``.anthropic_oauth.json``,
``.env``, ``webhook_subscriptions.json``, ``auth/google_oauth.json``,
and anything under ``mcp-tokens/``. These hold plaintext provider keys,
OAuth tokens, and HMAC secrets that the agent never needs to read
directly provider tools / gateway adapters consume them through
internal channels.
**This is NOT a security boundary.** The terminal tool runs as the
same OS user with shell access; the agent can still ``cat auth.json``
or ``cat ~/.hermes/.env`` and exfiltrate the file. The read-deny exists
as defense-in-depth that:
* Returns a clear error to models that respect tool denials, which
empirically prompts most modern models to stop rather than reach
for the shell.
* Surfaces a visible audit trail when something tries to read
credentials easier to spot in logs than a generic ``cat``.
Treat any user-visible framing around this as "may help" rather than
"stops attackers." A determined model or malicious instruction can
always shell out.
Callers that resolve relative paths against a non-process cwd
(e.g. ``TERMINAL_CWD`` in ``tools/file_tools.py``) MUST pre-resolve
and pass the absolute path string. This function's own ``resolve()``
is anchored at the Python process cwd, so a relative input like
``"auth.json"`` would otherwise miss the denylist when the task's
terminal cwd differs from the process cwd.
"""
resolved = Path(path).expanduser().resolve()
hermes_home = _hermes_home_path().resolve()
blocked_dirs = [
hermes_home / "skills" / ".hub" / "index-cache",
hermes_home / "skills" / ".hub",
]
for blocked in blocked_dirs:
# Resolve BOTH the active HERMES_HOME (profile-aware) AND the global
# Hermes root so credential stores at <root>/auth.json etc. are also
# blocked when running under a profile (HERMES_HOME points at
# <root>/profiles/<name> in profile mode). Same shape as the write
# deny widening (#15981, #14157).
hermes_dirs: list[Path] = []
for base in (_hermes_home_path(), _hermes_root_path()):
try:
resolved.relative_to(blocked)
real = base.resolve()
if real not in hermes_dirs:
hermes_dirs.append(real)
except Exception:
continue
# Skills .hub: prompt-injection carriers.
for hd in hermes_dirs:
blocked_dirs = [
hd / "skills" / ".hub" / "index-cache",
hd / "skills" / ".hub",
]
for blocked in blocked_dirs:
try:
resolved.relative_to(blocked)
except ValueError:
continue
return (
f"Access denied: {path} is an internal Hermes cache file "
"and cannot be read directly to prevent prompt injection. "
"Use the skills_list or skill_view tools instead."
)
# Credential / secret stores. Exact-file matches under either
# HERMES_HOME or <root>.
credential_file_names = (
"auth.json",
"auth.lock",
".anthropic_oauth.json",
".env",
"webhook_subscriptions.json",
os.path.join("auth", "google_oauth.json"),
)
for hd in hermes_dirs:
for name in credential_file_names:
try:
blocked = (hd / name).resolve()
except Exception:
continue
if resolved == blocked:
return (
f"Access denied: {path} is a Hermes credential store "
"and cannot be read directly. Provider tools consume "
"these credentials through internal channels. "
"(Defense-in-depth — not a security boundary; the "
"terminal tool can still bypass.)"
)
# mcp-tokens/: directory prefix match — anything inside is OAuth
# token material.
for hd in hermes_dirs:
try:
mcp_tokens = (hd / "mcp-tokens").resolve()
except Exception:
continue
if resolved == mcp_tokens:
return (
f"Access denied: {path} is the Hermes MCP token directory "
"and cannot be read directly. (Defense-in-depth — not a "
"security boundary; the terminal tool can still bypass.)"
)
try:
resolved.relative_to(mcp_tokens)
except ValueError:
continue
return (
f"Access denied: {path} is an internal Hermes cache file "
"and cannot be read directly to prevent prompt injection. "
"Use the skills_list or skill_view tools instead."
f"Access denied: {path} is a Hermes MCP token file "
"and cannot be read directly. (Defense-in-depth — not a "
"security boundary; the terminal tool can still bypass.)"
)
return None
# ---------------------------------------------------------------------------
# Cross-profile write guard (#TBD)
#
# Hermes profiles are separate HERMES_HOME dirs under
# ``<root>/profiles/<name>/``. Each profile has its own skills/, plugins/,
# cron/, memories/. When an agent runs under one profile, writing into
# ANOTHER profile's directories is almost always wrong — those skills /
# plugins / cron jobs / memories affect a different session the user runs
# from a different shell.
#
# Soft guard, NOT a security boundary: the agent runs as the same OS user
# and has unrestricted terminal access, so this returns a warning the model
# can choose to honor or override with ``cross_profile=True``. Same shape
# as the dangerous-command approval flow — the agent is told the boundary
# exists, and explicit user direction is required to cross it.
#
# Reference: May 2026 incident where a hermes-security profile session
# edited skills under both ``~/.hermes/profiles/hermes-security/skills/``
# AND ``~/.hermes/skills/`` (the default profile's skills) without realizing
# the second path belonged to a different profile.
# ---------------------------------------------------------------------------
# Profile-scoped directories under HERMES_HOME / <root> / <root>/profiles/<X>/
# that should be guarded. Adding a new area here extends the guard with no
# other code change.
PROFILE_SCOPED_AREAS = ("skills", "plugins", "cron", "memories")
def _resolve_active_profile_name() -> str:
"""Return the active profile name derived from HERMES_HOME.
``~/.hermes`` -> ``"default"``
``~/.hermes/profiles/X`` -> ``"X"``
Falls back to ``"default"`` on any resolution failure so the guard
never raises into the tool path.
"""
try:
home_real = _hermes_home_path().resolve()
root_real = _hermes_root_path().resolve()
except (OSError, RuntimeError):
return "default"
profiles_dir = root_real / "profiles"
try:
rel = home_real.relative_to(profiles_dir)
parts = rel.parts
if len(parts) >= 1:
return parts[0]
except ValueError:
pass
return "default"
def classify_cross_profile_target(path: str) -> Optional[dict]:
"""Classify a write target as cross-profile if it lands in another
profile's scoped area (skills/plugins/cron/memories).
Returns ``None`` when the target is outside Hermes scope, or is inside
the ACTIVE profile, or doesn't hit a profile-scoped area. Otherwise
returns a dict with:
* ``active_profile``: name of the profile the agent is running as
* ``target_profile``: name of the profile the path belongs to
* ``area``: which scoped area (``"skills"``, ``"plugins"``, etc.)
* ``target_path``: the resolved path string
The caller decides what to do with the result surface a warning to
the model, prompt the user, or (with explicit consent /
``cross_profile=True``) proceed anyway.
"""
try:
target = Path(os.path.expanduser(str(path))).resolve()
root_real = _hermes_root_path().resolve()
except (OSError, RuntimeError):
return None
target_profile: Optional[str] = None
area: Optional[str] = None
try:
rel = target.relative_to(root_real)
except ValueError:
return None
parts = rel.parts
if not parts:
return None
if parts[0] in PROFILE_SCOPED_AREAS:
# ``<root>/<area>/...`` → default profile.
target_profile = "default"
area = parts[0]
elif (
parts[0] == "profiles"
and len(parts) >= 3
and parts[2] in PROFILE_SCOPED_AREAS
):
# ``<root>/profiles/<name>/<area>/...`` → named profile.
target_profile = parts[1]
area = parts[2]
else:
return None
active_profile = _resolve_active_profile_name()
if target_profile == active_profile:
# In-profile write — not a cross-profile event.
return None
return {
"active_profile": active_profile,
"target_profile": target_profile,
"area": area,
"target_path": str(target),
}
def get_cross_profile_warning(path: str) -> Optional[str]:
"""Return a model-facing warning string when ``path`` is cross-profile.
Returns ``None`` when the write is in-scope (same profile) or outside
Hermes entirely. Caller is expected to surface the warning to the
agent as a tool-result error, NOT to silently allow the write the
agent must either get explicit user direction to proceed, or pass
``cross_profile=True`` to its write tool.
This is defense-in-depth: the terminal tool runs as the same OS user
and can write any of these paths without going through this guard.
Treat the guard as a confusion-reducer, not a security boundary.
"""
info = classify_cross_profile_target(path)
if info is None:
return None
return (
f"Cross-profile write blocked by soft guard: {info['target_path']} "
f"belongs to Hermes profile {info['target_profile']!r}, but the "
f"agent is running under profile {info['active_profile']!r}. "
f"Editing another profile's {info['area']}/ will affect that "
f"profile's future sessions, not the one you are currently in. "
f"Confirm with the user before proceeding. To bypass this guard "
f"after explicit user direction, retry the call with "
f"``cross_profile=True``. (Defense-in-depth — not a security "
f"boundary; the terminal tool can still bypass.)"
)
+82
View File
@@ -191,6 +191,88 @@ def save_b64_image(
return path
# Extension inference for save_url_image — keep small and explicit. We don't
# want to import mimetypes for a handful of formats every image_gen provider
# actually returns, and we never want to inherit a content-type that points
# at HTML or JSON when the API gives us a degenerate response.
_URL_IMAGE_CONTENT_TYPES = {
"image/png": "png",
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
}
def save_url_image(
url: str,
*,
prefix: str = "image",
timeout: float = 60.0,
max_bytes: int = 25 * 1024 * 1024,
) -> Path:
"""Download an image URL and write it under ``$HERMES_HOME/cache/images/``.
Used by providers (xAI, fallback OpenAI) whose API returns an *ephemeral*
URL instead of inline base64 those URLs frequently expire before a
downstream consumer (Telegram ``send_photo``, browser fetch) can resolve
them, so we materialise the bytes locally at tool-completion time.
Mirrors :func:`save_b64_image`'s shape so providers can swap in one line.
Returns the absolute :class:`Path` to the saved file. Raises on any
network / HTTP / oversize / non-image-content-type error so callers can
fall back to returning the bare URL with a clear error message.
"""
import requests
response = requests.get(url, timeout=timeout, stream=True)
response.raise_for_status()
# Infer extension from the response content-type, falling back to the
# URL suffix when xAI / OpenAI omit a precise type (some CDNs return
# ``application/octet-stream``). Defaults to ``png``.
content_type = (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
extension = _URL_IMAGE_CONTENT_TYPES.get(content_type)
if extension is None:
url_path = url.split("?", 1)[0].lower()
for ext in ("png", "jpg", "jpeg", "webp", "gif"):
if url_path.endswith(f".{ext}"):
extension = "jpg" if ext == "jpeg" else ext
break
if extension is None:
extension = "png"
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
short = uuid.uuid4().hex[:8]
path = _images_cache_dir() / f"{prefix}_{ts}_{short}.{extension}"
bytes_written = 0
with path.open("wb") as fh:
for chunk in response.iter_content(chunk_size=64 * 1024):
if not chunk:
continue
bytes_written += len(chunk)
if bytes_written > max_bytes:
fh.close()
try:
path.unlink()
except OSError:
pass
raise ValueError(
f"Image at {url} exceeds {max_bytes // (1024 * 1024)}MB cap; refusing to cache."
)
fh.write(chunk)
if bytes_written == 0:
try:
path.unlink()
except OSError:
pass
raise ValueError(f"Image at {url} returned 0 bytes; refusing to cache.")
return path
def success_response(
*,
image: str,
+2 -1
View File
@@ -209,6 +209,7 @@ DEFAULT_CONTEXT_LENGTHS = {
# via a custom provider. Values sourced from models.dev (2026-04).
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
"grok-build": 256000, # grok-build-0.1
"grok-code-fast": 256000, # grok-code-fast-1
"grok-4-1-fast": 2000000, # grok-4-1-fast-(non-)reasoning
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
@@ -640,7 +641,7 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
return cache
except Exception as e:
logging.warning(f"Failed to fetch model metadata from OpenRouter: {e}")
logger.warning(f"Failed to fetch model metadata from OpenRouter: {e}")
return _model_metadata_cache or {}
+3
View File
@@ -167,6 +167,9 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
"gemini": "google",
"google": "google",
"xai": "xai",
# xAI OAuth is an authentication/transport path for the same xAI model
# catalog, so model metadata should resolve through the xAI provider.
"xai-oauth": "xai",
"xiaomi": "xiaomi",
"nvidia": "nvidia",
"groq": "groq",
+42
View File
@@ -176,6 +176,15 @@ _URL_USERINFO_RE = re.compile(
r"(https?|wss?|ftp)://([^/\s:@]+):([^/\s@]+)@",
)
# HTTP access logs often use a relative request target rather than a full URL:
# `"POST /webhook?password=... HTTP/1.1"`. The full-URL redactor above only
# sees strings containing `://`, so handle request-target query strings too.
_HTTP_REQUEST_TARGET_QUERY_RE = re.compile(
r"\b((?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE|CONNECT)\s+[^ \t\r\n\"']*?)"
r"\?([^ \t\r\n\"']+)",
re.IGNORECASE,
)
# Form-urlencoded body detection: conservative — only applies when the entire
# text looks like a query string (k=v&k=v pattern with no newlines).
_FORM_BODY_RE = re.compile(
@@ -293,6 +302,15 @@ def _redact_url_userinfo(text: str) -> str:
)
def _redact_http_request_target_query_params(text: str) -> str:
"""Redact sensitive query params in HTTP access-log request targets."""
def _sub(m: re.Match) -> str:
prefix = m.group(1)
query = _redact_query_string(m.group(2))
return f"{prefix}?{query}"
return _HTTP_REQUEST_TARGET_QUERY_RE.sub(_sub, text)
def _redact_form_body(text: str) -> str:
"""Redact sensitive values in a form-urlencoded body.
@@ -397,6 +415,11 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
if "?" in text:
text = _redact_url_query_params(text)
# HTTP access logs can contain relative request targets with query params
# and no URL scheme, e.g. `"POST /hook?password=... HTTP/1.1"`.
if "?" in text and "=" in text and _has_http_method_substring(text):
text = _redact_http_request_target_query_params(text)
# Form-urlencoded bodies (only triggers on clean k=v&k=v inputs).
if "&" in text and "=" in text:
text = _redact_form_body(text)
@@ -456,6 +479,25 @@ def _has_known_prefix_substring(text: str) -> bool:
return any(p in text for p in _PREFIX_SUBSTRINGS)
_HTTP_METHOD_SUBSTRINGS = (
"GET ",
"POST ",
"PUT ",
"PATCH ",
"DELETE ",
"HEAD ",
"OPTIONS ",
"TRACE ",
"CONNECT ",
)
def _has_http_method_substring(text: str) -> bool:
"""Cheap pre-check before scanning for access-log request targets."""
upper = text.upper()
return any(method in upper for method in _HTTP_METHOD_SUBSTRINGS)
class RedactingFormatter(logging.Formatter):
"""Log formatter that redacts secrets from all log messages."""
+13
View File
@@ -0,0 +1,13 @@
"""External secret source integrations.
A secret source is anything that can supply environment-variable-shaped
credentials at process startup, _after_ ~/.hermes/.env has loaded. By
default sources are non-destructive: they only set values for env vars
that aren't already present, so .env and shell exports continue to win.
Currently shipped:
- ``bitwarden`` Bitwarden Secrets Manager (`bws` CLI). See
``agent.secret_sources.bitwarden`` for the integration and
``hermes_cli.secrets_cli`` for the user-facing setup wizard.
"""
+535
View File
@@ -0,0 +1,535 @@
"""Bitwarden Secrets Manager (`bws` CLI) integration.
Hermes pulls API keys from Bitwarden Secrets Manager at process startup
so they don't have to live in plaintext in ``~/.hermes/.env``.
Design summary
--------------
* The ``bws`` binary is auto-installed into ``<hermes_home>/bin/bws`` on
first use. Hermes pins one version (``_BWS_VERSION``) and downloads
the matching asset from the official GitHub Releases page, verifying
the SHA-256 against the release's published checksum file.
* The access token is stored in ``~/.hermes/.env`` as
``BWS_ACCESS_TOKEN`` (or whatever name the user picked in
``secrets.bitwarden.access_token_env``). This is the one
bootstrap secret every other provider key can live in Bitwarden.
* Pulling secrets is a single ``bws secret list <project_id>
--output json`` call. We cache the result in-process for
``cache_ttl_seconds`` so back-to-back ``hermes`` invocations don't
hammer the API.
* Failures NEVER block Hermes startup. Missing binary, no network,
expired token, etc. all emit a one-line warning and continue with
whatever credentials ``.env`` already had.
The module is intentionally subprocess-driven rather than going through
the ``bitwarden-sdk-secrets`` Python package: one cross-platform binary
is easier to lazy-install than a wheels-with-Rust-extension dependency.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import platform
import shutil
import stat
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Configuration constants
# ---------------------------------------------------------------------------
# Pinned upstream version. Bump in a follow-up PR — never auto-resolve
# "latest" because upstream release shape (asset names, CLI flags) is
# allowed to change between majors and we want updates to be deliberate.
_BWS_VERSION = "2.0.0"
_BWS_RELEASE_BASE = (
f"https://github.com/bitwarden/sdk-sm/releases/download/bws-v{_BWS_VERSION}"
)
_BWS_CHECKSUM_NAME = f"bws-sha256-checksums-{_BWS_VERSION}.txt"
# How long to wait for bws subprocesses and HTTP downloads, in seconds.
_BWS_DOWNLOAD_TIMEOUT = 60
_BWS_RUN_TIMEOUT = 30
# In-process cache so repeated load_hermes_dotenv() calls (CLI startup,
# gateway hot-reload, test suites) don't re-fetch from BSM.
_CacheKey = Tuple[str, str, str] # (access_token_fingerprint, project_id, server_url)
_CACHE: Dict[_CacheKey, "_CachedFetch"] = {}
@dataclass
class _CachedFetch:
secrets: Dict[str, str]
fetched_at: float
def is_fresh(self, ttl_seconds: float) -> bool:
if ttl_seconds <= 0:
return False
return (time.time() - self.fetched_at) < ttl_seconds
# ---------------------------------------------------------------------------
# Public dataclasses
# ---------------------------------------------------------------------------
@dataclass
class FetchResult:
"""Outcome of a single BSM pull."""
secrets: Dict[str, str] = field(default_factory=dict)
applied: List[str] = field(default_factory=list) # set into os.environ
skipped: List[str] = field(default_factory=list) # already set, not overridden
warnings: List[str] = field(default_factory=list) # non-fatal issues
error: Optional[str] = None # fatal: nothing was fetched
binary_path: Optional[Path] = None
@property
def ok(self) -> bool:
return self.error is None
# ---------------------------------------------------------------------------
# Binary discovery + lazy install
# ---------------------------------------------------------------------------
def _hermes_bin_dir() -> Path:
"""Where Hermes stores its managed binaries. Profile-aware."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "bin"
def find_bws(*, install_if_missing: bool = False) -> Optional[Path]:
"""Return a path to a usable ``bws`` binary, or None.
Resolution order:
1. ``<hermes_home>/bin/bws`` (our managed copy preferred)
2. ``shutil.which("bws")`` (system PATH)
When ``install_if_missing`` is True and neither resolves, this calls
:func:`install_bws` to download and verify the pinned version.
"""
managed = _hermes_bin_dir() / _platform_binary_name()
if managed.exists() and os.access(managed, os.X_OK):
return managed
system = shutil.which("bws")
if system:
return Path(system)
if install_if_missing:
try:
return install_bws()
except Exception as exc: # noqa: BLE001 — never block startup
logger.warning("bws auto-install failed: %s", exc)
return None
return None
def _platform_binary_name() -> str:
return "bws.exe" if platform.system() == "Windows" else "bws"
def _platform_asset_name() -> str:
"""Map (uname, arch, libc) → the upstream asset filename.
Asset names follow Rust's target triple convention. Linux defaults
to gnu (glibc); we switch to musl only if ldd --version says so.
"""
system = platform.system()
machine = platform.machine().lower()
if system == "Darwin":
# Universal binary works on both Intel and Apple Silicon — no
# need to pick a per-arch asset.
return f"bws-macos-universal-{_BWS_VERSION}.zip"
if system == "Windows":
arch = "aarch64" if machine in ("arm64", "aarch64") else "x86_64"
return f"bws-{arch}-pc-windows-msvc-{_BWS_VERSION}.zip"
if system == "Linux":
arch = "aarch64" if machine in ("arm64", "aarch64") else "x86_64"
libc = "gnu"
# ldd --version writes to stderr on glibc, stdout on musl. We
# don't need bullet-proof detection — getting it wrong falls
# back to a clear error from the binary loader, which we catch.
try:
res = subprocess.run(
["ldd", "--version"],
capture_output=True,
text=True,
timeout=2,
)
if "musl" in (res.stdout + res.stderr).lower():
libc = "musl"
except (OSError, subprocess.TimeoutExpired):
pass
return f"bws-{arch}-unknown-linux-{libc}-{_BWS_VERSION}.zip"
raise RuntimeError(
f"Unsupported platform for bws auto-install: {system} {machine}"
)
def install_bws(*, force: bool = False) -> Path:
"""Download, verify, and install the pinned ``bws`` binary.
Returns the path to the installed executable. Raises on any
failure (network, checksum, extraction) callers in the auto-install
path catch these; the user-facing ``hermes secrets bitwarden setup``
surface lets them propagate so the wizard can show a clear error.
"""
bin_dir = _hermes_bin_dir()
bin_dir.mkdir(parents=True, exist_ok=True)
target = bin_dir / _platform_binary_name()
if target.exists() and not force:
return target
asset_name = _platform_asset_name()
asset_url = f"{_BWS_RELEASE_BASE}/{asset_name}"
checksum_url = f"{_BWS_RELEASE_BASE}/{_BWS_CHECKSUM_NAME}"
with tempfile.TemporaryDirectory(prefix="hermes-bws-") as tmpdir:
tmp = Path(tmpdir)
zip_path = tmp / asset_name
checksum_path = tmp / _BWS_CHECKSUM_NAME
logger.info("Downloading %s", asset_url)
_http_download(asset_url, zip_path)
_http_download(checksum_url, checksum_path)
expected = _expected_sha256(checksum_path, asset_name)
actual = _sha256_file(zip_path)
if expected.lower() != actual.lower():
raise RuntimeError(
f"Checksum mismatch for {asset_name}: "
f"expected {expected}, got {actual}"
)
with zipfile.ZipFile(zip_path) as zf:
member = _pick_zip_member(zf, _platform_binary_name())
zf.extract(member, tmp)
extracted = tmp / member
# Move into place atomically. We write to a sibling tempfile in
# the final directory so the rename can't cross filesystems.
fd, staged = tempfile.mkstemp(dir=str(bin_dir), prefix=".bws_")
os.close(fd)
shutil.copy2(extracted, staged)
os.chmod(
staged,
stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR
| stat.S_IRGRP | stat.S_IXGRP
| stat.S_IROTH | stat.S_IXOTH,
)
os.replace(staged, target)
logger.info("Installed bws %s at %s", _BWS_VERSION, target)
return target
def _http_download(url: str, dest: Path) -> None:
req = urllib.request.Request(url, headers={"User-Agent": "hermes-agent"})
try:
with urllib.request.urlopen(req, timeout=_BWS_DOWNLOAD_TIMEOUT) as resp: # noqa: S310
with open(dest, "wb") as f:
shutil.copyfileobj(resp, f)
except urllib.error.URLError as exc:
raise RuntimeError(f"Failed to download {url}: {exc}") from exc
def _expected_sha256(checksum_file: Path, asset_name: str) -> str:
"""Parse the upstream ``bws-sha256-checksums-X.Y.Z.txt`` file.
Format is the standard ``sha256sum`` output: ``<hex> <filename>``,
one per line.
"""
text = checksum_file.read_text(encoding="utf-8", errors="replace")
for line in text.splitlines():
parts = line.strip().split()
if len(parts) >= 2 and parts[-1] == asset_name:
return parts[0]
raise RuntimeError(
f"No checksum entry for {asset_name} in {checksum_file.name}"
)
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def _pick_zip_member(zf: zipfile.ZipFile, binary_name: str) -> str:
"""Find the binary inside the upstream zip.
Historically the archive has been flat (``bws`` at the root) but we
tolerate a top-level directory just in case upstream changes.
"""
candidates = [n for n in zf.namelist() if n.split("/")[-1] == binary_name]
if not candidates:
raise RuntimeError(
f"Could not find {binary_name} inside downloaded archive "
f"(members: {zf.namelist()[:5]}...)"
)
# Prefer the shortest path (i.e. root over nested) for determinism.
candidates.sort(key=len)
return candidates[0]
# ---------------------------------------------------------------------------
# Secret fetch + apply
# ---------------------------------------------------------------------------
def _token_fingerprint(token: str) -> str:
"""SHA-256 prefix used as a cache key — never logged, never displayed."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
def fetch_bitwarden_secrets(
*,
access_token: str,
project_id: str,
binary: Optional[Path] = None,
cache_ttl_seconds: float = 300,
use_cache: bool = True,
server_url: str = "",
) -> Tuple[Dict[str, str], List[str]]:
"""Pull the secrets for ``project_id`` from Bitwarden Secrets Manager.
Returns ``(secrets_dict, warnings_list)``.
Set ``server_url`` to point at a non-default Bitwarden region or a
self-hosted instance e.g. ``https://vault.bitwarden.eu`` for EU
Cloud accounts. When empty, ``bws`` uses its built-in default
(``https://vault.bitwarden.com``, US Cloud). This is plumbed into
the subprocess as ``BWS_SERVER_URL``.
Raises :class:`RuntimeError` for fatal conditions (missing binary,
auth failure, unparseable output). Callers in the env_loader path
catch this and emit a single warning; callers in the user-facing
setup wizard let it propagate.
"""
if not access_token:
raise RuntimeError("Bitwarden access token is empty")
if not project_id:
raise RuntimeError("Bitwarden project_id is empty")
cache_key = (_token_fingerprint(access_token), project_id, server_url or "")
if use_cache:
cached = _CACHE.get(cache_key)
if cached and cached.is_fresh(cache_ttl_seconds):
return cached.secrets, []
bws = binary or find_bws(install_if_missing=True)
if bws is None:
raise RuntimeError(
"bws binary not available — auto-install failed and `bws` is "
"not on PATH. Install manually from "
"https://github.com/bitwarden/sdk-sm/releases or re-run "
"`hermes secrets bitwarden setup`."
)
secrets, warnings = _run_bws_list(bws, access_token, project_id, server_url)
_CACHE[cache_key] = _CachedFetch(secrets=secrets, fetched_at=time.time())
return secrets, warnings
def _run_bws_list(
bws: Path, access_token: str, project_id: str, server_url: str = ""
) -> Tuple[Dict[str, str], List[str]]:
cmd = [str(bws), "secret", "list", project_id, "--output", "json"]
env = os.environ.copy()
env["BWS_ACCESS_TOKEN"] = access_token
# Make sure we're not echoing telemetry / colour codes into json.
env.setdefault("NO_COLOR", "1")
# Region / self-hosted support. bws defaults to https://vault.bitwarden.com
# (US Cloud); EU Cloud users need https://vault.bitwarden.eu, and
# self-hosted users need their own URL. When unset, fall back to whatever
# BWS_SERVER_URL the caller already had in their shell env (preserved by
# the copy above) so manual overrides keep working too.
if server_url:
env["BWS_SERVER_URL"] = server_url
try:
proc = subprocess.run( # noqa: S603 — bws path is trusted
cmd,
env=env,
capture_output=True,
text=True,
timeout=_BWS_RUN_TIMEOUT,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"bws timed out after {_BWS_RUN_TIMEOUT}s fetching secrets"
) from exc
except OSError as exc:
raise RuntimeError(f"failed to invoke bws: {exc}") from exc
if proc.returncode != 0:
# bws writes auth/network errors to stderr in plain English.
# Strip ANSI just in case and surface the first 200 chars.
err = (proc.stderr or proc.stdout or "").strip().replace("\x1b", "")
raise RuntimeError(
f"bws exited {proc.returncode}: {err[:200]}"
)
raw = proc.stdout.strip()
if not raw:
return {}, ["bws returned no output (empty project?)"]
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError(f"bws returned non-JSON output: {exc}") from exc
if not isinstance(payload, list):
raise RuntimeError(
f"bws returned unexpected shape: {type(payload).__name__}"
)
secrets: Dict[str, str] = {}
warnings: List[str] = []
for item in payload:
if not isinstance(item, dict):
continue
key = item.get("key")
value = item.get("value")
if not isinstance(key, str) or not isinstance(value, str):
continue
if not _is_valid_env_name(key):
warnings.append(
f"Skipping secret {key!r}: not a valid env-var name"
)
continue
secrets[key] = value
return secrets, warnings
def _is_valid_env_name(name: str) -> bool:
if not name:
return False
if not (name[0].isalpha() or name[0] == "_"):
return False
return all(c.isalnum() or c == "_" for c in name)
# ---------------------------------------------------------------------------
# Public entry point — called from hermes_cli.env_loader
# ---------------------------------------------------------------------------
def apply_bitwarden_secrets(
*,
enabled: bool,
access_token_env: str = "BWS_ACCESS_TOKEN",
project_id: str = "",
override_existing: bool = False,
cache_ttl_seconds: float = 300,
auto_install: bool = True,
server_url: str = "",
) -> FetchResult:
"""Pull secrets from BSM and set them on ``os.environ``.
This is the function ``load_hermes_dotenv()`` calls after the .env
files have loaded. It is intentionally defensive any failure
returns a :class:`FetchResult` with ``error`` set; it never raises.
``server_url`` selects the Bitwarden region or self-hosted endpoint
(e.g. ``https://vault.bitwarden.eu`` for EU Cloud). Empty string
means use ``bws``'s default (US Cloud).
Parameters mirror the ``secrets.bitwarden.*`` config keys so the
caller can just splat the dict in.
"""
result = FetchResult()
if not enabled:
return result
access_token = os.environ.get(access_token_env, "").strip()
if not access_token:
result.error = (
f"secrets.bitwarden.enabled is true but {access_token_env} is "
"not set. Run `hermes secrets bitwarden setup`."
)
return result
if not project_id:
result.error = (
"secrets.bitwarden.project_id is empty. "
"Run `hermes secrets bitwarden setup`."
)
return result
binary = find_bws(install_if_missing=auto_install)
result.binary_path = binary
if binary is None:
result.error = (
"bws binary not available and auto-install is disabled. "
"Run `hermes secrets bitwarden setup` to install."
)
return result
try:
secrets, warnings = fetch_bitwarden_secrets(
access_token=access_token,
project_id=project_id,
binary=binary,
cache_ttl_seconds=cache_ttl_seconds,
server_url=server_url,
)
except RuntimeError as exc:
result.error = str(exc)
return result
result.secrets = secrets
result.warnings.extend(warnings)
for key, value in secrets.items():
if key == access_token_env:
# Don't let BSM clobber the very token we used to fetch
# itself — that would be a footgun if someone stored the
# token as a BSM secret too.
result.skipped.append(key)
continue
if not override_existing and os.environ.get(key):
result.skipped.append(key)
continue
os.environ[key] = value
result.applied.append(key)
return result
# ---------------------------------------------------------------------------
# Test hook — used by hermetic tests to flush the cache between cases.
# ---------------------------------------------------------------------------
def _reset_cache_for_tests() -> None:
_CACHE.clear()
+58 -3
View File
@@ -12,7 +12,7 @@ import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import get_config_path, get_skills_dir
from hermes_constants import get_config_path, get_skills_dir, is_termux
logger = logging.getLogger(__name__)
@@ -24,7 +24,43 @@ PLATFORM_MAP = {
"windows": "win32",
}
EXCLUDED_SKILL_DIRS = frozenset((".git", ".github", ".hub", ".archive"))
EXCLUDED_SKILL_DIRS = frozenset(
(
".git",
".github",
".hub",
".archive",
".venv",
"venv",
"node_modules",
"site-packages",
"__pycache__",
".tox",
".nox",
".pytest_cache",
".mypy_cache",
".ruff_cache",
)
)
def is_excluded_skill_path(path) -> bool:
"""True if any component of *path* is in EXCLUDED_SKILL_DIRS.
Use this on every SKILL.md path produced by ``rglob`` to prune
dependency, virtualenv, VCS, and cache directories. Centralising the
check here keeps every skill-scanning site in sync with the shared
exclusion set.
Accepts a Path or string.
"""
try:
parts = path.parts # Path
except AttributeError:
from pathlib import PurePath
parts = PurePath(str(path)).parts
return any(part in EXCLUDED_SKILL_DIRS for part in parts)
# ── Lazy YAML loader ─────────────────────────────────────────────────────
@@ -100,6 +136,14 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
If the field is absent or empty the skill is compatible with **all**
platforms (backward-compatible default).
Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
Linux userland riding on the Android kernel, so skills tagged
``linux`` are treated as compatible in Termux regardless of which
``sys.platform`` value Python reports. Individual Linux commands
inside a skill may still misbehave (no systemd, BusyBox utils, no
apt/dnf, etc.) but that is on the skill, not on platform gating.
"""
platforms = frontmatter.get("platforms")
if not platforms:
@@ -107,11 +151,21 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
if not isinstance(platforms, list):
platforms = [platforms]
current = sys.platform
running_in_termux = is_termux()
for platform in platforms:
normalized = str(platform).lower().strip()
mapped = PLATFORM_MAP.get(normalized, normalized)
if current.startswith(mapped):
return True
# Termux runs a Linux userland on Android. Accept linux-tagged
# skills regardless of whether sys.platform is "linux" (pre-3.13
# Termux) or "android" (Python 3.13+ Termux, and any other
# Android runtime).
if running_in_termux and mapped == "linux":
return True
# Explicit termux/android tags match a Termux session too.
if running_in_termux and mapped in ("termux", "android"):
return True
return False
@@ -478,7 +532,8 @@ def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
def iter_skill_index_files(skills_dir: Path, filename: str):
"""Walk skills_dir yielding sorted paths matching *filename*.
Excludes ``.git``, ``.github``, ``.hub``, ``.archive`` directories.
Excludes Hermes metadata, VCS, virtualenv/dependency, and cache
directories so dependencies cannot register nested skills.
"""
matches = []
for root, dirs, files in os.walk(skills_dir, followlinks=True):
+34
View File
@@ -205,6 +205,40 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if _env_hints:
stable_parts.append(_env_hints)
# Active-profile hint — names the Hermes profile the agent is running
# under so it doesn't conflate ~/.hermes/skills/ (default profile) with
# ~/.hermes/profiles/<active>/skills/ (this profile's). Deterministic
# for the lifetime of the agent — profile name doesn't change
# mid-session, so this doesn't break the prompt cache.
# See file_safety._resolve_active_profile_name + classify_cross_profile_target
# for the matching tool-side guard.
try:
from agent.file_safety import _resolve_active_profile_name
active_profile = _resolve_active_profile_name()
except Exception:
active_profile = "default"
if active_profile == "default":
stable_parts.append(
"Active Hermes profile: default. Other profiles (if any) live "
"under ~/.hermes/profiles/<name>/. Each profile has its own "
"skills/, plugins/, cron/, and memories/ that affect a different "
"session than this one. Do not modify another profile's "
"skills/plugins/cron/memories unless the user explicitly directs "
"you to."
)
else:
stable_parts.append(
f"Active Hermes profile: {active_profile}. This session reads "
f"and writes ~/.hermes/profiles/{active_profile}/. The default "
f"profile's data lives at ~/.hermes/skills/, ~/.hermes/plugins/, "
f"~/.hermes/cron/, ~/.hermes/memories/ — those belong to a "
f"different session run from a different shell. Do NOT modify "
f"another profile's skills/plugins/cron/memories unless the user "
f"explicitly directs you to. The cross-profile write guard will "
f"refuse such writes by default; pass cross_profile=True only "
f"after explicit direction."
)
platform_key = (agent.platform or "").lower().strip()
if platform_key in PLATFORM_HINTS:
stable_parts.append(PLATFORM_HINTS[platform_key])
+3 -1
View File
@@ -388,6 +388,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
agent.tool_progress_callback(
"tool.completed", function_name, None, None,
duration=tool_duration, is_error=is_error,
result=function_result,
)
except Exception as cb_err:
logging.debug(f"Tool progress callback error: {cb_err}")
@@ -491,7 +492,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
try:
function_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as e:
logging.warning(f"Unexpected JSON error after validation: {e}")
logger.warning(f"Unexpected JSON error after validation: {e}")
function_args = {}
if not isinstance(function_args, dict):
function_args = {}
@@ -822,6 +823,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
agent.tool_progress_callback(
"tool.completed", function_name, None, None,
duration=tool_duration, is_error=_is_error_result,
result=function_result,
)
except Exception as cb_err:
logging.debug(f"Tool progress callback error: {cb_err}")
+11 -1
View File
@@ -106,7 +106,17 @@ class AnthropicTransport(ProviderTransport):
elif block.type == "tool_use":
name = block.name
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
name = name[len(_MCP_PREFIX):]
stripped = name[len(_MCP_PREFIX):]
# Only strip the mcp_ prefix for OAuth-injected tools
# (where Hermes adds the prefix when sending to Anthropic
# and must remove it on the way back). Native MCP server
# tools (from mcp_servers: in config.yaml) are registered
# in the tool registry under their FULL mcp_<server>_<tool>
# name and must NOT be stripped. GH-25255.
from tools.registry import registry as _tool_registry
if (_tool_registry.get_entry(stripped)
and not _tool_registry.get_entry(name)):
name = stripped
tool_calls.append(
ToolCall(
id=block.id,
+20 -3
View File
@@ -113,9 +113,8 @@ class ChatCompletionsTransport(ProviderTransport):
self, messages: list[dict[str, Any]], **kwargs
) -> list[dict[str, Any]]:
"""Messages are already in OpenAI format — strip internal fields
that strict chat-completions providers reject with HTTP 400/422.
Strips:
that strict chat-completions providers reject with HTTP 400/422
(or, in the case of some OpenAI-compatible gateways, 5xx):
- Codex Responses API fields: ``codex_reasoning_items`` /
``codex_message_items`` on the message, ``call_id`` /
@@ -127,6 +126,16 @@ class ChatCompletionsTransport(ProviderTransport):
``Extra inputs are not permitted, field: 'messages[N].tool_name'``.
Permissive providers (OpenRouter, MiniMax) silently ignore the
field, which masked the bug for months.
- Hermes-internal scaffolding markers any top-level message key
starting with ``_`` (e.g. ``_empty_recovery_synthetic``,
``_empty_terminal_sentinel``, ``_thinking_prefill``). These are
bookkeeping flags the agent loop attaches to messages so the
persistence layer can later strip its own scaffolding; they must
never reach the wire. Permissive providers (real OpenAI,
Anthropic) silently drop unknown message keys, but strict
gateways (e.g. opencode-go, codex.nekos.me) reject with
``Extra inputs are not permitted, field: 'messages[N]._empty_recovery_synthetic'``,
which then poisons every subsequent request in the session.
"""
needs_sanitize = False
for msg in messages:
@@ -139,6 +148,9 @@ class ChatCompletionsTransport(ProviderTransport):
):
needs_sanitize = True
break
if any(isinstance(k, str) and k.startswith("_") for k in msg):
needs_sanitize = True
break
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list):
for tc in tool_calls:
@@ -160,6 +172,11 @@ class ChatCompletionsTransport(ProviderTransport):
msg.pop("codex_reasoning_items", None)
msg.pop("codex_message_items", None)
msg.pop("tool_name", None)
# Drop all Hermes-internal scaffolding markers (``_``-prefixed).
# OpenAI's message schema has no ``_``-prefixed fields, so this
# is safe and future-proofs against new markers being added.
for key in [k for k in msg if isinstance(k, str) and k.startswith("_")]:
msg.pop(key, None)
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list):
for tc in tool_calls:
+37 -2
View File
@@ -87,6 +87,39 @@ class TurnResult:
_TURN_ABORTED_MARKERS = ("<turn_aborted>", "<turn_aborted/>")
def _coerce_turn_input_text(user_input: Any) -> str:
"""Collapse Hermes/OpenAI rich content into app-server text input.
The current `turn/start` path sends text items only. TUI image attachment
can hand us OpenAI-style content parts, so keep the text/path hints and
replace opaque image payloads with a small marker instead of putting a
Python list into the `text` field.
"""
if isinstance(user_input, str):
return user_input
if isinstance(user_input, list):
parts: list[str] = []
for item in user_input:
if isinstance(item, str):
if item.strip():
parts.append(item)
continue
if not isinstance(item, dict):
if item is not None:
parts.append(str(item))
continue
item_type = item.get("type")
if item_type in {"text", "input_text"}:
text = item.get("text") or item.get("content") or ""
if text:
parts.append(str(text))
elif item_type in {"image", "image_url", "input_image"}:
parts.append("[image attached]")
text = "\n\n".join(p for p in parts if p).strip()
return text or "What do you see in this image?"
return "" if user_input is None else str(user_input)
# Substrings in codex stderr / JSON-RPC error messages that signal the
# subprocess died because its OAuth credentials are no longer valid.
# Kept conservative: we only redirect users to `codex login` when we're
@@ -327,7 +360,7 @@ class CodexAppServerSession:
def run_turn(
self,
user_input: str,
user_input: Any,
*,
turn_timeout: float = 600.0,
notification_poll_timeout: float = 0.25,
@@ -365,6 +398,8 @@ class CodexAppServerSession:
self._interrupt_event.clear()
projector = CodexEventProjector()
user_input_text = _coerce_turn_input_text(user_input)
# Send turn/start with the user input. Text-only for now (codex
# supports rich content but Hermes' text path is the common case).
try:
@@ -372,7 +407,7 @@ class CodexAppServerSession:
"turn/start",
{
"threadId": self._thread_id,
"input": [{"type": "text", "text": user_input}],
"input": [{"type": "text", "text": user_input_text}],
},
timeout=10,
)
+274
View File
@@ -0,0 +1,274 @@
"""
Text-to-Speech Provider ABC
============================
Defines the pluggable-backend interface for text-to-speech synthesis.
Providers register instances via
``PluginContext.register_tts_provider()``; the active one (selected via
``tts.provider`` in ``config.yaml``) services every ``text_to_speech``
tool call **only when the configured name is neither a built-in nor a
command-type provider declared under ``tts.providers.<name>``**.
Three coexisting TTS extension surfaces in resolution order:
1. **Built-in providers** (``BUILTIN_TTS_PROVIDERS`` in
:mod:`tools.tts_tool`) native Python implementations (edge, openai,
elevenlabs, ). **Always win** plugins cannot shadow them.
2. **Command-type providers** declared under ``tts.providers.<name>:
type: command`` (PR #17843, commit ``2facea7f7``). Wire any local
CLI into Hermes with shell-template placeholders. **Wins over a
same-name plugin** config is more local than plugin install.
3. **Plugin-registered providers** (this ABC). For backends that need a
Python SDK, streaming bytes, OAuth refresh, or voice-listing APIs
the shell-template grammar can't reasonably express.
Built-ins-always-win is enforced at registration time
(:func:`agent.tts_registry.register_provider` rejects names in
``BUILTIN_TTS_PROVIDERS`` with a warning) AND at dispatch time
(:func:`tools.tts_tool._dispatch_to_plugin_provider` re-checks
defensively). The dispatcher also rejects plugin dispatch when a same-
name command provider is configured.
Providers live in ``<repo>/plugins/tts/<name>/`` (built-in plugins, no
shipped today) or ``~/.hermes/plugins/tts/<name>/`` (user-installed).
None ship in-tree as of issue #30398 — the hook is additive
infrastructure waiting for a real consumer (Cartesia, Fish Audio, ).
Response contract
-----------------
:meth:`TTSProvider.synthesize` writes the audio bytes to ``output_path``
and returns the path as a string. Implementations should raise on
failure the dispatcher converts exceptions into the standard
``{success: False, error: }`` JSON envelope the rest of Hermes
expects.
"""
from __future__ import annotations
import abc
import logging
from typing import Any, Dict, Iterator, List, Optional
logger = logging.getLogger(__name__)
DEFAULT_OUTPUT_FORMAT = "mp3"
VALID_OUTPUT_FORMATS = frozenset({"mp3", "wav", "ogg", "opus", "flac"})
# ---------------------------------------------------------------------------
# ABC
# ---------------------------------------------------------------------------
class TTSProvider(abc.ABC):
"""Abstract base class for a text-to-speech backend.
Subclasses must implement :attr:`name` and :meth:`synthesize`.
Everything else has sane defaults override only what your provider
needs.
"""
@property
@abc.abstractmethod
def name(self) -> str:
"""Stable short identifier used in ``tts.provider`` config.
Lowercase, no spaces. Examples: ``cartesia``, ``fishaudio``,
``deepgram``. Names that collide with a built-in TTS provider
(``edge``, ``openai``, ``elevenlabs``, ``minimax``, ``gemini``,
``mistral``, ``xai``, ``piper``, ``kittentts``, ``neutts``) are
rejected at registration time.
"""
@property
def display_name(self) -> str:
"""Human-readable label shown in ``hermes tools``.
Defaults to ``name.title()`` (e.g. ``Cartesia`` for ``cartesia``).
"""
return self.name.title()
def is_available(self) -> bool:
"""Return True when this provider can service calls.
Typically checks for a required API key + that the SDK is
importable. Default: True (providers with no external
dependencies are always available).
Must NOT raise used by the picker and ``hermes setup`` for
availability displays and should fail gracefully.
"""
return True
def list_voices(self) -> List[Dict[str, Any]]:
"""Return voice catalog entries.
Each entry::
{
"id": "voice-abc-123", # required
"display": "Aria — neutral female", # optional; defaults to id
"language": "en-US", # optional
"gender": "female", # optional
"preview_url": "https://...mp3", # optional
}
Default: empty list (provider has no enumerable voices or
doesn't surface them via API).
"""
return []
def list_models(self) -> List[Dict[str, Any]]:
"""Return model catalog entries.
Each entry::
{
"id": "sonic-2", # required
"display": "Sonic 2", # optional
"languages": ["en", "es", "fr"], # optional
"max_text_length": 5000, # optional
}
Default: empty list (provider has a single fixed model or
doesn't expose model selection).
"""
return []
def get_setup_schema(self) -> Dict[str, Any]:
"""Return provider metadata for the ``hermes tools`` picker.
Used by ``tools_config.py`` to inject this provider as a row in
the Text-to-Speech provider list. Shape::
{
"name": "Cartesia", # picker label
"badge": "paid", # optional short tag
"tag": "Ultra-low-latency streaming", # optional subtitle
"env_vars": [ # keys to prompt for
{"key": "CARTESIA_API_KEY",
"prompt": "Cartesia API key",
"url": "https://play.cartesia.ai/console"},
],
}
Default: minimal entry derived from ``display_name`` with no
env vars. Override to expose API key prompts and custom badges.
"""
return {
"name": self.display_name,
"badge": "",
"tag": "",
"env_vars": [],
}
def default_model(self) -> Optional[str]:
"""Return the default model id, or None if not applicable."""
models = self.list_models()
if models:
return models[0].get("id")
return None
def default_voice(self) -> Optional[str]:
"""Return the default voice id, or None if not applicable."""
voices = self.list_voices()
if voices:
return voices[0].get("id")
return None
@abc.abstractmethod
def synthesize(
self,
text: str,
output_path: str,
*,
voice: Optional[str] = None,
model: Optional[str] = None,
speed: Optional[float] = None,
format: str = DEFAULT_OUTPUT_FORMAT,
**extra: Any,
) -> str:
"""Synthesize ``text`` and write audio bytes to ``output_path``.
Returns the absolute path to the written file as a string
(typically just echoes ``output_path``). Raises on failure
the dispatcher converts exceptions to the standard
``{success: False, error: ...}`` JSON envelope.
Args:
text: The text to synthesize. Already truncated to the
provider's max length by the dispatcher.
output_path: Absolute path where the audio file should be
written. Parent directory is guaranteed to exist.
voice: Voice identifier from :meth:`list_voices`, or None
to use :meth:`default_voice`.
model: Model identifier from :meth:`list_models`, or None
to use :meth:`default_model`.
speed: Optional speech-rate multiplier (1.0 = normal).
Providers that don't support speed control should
ignore this argument.
format: Output audio format. Implementations should match
the requested format when possible; if unsupported,
pick the closest equivalent and ensure ``output_path``
ends with the correct extension.
**extra: Forward-compat parameters future schema versions
may expose. Implementations should ignore unknown keys.
"""
def stream(
self,
text: str,
*,
voice: Optional[str] = None,
model: Optional[str] = None,
format: str = "opus",
**extra: Any,
) -> Iterator[bytes]:
"""Stream synthesized audio bytes.
Optional. Providers that don't support streaming raise
:class:`NotImplementedError` (the default) and the dispatcher
falls back to :meth:`synthesize` + read-whole-file.
Args mirror :meth:`synthesize`. Default ``format`` is ``opus``
because the primary streaming use case is voice-bubble
delivery (Telegram et al.) which requires Opus.
"""
raise NotImplementedError(
f"TTS provider {self.name!r} does not implement streaming "
"synthesis. Use synthesize() instead, or implement stream() "
"if your backend supports it."
)
@property
def voice_compatible(self) -> bool:
"""Whether output is suitable for voice-bubble delivery.
Mirrors the ``tts.providers.<name>.voice_compatible`` field
from PR #17843. When True, the gateway's voice-message
delivery pipeline runs ffmpeg conversion to Opus if needed.
When False, output is delivered as a regular audio attachment.
Default: False (safe providers opt in explicitly).
"""
return False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def resolve_output_format(value: Optional[str]) -> str:
"""Clamp an output_format value to the valid set.
Invalid values are coerced to :data:`DEFAULT_OUTPUT_FORMAT` rather
than rejected so the tool surface is forgiving of agent mistakes.
"""
if not isinstance(value, str):
return DEFAULT_OUTPUT_FORMAT
v = value.strip().lower()
if v in VALID_OUTPUT_FORMATS:
return v
return DEFAULT_OUTPUT_FORMAT
+133
View File
@@ -0,0 +1,133 @@
"""
TTS Provider Registry
=====================
Central map of registered TTS providers. Populated by plugins at
import-time via :meth:`PluginContext.register_tts_provider`; consumed
by :mod:`tools.tts_tool` to dispatch ``text_to_speech`` tool calls to
the active plugin backend **when** the configured ``tts.provider``
name is neither a built-in nor a command-type provider.
Built-ins-always-win
--------------------
Plugin names that collide with a built-in TTS provider (``edge``,
``openai``, ``elevenlabs``, ``minimax``, ``gemini``, ``mistral``,
``xai``, ``piper``, ``kittentts``, ``neutts``) are rejected at
registration with a warning. This invariant is also re-checked at
dispatch time in :func:`tools.tts_tool._dispatch_to_plugin_provider`.
Command-providers-win-over-plugins
----------------------------------
This registry doesn't enforce the command-vs-plugin precedence — that
lives in the dispatcher, which checks for a same-name
``tts.providers.<name>: type: command`` entry before consulting the
registry. The rationale is locality: a name declared in the user's
``config.yaml`` is more specific to their setup than a plugin that
happens to be installed.
"""
from __future__ import annotations
import logging
import threading
from typing import Dict, List, Optional
from agent.tts_provider import TTSProvider
logger = logging.getLogger(__name__)
# Names reserved for native built-in TTS handlers. Plugins cannot
# register a name in this set — the registration call is rejected with
# a warning. **Kept in sync with ``BUILTIN_TTS_PROVIDERS`` in
# :mod:`tools.tts_tool`** — a regression test in
# ``tests/agent/test_tts_registry.py::TestBuiltinSync`` fails if the
# two lists drift. Importing from ``tools.tts_tool`` directly would
# create a circular dependency (``tools.tts_tool`` imports
# ``agent.tts_registry`` for dispatch).
_BUILTIN_NAMES = frozenset({
"edge",
"elevenlabs",
"openai",
"minimax",
"xai",
"mistral",
"gemini",
"neutts",
"kittentts",
"piper",
})
_providers: Dict[str, TTSProvider] = {}
_lock = threading.Lock()
def register_provider(provider: TTSProvider) -> None:
"""Register a TTS provider.
Rejects:
- Non-:class:`TTSProvider` instances (raises :class:`TypeError`).
- Empty/whitespace ``.name`` (raises :class:`ValueError`).
- Names colliding with a built-in (logs a warning, silently
ignores built-ins-always-win invariant).
Re-registration (same ``name``) overwrites the previous entry and
logs a debug message makes hot-reload scenarios (tests, dev
loops) behave predictably.
"""
if not isinstance(provider, TTSProvider):
raise TypeError(
f"register_provider() expects a TTSProvider instance, "
f"got {type(provider).__name__}"
)
name = provider.name
if not isinstance(name, str) or not name.strip():
raise ValueError("TTS provider .name must be a non-empty string")
key = name.strip().lower()
if key in _BUILTIN_NAMES:
logger.warning(
"TTS provider '%s' shadows a built-in name; registration ignored. "
"Built-in TTS providers (%s) always win — pick a different name.",
key, ", ".join(sorted(_BUILTIN_NAMES)),
)
return
with _lock:
existing = _providers.get(key)
_providers[key] = provider
if existing is not None:
logger.debug(
"TTS provider '%s' re-registered (was %r)",
key, type(existing).__name__,
)
else:
logger.debug(
"Registered TTS provider '%s' (%s)",
key, type(provider).__name__,
)
def list_providers() -> List[TTSProvider]:
"""Return all registered providers, sorted by name."""
with _lock:
items = list(_providers.values())
return sorted(items, key=lambda p: p.name)
def get_provider(name: str) -> Optional[TTSProvider]:
"""Return the provider registered under *name*, or None.
Name matching is case-insensitive and whitespace-tolerant mirrors
how ``tools.tts_tool._get_provider`` normalizes the configured
``tts.provider`` value.
"""
if not isinstance(name, str):
return None
return _providers.get(name.strip().lower())
def _reset_for_tests() -> None:
"""Clear the registry. **Test-only.**"""
with _lock:
_providers.clear()
+50
View File
@@ -63,3 +63,53 @@ src/
├── main.tsx # React entry point
└── index.css # Tailwind imports and theme variables
```
## Typography & contrast rules
Read before adding or editing UI styles. These rules keep the dashboard legible across all built-in themes and stop drift back into the patterns the design system was just refactored out of.
### Text size floor
- **Minimum body size: `text-xs` (12px / 0.75rem).** Do not use arbitrary `text-[0.6rem]`, `text-[0.65rem]`, `text-[9px]`, `text-[10px]`, or `text-[11px]` on copy, hints, labels, counts, or badges. Use the standard scale: `text-xs`, `text-sm`, `text-base`.
- Smaller sizes are only acceptable on **decorative overlays** (chart stripes, empty-state icons) — never on text the user is meant to read.
### Opacity floor on text
- **Never apply opacity below 0.7 to text.** No `opacity-30`, `opacity-50`, `opacity-60` on `<span>`s, `<p>`s, labels, etc.
- **Do not stack opacity tokens.** Patterns like `text-muted-foreground/60`, `text-midground/70`, `text-foreground/50` create unpredictable WCAG failures because the parent token already has alpha.
- Use the **semantic text tokens** from `@nous-research/ui`'s `globals.css`:
- `text-text-primary` — default body text.
- `text-text-secondary` — subtitles, meta, inactive nav.
- `text-text-tertiary` — small chrome labels, counts, footnotes.
- `text-text-disabled` — disabled states.
- `text-text-on-accent` — text on filled accent surfaces.
### Brand uppercase via `text-display`, not raw `uppercase`
- The dashboard preserves the Nous brand uppercase aesthetic, but it is **opt-in per element, not global**.
- Apply uppercase via the DS utility `text-display` on **brand chrome only** — page titles, nav section headings, badges, brand wordmark. DS components (`Button`, `Badge`, `Tabs`, `Segmented`, etc.) already self-apply `text-display`.
- **Do not introduce new `uppercase`** (the literal Tailwind class) in `hermes-agent/web/src`. Prefer `text-display` for new brand chrome. Legacy `uppercase` call sites (e.g. `components/ui/label.tsx`, `card.tsx`) remain until migrated.
- The app shell no longer forces uppercase globally, so blanket `normal-case` opt-outs are unnecessary. Use `normal-case` only where a DS component applies `text-display` but the label should stay sentence case — e.g. dynamic user content (model slugs, theme names) **or** fixed UI copy that is not brand chrome (EnvPage “not configured” toggle, sidebar “New chat”).
### Fonts
Typography is **opt-in per surface**, not global on layout shells — the app shell and page header keep their original theme/expanded fonts; Mondwest applies only where explicitly set.
| Tier | Classes | Use for |
|------|---------|---------|
| Brand chrome | `font-mondwest text-display` (or `themedChrome`) | Sidebar nav, card section headers (`CardTitle`), Segmented filter buttons, filter panel headings |
| Themed body | `font-mondwest normal-case` (or `themedBody`) | Card content (`Card`, `CardDescription`), session/platform rows, analytics tables — **scoped to the component** |
| Page chrome | `font-expanded` | Page header h1 (`PageHeaderProvider`) — sentence case, not `text-display` |
| Wordmark | `Typography` + size/tracking only | Sidebar/mobile “Hermes Agent” — mixed case, no Mondwest, no `text-display` |
| Technical | `font-mono-ui` / `font-mono` / `font-courier` | Model slugs, env keys, schedules, YAML, repo URLs |
- Do **not** put `themedBody` or `themedFont` on `<main>`, `App`, or other layout wrappers — it overrides component-scoped styles.
- **`Card`** applies `themedBody`; **`CardTitle`** uses `text-display` (uppercase chrome); **`CardDescription`** uses `themedBody`.
- **`NouiTypography`** defaults to `font-sans` unless a font prop is passed.
- Do **not** use raw `font-sans` or `font-display` (theme sans variable) on new dashboard UI — prefer Mondwest tiers above where brand-appropriate.
### Color tokens
- Prefer **semantic tokens** (`text-text-*`, `bg-card`, `border-border`, `text-foreground`, `text-destructive`, `text-success`, `text-warning`) over raw layer references (`text-midground`, `text-foreground`).
- `text-muted-foreground` is now wired to `--color-text-secondary`, so existing call sites stay correct, but new code should prefer the semantic name.
- When you genuinely need a non-token color (icon de-emphasis on a chart, terminal foreground via inline style), keep alpha at `≥ 0.7` for any text.
+4 -11
View File
@@ -9,7 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@hermes/shared": "file:../shared",
"@nous-research/ui": "^0.14.2",
"@nous-research/ui": "0.16.0",
"@observablehq/plot": "^0.6.17",
"@react-three/fiber": "^9.6.0",
"@tailwindcss/vite": "^4.2.1",
@@ -20,7 +20,6 @@
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"flag-icons": "^7.5.0",
"gsap": "^3.15.0",
"leva": "^0.10.1",
"lucide-react": "^0.577.0",
@@ -1092,9 +1091,9 @@
}
},
"node_modules/@nous-research/ui": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.14.2.tgz",
"integrity": "sha512-H3cMt2e0IpmcTNOmR6zVX+8ja48w4X4F/IFXhWCpaoVs8zKVRN12Ryb4RnX/ac8IrbUu6UsIds7ZtmXxPHcfdQ==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.16.0.tgz",
"integrity": "sha512-JvSwf9vBOCEEGDSOYIRn/F/JJSBDh9DvGU3s3OFbX6K1otnSK7s47cZdgvfBoEPmeKFom2fWQDDqfzLV+eR7Qg==",
"license": "MIT",
"dependencies": {
"@nanostores/react": "^1.1.0",
@@ -4158,12 +4157,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flag-icons": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
"license": "MIT"
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+1 -2
View File
@@ -11,7 +11,7 @@
},
"dependencies": {
"@hermes/shared": "file:../shared",
"@nous-research/ui": "^0.14.2",
"@nous-research/ui": "0.16.0",
"@observablehq/plot": "^0.6.17",
"@react-three/fiber": "^9.6.0",
"@tailwindcss/vite": "^4.2.1",
@@ -22,7 +22,6 @@
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"flag-icons": "^7.5.0",
"gsap": "^3.15.0",
"leva": "^0.10.1",
"lucide-react": "^0.577.0",
+21 -15
View File
@@ -326,7 +326,9 @@ export default function App() {
api
.getConfig()
.then((cfg) => {
const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown };
const dash = (cfg?.dashboard ?? {}) as {
show_token_analytics?: unknown;
};
setShowTokenAnalytics(dash.show_token_analytics === true);
})
.catch(() => setShowTokenAnalytics(false));
@@ -366,7 +368,9 @@ export default function App() {
const base = embeddedChat
? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST]
: BUILTIN_NAV_REST;
return showTokenAnalytics ? base : base.filter((n) => n.path !== "/analytics");
return showTokenAnalytics
? base
: base.filter((n) => n.path !== "/analytics");
}, [embeddedChat, showTokenAnalytics]);
const sidebarNav = useMemo(
@@ -416,7 +420,7 @@ export default function App() {
return (
<div
data-layout-variant={layoutVariant}
className="font-mondwest flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black uppercase text-midground antialiased"
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black text-text-primary antialiased"
>
<SelectionSwitcher />
<Backdrop />
@@ -442,7 +446,7 @@ export default function App() {
aria-label={t.app.openNavigation}
aria-expanded={mobileOpen}
aria-controls="app-sidebar"
className="text-midground/70 hover:text-midground"
className="text-text-secondary hover:text-midground"
>
<Menu />
</Button>
@@ -498,7 +502,7 @@ export default function App() {
<PluginSlot name="header-left" />
<Typography
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground uppercase"
style={{ mixBlendMode: "plus-lighter" }}
>
Hermes
@@ -512,7 +516,7 @@ export default function App() {
size="icon"
onClick={closeMobile}
aria-label={t.app.closeNavigation}
className="lg:hidden text-midground/70 hover:text-midground"
className="lg:hidden text-text-secondary hover:text-midground"
>
<X />
</Button>
@@ -542,7 +546,7 @@ export default function App() {
<span
className={cn(
"px-5 pt-2.5 pb-1",
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
)}
id="hermes-sidebar-plugin-nav-heading"
>
@@ -671,10 +675,12 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
cn(
"group relative flex items-center gap-3",
"px-5 py-2.5",
"font-mondwest text-[0.8rem] tracking-[0.12em]",
"font-mondwest text-display uppercase text-sm tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
isActive ? "text-midground" : "opacity-60 hover:opacity-100",
isActive
? "text-midground"
: "text-text-secondary hover:text-midground",
)
}
style={{
@@ -746,7 +752,7 @@ function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
<span
className={cn(
"px-5 pt-0.5 pb-0.5",
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
)}
>
{t.app.system}
@@ -772,12 +778,12 @@ function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
active={busy}
className={cn(
"gap-3 px-5 py-1.5 whitespace-nowrap",
"font-mondwest text-[0.75rem] tracking-[0.1em]",
"transition-opacity",
"font-mondwest text-display text-xs tracking-[0.1em]",
"transition-colors",
busy
? "text-midground opacity-100"
: "opacity-60 hover:opacity-100",
"disabled:opacity-30",
? "text-midground"
: "text-text-secondary hover:text-midground",
"disabled:text-text-disabled",
)}
>
{isPending ? (
+2 -2
View File
@@ -11,8 +11,8 @@ function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; sch
return (
<div className="flex flex-col gap-0.5">
{keyPath && <span className="text-[10px] font-mono text-muted-foreground/50">{keyPath}</span>}
{description && <span className="text-xs text-muted-foreground/70">{description}</span>}
{keyPath && <span className="text-xs font-mono text-text-tertiary">{keyPath}</span>}
{description && <span className="text-xs text-text-secondary">{description}</span>}
</div>
);
}
@@ -7,7 +7,7 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { Typography } from "@/components/NouiTypography";
import { cn } from "@/lib/utils";
import { cn, themedBody } from "@/lib/utils";
const CLOSE_DRAG_MIN_PX = 72;
const CLOSE_DRAG_RATIO = 0.18;
@@ -168,6 +168,7 @@ export function BottomPickSheet({
aria-modal="true"
ref={sheetRef}
className={cn(
themedBody,
"relative flex max-h-[85dvh] min-h-0 flex-col rounded-t-xl border border-current/20",
"bg-background-base/98 pb-[max(1rem,env(safe-area-inset-bottom))]",
"shadow-[0_-12px_40px_-8px_rgba(0,0,0,0.55)] backdrop-blur-md",
@@ -200,7 +201,7 @@ export function BottomPickSheet({
<Typography
mondwest
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
>
{title}
</Typography>
@@ -304,13 +304,13 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
return (
<aside
className={cn(
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1 normal-case lg:w-80",
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1 lg:w-80",
className,
)}
>
<Card className="flex items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
<div className="text-display text-xs tracking-wider text-text-tertiary">
model
</div>
@@ -321,7 +321,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
onClick={() => setModelOpen(true)}
suffix={
canPickModel ? (
<ChevronDown className="opacity-60" />
<ChevronDown className="text-text-secondary" />
) : undefined
}
className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium underline-offset-4 decoration-current/40 hover:underline disabled:no-underline"
@@ -357,13 +357,13 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
)}
<Card className="flex min-h-0 flex-none flex-col px-2 py-2">
<div className="px-1 pb-2 text-xs uppercase tracking-wider text-muted-foreground">
<div className="text-display px-1 pb-2 text-xs tracking-wider text-text-tertiary">
tools
</div>
<div className="flex min-h-0 flex-col gap-1.5">
{tools.length === 0 ? (
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
<div className="px-2 py-4 text-center text-xs text-text-secondary">
no tool calls yet
</div>
) : (
@@ -9,15 +9,16 @@ import type { Locale } from "@/i18n";
import { cn } from "@/lib/utils";
/**
* Language picker shows the current language's flag + endonym, opens a
* dropdown of all supported locales when clicked. Persists choice to
* localStorage via the I18n context.
* Language picker shows the current language's endonym, opens a dropdown
* of all supported locales when clicked. Persists choice to localStorage via
* the I18n context.
*
* Replaces the older two-state ENZH toggle now that we ship 16 locales
* (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu).
*
* Locale markers use lipis/flag-icons (SVG sprites) instead of emoji so flags
* render consistently across platforms.
* No country flags by design languages aren't countries, and flag pairings
* inevitably create political mismappings (e.g. Mandarin variants any single
* jurisdiction, English GB, Portuguese PT). Endonyms are unambiguous.
*
* When placed at the bottom of the sidebar (next to ThemeSwitcher), pass
* `dropUp` so the list opens above the trigger and avoids clipping below the
@@ -68,13 +69,12 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
aria-label={t.language.switchTo}
aria-haspopup="listbox"
aria-expanded={open}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground"
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground"
>
<span className="inline-flex items-center gap-1.5">
<LocaleFlagIcon countryCode={current.flagCountryCode} />
<Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
className="hidden sm:inline text-display tracking-wide text-xs"
>
{locale === "en" ? "EN" : current.name}
</Typography>
@@ -146,8 +146,6 @@ function LanguageSwitcherOptions({
role="option"
type="button"
>
<LocaleFlagIcon countryCode={meta.flagCountryCode} />
<span className="truncate">{meta.name}</span>
{selected && <span className="ml-auto text-xs"></span>}
@@ -158,15 +156,6 @@ function LanguageSwitcherOptions({
);
}
function LocaleFlagIcon({ countryCode }: LocaleFlagIconProps) {
return (
<span
aria-hidden
className={cn("fi fis shrink-0 text-base leading-none", `fi-${countryCode}`)}
/>
);
}
interface LanguageSwitcherOptionsProps {
allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>;
locale: Locale;
@@ -177,7 +166,3 @@ interface LanguageSwitcherOptionsProps {
interface LanguageSwitcherProps {
dropUp?: boolean;
}
interface LocaleFlagIconProps {
countryCode: string;
}
@@ -60,11 +60,11 @@ export function ModelInfoCard({
{formatTokenCount(info.effective_context_length)}
</span>
{info.config_context_length > 0 ? (
<span className="text-amber-500/80 text-[10px]">
<span className="text-amber-500 text-xs">
(override auto: {formatTokenCount(info.auto_context_length)})
</span>
) : (
<span className="text-muted-foreground/60 text-[10px]">
<span className="text-text-tertiary text-xs">
auto-detected
</span>
)}
@@ -86,22 +86,22 @@ export function ModelInfoCard({
{hasCaps && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{caps.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
{caps.supports_vision && (
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-2 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-2 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
<Eye className="h-2.5 w-2.5" /> Vision
</span>
)}
{caps.supports_reasoning && (
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-2 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400">
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-2 py-0.5 text-xs font-medium text-purple-600 dark:text-purple-400">
<Brain className="h-2.5 w-2.5" /> Reasoning
</span>
)}
{caps.model_family && (
<span className="inline-flex items-center gap-1 bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
<span className="inline-flex items-center gap-1 bg-muted px-2 py-0.5 text-xs font-medium text-text-secondary">
{caps.model_family}
</span>
)}
@@ -8,6 +8,7 @@ import type { GatewayClient } from "@/lib/gatewayClient";
import { Check, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn, themedBody } from "@/lib/utils";
/**
* Two-stage model picker modal.
@@ -212,7 +213,7 @@ export function ModelPickerDialog(props: Props) {
aria-modal="true"
aria-labelledby="model-picker-title"
>
<div className="relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col">
<div className={cn(themedBody, "relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
@@ -226,7 +227,7 @@ export function ModelPickerDialog(props: Props) {
<header className="p-5 pb-3 border-b border-border">
<h2
id="model-picker-title"
className="font-display text-base tracking-wider uppercase"
className="font-mondwest text-display text-base tracking-wider"
>
{title}
</h2>
@@ -295,7 +296,7 @@ export function ModelPickerDialog(props: Props) {
/>
<Label
className="font-sans normal-case tracking-normal text-xs text-muted-foreground cursor-pointer"
className="font-mondwest normal-case tracking-normal text-xs text-muted-foreground cursor-pointer"
htmlFor="model-picker-persist-global"
>
Persist globally (otherwise this session only)
@@ -375,7 +376,7 @@ function ProviderColumn({
<span className="font-medium truncate">{p.name}</span>
{p.is_current && <CurrentTag />}
</div>
<div className="text-[0.65rem] text-muted-foreground/80 font-mono truncate">
<div className="text-xs text-text-secondary font-mono truncate">
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
</div>
</div>
@@ -462,7 +463,7 @@ function ModelColumn({
function CurrentTag() {
return (
<span className="text-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0">
<span className="text-display text-xs tracking-wider text-primary shrink-0">
current
</span>
);
@@ -7,6 +7,7 @@ import { H2 } from "@/components/NouiTypography";
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { useI18n } from "@/i18n";
import { cn, themedBody } from "@/lib/utils";
interface Props {
provider: OAuthProvider;
@@ -169,7 +170,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
aria-modal="true"
aria-labelledby="oauth-modal-title"
>
<div className="relative w-full max-w-md border border-border bg-card shadow-2xl">
<div className={cn(themedBody, "relative w-full max-w-md border border-border bg-card shadow-2xl")}>
<Button
ghost
size="icon"
@@ -4,9 +4,7 @@ import {
ShieldOff,
ExternalLink,
RefreshCw,
LogOut,
Terminal,
LogIn,
} from "lucide-react";
import { api, type OAuthProvider } from "@/lib/api";
import { Button } from "@nous-research/ui/ui/components/button";
@@ -105,13 +103,14 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
</CardTitle>
</div>
<Button
size="sm"
outlined
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={refresh}
disabled={loading}
prefix={loading ? <Spinner /> : <RefreshCw />}
aria-label={t.common.refresh}
>
{t.common.refresh}
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</div>
<CardDescription>
@@ -154,46 +153,57 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
<span className="font-medium text-sm">{p.name}</span>
<Badge
tone="outline"
className="text-[11px] uppercase tracking-wide"
className="text-xs tracking-wide"
>
{t.oauth.flowLabels[p.flow]}
</Badge>
{p.status.logged_in && (
<Badge tone="success" className="text-[11px]">
<Badge tone="success" className="text-xs">
{t.oauth.connected}
</Badge>
)}
{expiresLabel === "expired" && (
<Badge tone="destructive" className="text-[11px]">
<Badge tone="destructive" className="text-xs">
{t.oauth.expired}
</Badge>
)}
{expiresLabel && expiresLabel !== "expired" && (
<Badge tone="outline" className="text-[11px]">
<Badge tone="outline" className="text-xs">
{expiresLabel}
</Badge>
)}
</div>
{p.status.logged_in && p.status.token_preview && (
<code className="text-xs font-mono-ui truncate">
<span className="opacity-50">token </span>
<span className="truncate text-xs font-mono-ui text-text-secondary">
<span className="text-text-tertiary">token </span>
{p.status.token_preview}
{p.status.source_label && (
<span className="opacity-40">
<span className="text-text-tertiary">
{" "}
· {p.status.source_label}
</span>
)}
</code>
</span>
)}
{!p.status.logged_in && (
<span className="text-xs text-muted-foreground/80">
{t.oauth.notConnected.split("{command}")[0]}
<code className="text-foreground bg-secondary/40 px-1">
{p.cli_command}
</code>
{t.oauth.notConnected.split("{command}")[1]}
</span>
<>
<span className="text-xs text-text-secondary">
{t.oauth.notConnected.split("{command}")[0].trimEnd()}
{t.oauth.notConnected.split("{command}")[1] ?? ""}
</span>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<code className="font-courier truncate text-xs opacity-60">
{p.cli_command}
</code>
<CopyButton
text={p.cli_command}
label={t.oauth.cli}
copiedLabel={t.oauth.copied}
/>
</div>
</>
)}
{p.status.error && (
<span className="text-xs text-destructive">
@@ -220,32 +230,26 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
{!p.status.logged_in && p.flow !== "external" && (
<Button
size="sm"
className="uppercase"
onClick={() => setLoginFor(p)}
prefix={<LogIn />}
>
{t.oauth.login}
</Button>
)}
{!p.status.logged_in && (
<CopyButton
text={p.cli_command}
label={t.oauth.cli}
copiedLabel={t.oauth.copied}
/>
)}
{p.status.logged_in && p.flow !== "external" && (
<Button
size="sm"
outlined
className="uppercase"
onClick={() => setDisconnectTarget(p)}
disabled={isBusy}
prefix={isBusy ? <Spinner /> : <LogOut />}
prefix={isBusy ? <Spinner /> : undefined}
>
{t.oauth.disconnect}
</Button>
)}
{p.status.logged_in && p.flow === "external" && (
<span className="text-[11px] text-muted-foreground italic px-2">
<span className="text-xs text-text-tertiary italic px-2">
<Terminal className="h-3 w-3 inline mr-0.5" />
{t.oauth.managedExternally}
</span>
@@ -57,18 +57,18 @@ export function PlatformsCard({ platforms }: PlatformsCardProps) {
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium capitalize truncate">
<span className="font-mondwest normal-case text-sm font-medium capitalize truncate">
{name}
</span>
{info.error_message && (
<span className="text-xs text-destructive">
<span className="font-mondwest normal-case text-xs text-destructive">
{info.error_message}
</span>
)}
{info.updated_at && (
<span className="text-xs text-muted-foreground">
<span className="font-mondwest normal-case text-xs text-muted-foreground">
{t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
</span>
)}
@@ -16,8 +16,7 @@ export function SidebarFooter() {
)}
>
<Typography
mondwest
className="font-mono-ui text-[0.7rem] tabular-nums tracking-[0.1em] text-muted-foreground/70 lowercase"
className="font-mono-ui text-xs tabular-nums tracking-[0.08em] text-text-tertiary lowercase"
>
{status?.version != null ? `v${status.version}` : "—"}
</Typography>
@@ -27,7 +26,7 @@ export function SidebarFooter() {
target="_blank"
rel="noopener noreferrer"
className={cn(
"font-mondwest text-[0.65rem] tracking-[0.15em] text-midground",
"font-mondwest text-display text-xs tracking-[0.12em] text-midground",
"transition-opacity hover:opacity-90",
"focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
)}
@@ -27,21 +27,21 @@ export function SidebarStatusStrip() {
className={cn(
"block text-left",
"px-5 pb-2 pt-0.5",
"text-muted-foreground/70",
"transition-colors hover:text-muted-foreground/90",
"text-text-secondary",
"transition-colors hover:text-midground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
"focus-visible:ring-inset",
)}
>
<div className="flex flex-col gap-1 font-mondwest text-[0.55rem] leading-snug tracking-[0.12em]">
<div className="flex flex-col gap-1 font-mondwest text-xs leading-snug tracking-[0.08em]">
<p className="break-words">
<span className="text-muted-foreground/50">{gatewayStatusLabel}</span>{" "}
<span className="text-text-tertiary">{gatewayStatusLabel}</span>{" "}
<span className={cn("font-medium", gw.tone)}>{gw.label}</span>
</p>
<p className="break-words">
<span className="text-muted-foreground/50">{activeSessionsLabel}</span>{" "}
<span className="tabular-nums text-muted-foreground/70">
<span className="text-text-tertiary">{activeSessionsLabel}</span>{" "}
<span className="tabular-nums text-text-secondary">
{status.active_sessions}
</span>
</p>
@@ -158,7 +158,7 @@ export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
</span>
{it.meta && (
<span className="text-[0.7rem] text-muted-foreground/70 truncate ml-auto">
<span className="text-xs text-text-tertiary truncate ml-auto">
{it.meta}
</span>
)}
@@ -65,7 +65,7 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
<Button
ghost
onClick={() => setOpen((o) => !o)}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground"
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground"
title={t.theme?.switchTheme ?? "Switch theme"}
aria-label={t.theme?.switchTheme ?? "Switch theme"}
aria-expanded={open}
@@ -76,7 +76,7 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
<Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
className="hidden sm:inline text-display tracking-wide text-xs"
>
{label}
</Typography>
@@ -115,7 +115,7 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
<div className="border-b border-current/20 px-3 py-2">
<Typography
mondwest
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
>
{sheetTitle}
</Typography>
@@ -166,12 +166,12 @@ function ThemeSwitcherOptions({
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<Typography
mondwest
className="truncate text-[0.75rem] tracking-wide uppercase"
className="truncate text-display text-xs tracking-wide"
>
{th.label}
</Typography>
{th.description && (
<Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50">
<Typography className="truncate text-xs tracking-normal text-text-tertiary">
{th.description}
</Typography>
)}
+5 -5
View File
@@ -104,7 +104,7 @@ export function ToolCall({ tool }: { tool: ToolEntry }) {
<span className="font-mono font-medium shrink-0">{tool.name}</span>
<span className="font-mono text-muted-foreground/80 truncate min-w-0 flex-1">
<span className="font-mono text-text-secondary truncate min-w-0 flex-1">
{tool.context ?? ""}
</span>
@@ -128,7 +128,7 @@ export function ToolCall({ tool }: { tool: ToolEntry }) {
)}
{elapsed && (
<span className="font-mono text-[0.65rem] text-muted-foreground tabular-nums shrink-0">
<span className="font-mono text-xs text-text-tertiary tabular-nums shrink-0">
{elapsed}
</span>
)}
@@ -186,8 +186,8 @@ function Section({
return (
<div className="flex gap-3">
<span
className={`uppercase tracking-wider text-[0.6rem] shrink-0 w-14 pt-0.5 ${
tone === "error" ? "text-destructive/80" : "text-muted-foreground/60"
className={`text-display font-mondwest tracking-wider text-xs shrink-0 w-20 pt-0.5 ${
tone === "error" ? "text-destructive" : "text-text-tertiary"
}`}
>
{label}
@@ -224,5 +224,5 @@ function diffLineClass(line: string): string {
if (line.startsWith("-") && !line.startsWith("---"))
return "text-destructive";
if (line.startsWith("@@")) return "text-primary";
return "text-muted-foreground/80";
return "text-text-secondary";
}
+14 -3
View File
@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn, themedBody } from "@/lib/utils";
/**
* Themed card primitive. Themes can restyle every card without touching
@@ -27,6 +27,7 @@ export function Card({ className, style, ...props }: React.HTMLAttributes<HTMLDi
<div
className={cn(
"border border-border bg-card/80 text-card-foreground w-full",
themedBody,
className,
)}
style={{ ...CARD_STYLE, ...style }}
@@ -40,11 +41,21 @@ export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDiv
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("font-expanded text-sm font-bold tracking-[0.08em] uppercase blend-lighter", className)} {...props} />;
return (
<h3
className={cn(
"font-mondwest text-display text-sm tracking-[0.12em] text-text-primary",
className,
)}
{...props}
/>
);
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("font-mondwest text-xs text-muted-foreground", className)} {...props} />;
return (
<p className={cn("font-mondwest normal-case text-xs text-muted-foreground", className)} {...props} />
);
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { AlertTriangle } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { cn } from "@/lib/utils";
import { cn, themedBody } from "@/lib/utils";
export function ConfirmDialog({
cancelLabel = "Cancel",
@@ -64,6 +64,7 @@ export function ConfirmDialog({
<div
ref={dialogRef}
className={cn(
themedBody,
"relative w-full max-w-md mx-4",
"border border-border bg-card shadow-lg",
"animate-[dialog-in_180ms_ease-out]",
@@ -82,7 +83,7 @@ export function ConfirmDialog({
<div className="flex-1 min-w-0 flex flex-col gap-1">
<h2
id="confirm-dialog-title"
className="font-expanded text-sm font-bold tracking-[0.08em] uppercase blend-lighter"
className="font-mondwest text-display text-sm font-bold tracking-[0.12em] blend-lighter"
>
{title}
</h2>
@@ -90,7 +91,7 @@ export function ConfirmDialog({
{description && (
<p
id="confirm-dialog-desc"
className="font-mondwest text-xs text-muted-foreground leading-relaxed"
className="font-mondwest normal-case text-xs text-muted-foreground leading-relaxed"
>
{description}
</p>
+5 -2
View File
@@ -127,6 +127,7 @@ export const af: Translations = {
sessions: {
title: "Sessies",
overview: "Oorsig",
searchPlaceholder: "Soek boodskap-inhoud...",
noSessions: "Nog geen sessies nie",
noMatch: "Geen sessies stem ooreen met jou soektog nie",
@@ -269,7 +270,7 @@ export const af: Translations = {
"Ontdek, installeer, aktiveer en werk Hermes-inproppe op (`hermes plugins` ekwivalent).",
identifierLabel: "Git-URL of owner/repo",
inactive: "onaktief",
installBtn: "Installeer vanaf Git",
installBtn: "Installeer",
installHeading: "Installeer vanaf GitHub / Git-URL",
installHint: "Gebruik owner/repo-kortvorm of 'n volledige https:// of git@ kloon-URL.",
memoryProviderLabel: "Geheueverskaffer",
@@ -367,6 +368,8 @@ export const af: Translations = {
description: "Bestuur API-sleutels en geheime gestoor in",
hideAdvanced: "Versteek Gevorderd",
showAdvanced: "Wys Gevorderd",
showLess: "Wys minder",
showMore: "Wys meer",
llmProviders: "LLM-verskaffers",
providersConfigured: "{configured} van {total} verskaffers gekonfigureer",
getKey: "Kry sleutel",
@@ -392,7 +395,7 @@ export const af: Translations = {
disconnect: "Ontkoppel",
managedExternally: "Ekstern bestuur",
copied: "Gekopieer ✓",
cli: "CLI",
cli: "Kopieer",
copyCliCommand: "Kopieer CLI-opdrag (vir ekstern / terugval)",
connect: "Koppel",
sessionExpires: "Sessie verval oor {time}",
+23 -19
View File
@@ -37,27 +37,31 @@ const TRANSLATIONS: Record<Locale, Translations> = {
};
// Display metadata for the language picker — endonym (native name) so users
// recognize their language even if they don't speak the current UI language,
// plus a flag-icons sprite (ISO 3166-1 alpha-2) for visual scanning.
// recognize their language even if they don't speak the current UI language.
// Exposed as a constant so the LanguageSwitcher and any future settings page
// can share the same list.
export const LOCALE_META: Record<Locale, { name: string; flagCountryCode: string }> = {
en: { name: "English", flagCountryCode: "gb" },
zh: { name: "简体中文", flagCountryCode: "cn" },
"zh-hant": { name: "繁體中文", flagCountryCode: "tw" },
ja: { name: "日本語", flagCountryCode: "jp" },
de: { name: "Deutsch", flagCountryCode: "de" },
es: { name: "Español", flagCountryCode: "es" },
fr: { name: "Français", flagCountryCode: "fr" },
tr: { name: "Türkçe", flagCountryCode: "tr" },
uk: { name: "Українська", flagCountryCode: "ua" },
af: { name: "Afrikaans", flagCountryCode: "za" },
ko: { name: "한국어", flagCountryCode: "kr" },
it: { name: "Italiano", flagCountryCode: "it" },
ga: { name: "Gaeilge", flagCountryCode: "ie" },
pt: { name: "Português", flagCountryCode: "pt" },
ru: { name: "Русский", flagCountryCode: "ru" },
hu: { name: "Magyar", flagCountryCode: "hu" },
//
// We intentionally do NOT pair locales with country flags. Languages are not
// countries (English ≠ GB, Portuguese ≠ PT, Spanish ≠ ES, Chinese variants ≠
// any single jurisdiction). Endonyms are unambiguous and avoid the political
// mismapping that flag pairings inevitably create.
export const LOCALE_META: Record<Locale, { name: string }> = {
en: { name: "English" },
zh: { name: "简体中文" },
"zh-hant": { name: "繁體中文" },
ja: { name: "日本語" },
de: { name: "Deutsch" },
es: { name: "Español" },
fr: { name: "Français" },
tr: { name: "Türkçe" },
uk: { name: "Українська" },
af: { name: "Afrikaans" },
ko: { name: "한국어" },
it: { name: "Italiano" },
ga: { name: "Gaeilge" },
pt: { name: "Português" },
ru: { name: "Русский" },
hu: { name: "Magyar" },
};
const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[];
+5 -2
View File
@@ -127,6 +127,7 @@ export const de: Translations = {
sessions: {
title: "Sitzungen",
overview: "Übersicht",
searchPlaceholder: "Nachrichteninhalt suchen...",
noSessions: "Noch keine Sitzungen",
noMatch: "Keine Sitzungen entsprechen deiner Suche",
@@ -269,7 +270,7 @@ export const de: Translations = {
"Hermes-Plugins entdecken, installieren, aktivieren und aktualisieren (entspricht `hermes plugins`).",
identifierLabel: "Git-URL oder owner/repo",
inactive: "inaktiv",
installBtn: "Aus Git installieren",
installBtn: "Installieren",
installHeading: "Aus GitHub / Git-URL installieren",
installHint: "Verwende owner/repo-Kurzform oder eine vollständige https:// oder git@ Klon-URL.",
memoryProviderLabel: "Speicheranbieter",
@@ -367,6 +368,8 @@ export const de: Translations = {
description: "Verwalte API-Schlüssel und Geheimnisse, die hier gespeichert sind",
hideAdvanced: "Erweitert ausblenden",
showAdvanced: "Erweitert anzeigen",
showLess: "Weniger anzeigen",
showMore: "Mehr anzeigen",
llmProviders: "LLM-Anbieter",
providersConfigured: "{configured} von {total} Anbietern konfiguriert",
getKey: "Schlüssel holen",
@@ -392,7 +395,7 @@ export const de: Translations = {
disconnect: "Trennen",
managedExternally: "Extern verwaltet",
copied: "Kopiert ✓",
cli: "CLI",
cli: "Kopieren",
copyCliCommand: "CLI-Befehl kopieren (für extern / Fallback)",
connect: "Verbinden",
sessionExpires: "Sitzung läuft in {time} ab",
+5 -2
View File
@@ -127,6 +127,7 @@ export const en: Translations = {
sessions: {
title: "Sessions",
overview: "Overview",
searchPlaceholder: "Search message content...",
noSessions: "No sessions yet",
noMatch: "No sessions match your search",
@@ -269,7 +270,7 @@ export const en: Translations = {
"Discover, install, enable, and update Hermes plugins (`hermes plugins` parity).",
identifierLabel: "Git URL or owner/repo",
inactive: "inactive",
installBtn: "Install from Git",
installBtn: "Install",
installHeading: "Install from GitHub / Git URL",
installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL.",
memoryProviderLabel: "Memory provider",
@@ -367,6 +368,8 @@ export const en: Translations = {
description: "Manage API keys and secrets stored in",
hideAdvanced: "Hide Advanced",
showAdvanced: "Show Advanced",
showLess: "Show less",
showMore: "Show more",
llmProviders: "LLM Providers",
providersConfigured: "{configured} of {total} providers configured",
getKey: "Get key",
@@ -392,7 +395,7 @@ export const en: Translations = {
disconnect: "Disconnect",
managedExternally: "Managed externally",
copied: "Copied ✓",
cli: "CLI",
cli: "Copy",
copyCliCommand: "Copy CLI command (for external / fallback)",
connect: "Connect",
sessionExpires: "Session expires in {time}",
+5 -2
View File
@@ -127,6 +127,7 @@ export const es: Translations = {
sessions: {
title: "Sesiones",
overview: "Resumen",
searchPlaceholder: "Buscar contenido de mensajes...",
noSessions: "Aún no hay sesiones",
noMatch: "Ninguna sesión coincide con tu búsqueda",
@@ -269,7 +270,7 @@ export const es: Translations = {
"Descubre, instala, habilita y actualiza complementos de Hermes (equivalente a `hermes plugins`).",
identifierLabel: "URL de Git u owner/repo",
inactive: "inactivo",
installBtn: "Instalar desde Git",
installBtn: "Instalar",
installHeading: "Instalar desde GitHub / URL de Git",
installHint: "Usa la forma corta owner/repo o una URL de clonación https:// o git@ completa.",
memoryProviderLabel: "Proveedor de memoria",
@@ -367,6 +368,8 @@ export const es: Translations = {
description: "Gestiona claves API y secretos almacenados en",
hideAdvanced: "Ocultar avanzado",
showAdvanced: "Mostrar avanzado",
showLess: "Mostrar menos",
showMore: "Mostrar más",
llmProviders: "Proveedores LLM",
providersConfigured: "{configured} de {total} proveedores configurados",
getKey: "Obtener clave",
@@ -392,7 +395,7 @@ export const es: Translations = {
disconnect: "Desconectar",
managedExternally: "Gestionado externamente",
copied: "Copiado ✓",
cli: "CLI",
cli: "Copiar",
copyCliCommand: "Copiar comando CLI (para externo / alternativa)",
connect: "Conectar",
sessionExpires: "La sesión caduca en {time}",
+5 -2
View File
@@ -127,6 +127,7 @@ export const fr: Translations = {
sessions: {
title: "Sessions",
overview: "Aperçu",
searchPlaceholder: "Rechercher dans les messages...",
noSessions: "Aucune session pour l'instant",
noMatch: "Aucune session ne correspond à votre recherche",
@@ -269,7 +270,7 @@ export const fr: Translations = {
"Découvrez, installez, activez et mettez à jour les plugins Hermes (parité avec `hermes plugins`).",
identifierLabel: "URL Git ou owner/repo",
inactive: "inactif",
installBtn: "Installer depuis Git",
installBtn: "Installer",
installHeading: "Installer depuis GitHub / URL Git",
installHint: "Utilisez le raccourci owner/repo ou une URL de clonage complète https:// ou git@.",
memoryProviderLabel: "Fournisseur de mémoire",
@@ -367,6 +368,8 @@ export const fr: Translations = {
description: "Gérer les clés API et les secrets stockés dans",
hideAdvanced: "Masquer les options avancées",
showAdvanced: "Afficher les options avancées",
showLess: "Afficher moins",
showMore: "Afficher plus",
llmProviders: "Fournisseurs LLM",
providersConfigured: "{configured} sur {total} fournisseurs configurés",
getKey: "Obtenir la clé",
@@ -392,7 +395,7 @@ export const fr: Translations = {
disconnect: "Déconnecter",
managedExternally: "Géré en externe",
copied: "Copié ✓",
cli: "CLI",
cli: "Copier",
copyCliCommand: "Copier la commande CLI (pour externe / repli)",
connect: "Connecter",
sessionExpires: "La session expire dans {time}",
+5 -2
View File
@@ -127,6 +127,7 @@ export const ga: Translations = {
sessions: {
title: "Seisiúin",
overview: "Forbhreathnú",
searchPlaceholder: "Cuardaigh ábhar teachtaireachta...",
noSessions: "Gan seisiúin go fóill",
noMatch: "Níl seisiún ar bith ag teacht le do chuardach",
@@ -269,7 +270,7 @@ export const ga: Translations = {
"Faigh, suiteáil, cumasaigh agus nuashonraigh plugins Hermes (paireacht le `hermes plugins`).",
identifierLabel: "URL Git nó owner/repo",
inactive: "neamhghníomhach",
installBtn: "Suiteáil ó Git",
installBtn: "Suiteáil",
installHeading: "Suiteáil ó GitHub / URL Git",
installHint: "Úsáid an gearrshamhail owner/repo nó URL clóin iomlán https:// nó git@.",
memoryProviderLabel: "Soláthraí cuimhne",
@@ -367,6 +368,8 @@ export const ga: Translations = {
description: "Bainistigh eochracha API agus rúin atá stóráilte i",
hideAdvanced: "Folaigh Ardroghanna",
showAdvanced: "Taispeáin Ardroghanna",
showLess: "Taispeáin níos lú",
showMore: "Taispeáin tuilleadh",
llmProviders: "Soláthraithe LLM",
providersConfigured: "{configured} as {total} soláthraí cumraithe",
getKey: "Faigh eochair",
@@ -392,7 +395,7 @@ export const ga: Translations = {
disconnect: "Dícheangail",
managedExternally: "Bainistithe go seachtrach",
copied: "Cóipeáilte ✓",
cli: "CLI",
cli: "Cóipeáil",
copyCliCommand: "Cóipeáil ordú CLI (le haghaidh úsáide seachtraí / cúltaca)",
connect: "Ceangail",
sessionExpires: "Téann an seisiún as feidhm i {time}",
+5 -2
View File
@@ -127,6 +127,7 @@ export const hu: Translations = {
sessions: {
title: "Munkamenetek",
overview: "Áttekintés",
searchPlaceholder: "Keresés üzenettartalomban...",
noSessions: "Még nincsenek munkamenetek",
noMatch: "Nincs a keresésnek megfelelő munkamenet",
@@ -269,7 +270,7 @@ export const hu: Translations = {
"Hermes-bővítmények felfedezése, telepítése, engedélyezése és frissítése (a `hermes plugins` paritás).",
identifierLabel: "Git URL vagy owner/repo",
inactive: "inaktív",
installBtn: "Telepítés Gitből",
installBtn: "Telepítés",
installHeading: "Telepítés GitHubról / Git URL-ről",
installHint: "Használjon owner/repo rövidítést vagy teljes https:// vagy git@ klónozási URL-t.",
memoryProviderLabel: "Memória-szolgáltató",
@@ -367,6 +368,8 @@ export const hu: Translations = {
description: "API-kulcsok és titkok kezelése a következő helyen:",
hideAdvanced: "Speciális elrejtése",
showAdvanced: "Speciális megjelenítése",
showLess: "Kevesebb",
showMore: "Több",
llmProviders: "LLM-szolgáltatók",
providersConfigured: "{configured} / {total} szolgáltató beállítva",
getKey: "Kulcs lekérése",
@@ -392,7 +395,7 @@ export const hu: Translations = {
disconnect: "Lecsatlakozás",
managedExternally: "Külsőleg kezelt",
copied: "Másolva ✓",
cli: "CLI",
cli: "Másolás",
copyCliCommand: "CLI-parancs másolása (külső / tartalék)",
connect: "Csatlakozás",
sessionExpires: "A munkamenet {time} múlva lejár",
+5 -2
View File
@@ -127,6 +127,7 @@ export const it: Translations = {
sessions: {
title: "Sessioni",
overview: "Panoramica",
searchPlaceholder: "Cerca nel contenuto dei messaggi...",
noSessions: "Nessuna sessione",
noMatch: "Nessuna sessione corrisponde alla ricerca",
@@ -269,7 +270,7 @@ export const it: Translations = {
"Scopri, installa, abilita e aggiorna i plugin Hermes (parità con `hermes plugins`).",
identifierLabel: "URL Git o owner/repo",
inactive: "inattivo",
installBtn: "Installa da Git",
installBtn: "Installa",
installHeading: "Installa da GitHub / URL Git",
installHint: "Usa la forma breve owner/repo o un URL clone https:// o git@ completo.",
memoryProviderLabel: "Provider di memoria",
@@ -367,6 +368,8 @@ export const it: Translations = {
description: "Gestisci chiavi API e segreti memorizzati in",
hideAdvanced: "Nascondi avanzate",
showAdvanced: "Mostra avanzate",
showLess: "Mostra meno",
showMore: "Mostra di più",
llmProviders: "Provider LLM",
providersConfigured: "{configured} di {total} provider configurati",
getKey: "Ottieni chiave",
@@ -392,7 +395,7 @@ export const it: Translations = {
disconnect: "Disconnetti",
managedExternally: "Gestito esternamente",
copied: "Copiato ✓",
cli: "CLI",
cli: "Copia",
copyCliCommand: "Copia comando CLI (per uso esterno / fallback)",
connect: "Connetti",
sessionExpires: "La sessione scade tra {time}",
+5 -2
View File
@@ -127,6 +127,7 @@ export const ja: Translations = {
sessions: {
title: "セッション",
overview: "概要",
searchPlaceholder: "メッセージ内容を検索...",
noSessions: "まだセッションがありません",
noMatch: "検索条件に一致するセッションはありません",
@@ -269,7 +270,7 @@ export const ja: Translations = {
"Hermes プラグインを発見、インストール、有効化、更新します (`hermes plugins` 相当)。",
identifierLabel: "Git URL または owner/repo",
inactive: "非アクティブ",
installBtn: "Git からインストール",
installBtn: "インストール",
installHeading: "GitHub / Git URL からインストール",
installHint: "owner/repo の短縮形、または完全な https:// もしくは git@ クローン URL を使用してください。",
memoryProviderLabel: "メモリプロバイダー",
@@ -367,6 +368,8 @@ export const ja: Translations = {
description: "API キーとシークレットを管理します。保存先:",
hideAdvanced: "詳細設定を隠す",
showAdvanced: "詳細設定を表示",
showLess: "表示を減らす",
showMore: "もっと見る",
llmProviders: "LLM プロバイダー",
providersConfigured: "{configured} / {total} プロバイダーが設定済み",
getKey: "キーを取得",
@@ -392,7 +395,7 @@ export const ja: Translations = {
disconnect: "切断",
managedExternally: "外部で管理",
copied: "コピーしました ✓",
cli: "CLI",
cli: "コピー",
copyCliCommand: "CLI コマンドをコピー (外部 / フォールバック用)",
connect: "接続",
sessionExpires: "セッションは {time} 後に期限切れになります",
+5 -2
View File
@@ -127,6 +127,7 @@ export const ko: Translations = {
sessions: {
title: "세션",
overview: "개요",
searchPlaceholder: "메시지 내용 검색...",
noSessions: "아직 세션이 없습니다",
noMatch: "검색과 일치하는 세션이 없습니다",
@@ -269,7 +270,7 @@ export const ko: Translations = {
"Hermes 플러그인을 검색, 설치, 활성화 및 업데이트합니다 (`hermes plugins` 동등).",
identifierLabel: "Git URL 또는 owner/repo",
inactive: "비활성",
installBtn: "Git에서 설치",
installBtn: "설치",
installHeading: "GitHub / Git URL에서 설치",
installHint: "owner/repo 약어 또는 전체 https:// 또는 git@ 클론 URL을 사용하세요.",
memoryProviderLabel: "메모리 제공자",
@@ -367,6 +368,8 @@ export const ko: Translations = {
description: "다음 위치에 저장된 API 키와 비밀을 관리합니다",
hideAdvanced: "고급 숨기기",
showAdvanced: "고급 표시",
showLess: "간략히",
showMore: "더 보기",
llmProviders: "LLM 제공자",
providersConfigured: "{configured}/{total} 제공자가 구성됨",
getKey: "키 받기",
@@ -392,7 +395,7 @@ export const ko: Translations = {
disconnect: "연결 해제",
managedExternally: "외부에서 관리됨",
copied: "복사됨 ✓",
cli: "CLI",
cli: "복사",
copyCliCommand: "CLI 명령 복사 (외부 / 대체용)",
connect: "연결",
sessionExpires: "세션이 {time} 후 만료됩니다",
+5 -2
View File
@@ -127,6 +127,7 @@ export const pt: Translations = {
sessions: {
title: "Sessões",
overview: "Visão geral",
searchPlaceholder: "Pesquisar conteúdo das mensagens...",
noSessions: "Ainda não há sessões",
noMatch: "Nenhuma sessão corresponde à pesquisa",
@@ -269,7 +270,7 @@ export const pt: Translations = {
"Descobrir, instalar, ativar e atualizar plugins Hermes (paridade com `hermes plugins`).",
identifierLabel: "URL Git ou owner/repo",
inactive: "inativo",
installBtn: "Instalar a partir do Git",
installBtn: "Instalar",
installHeading: "Instalar a partir de GitHub / URL Git",
installHint: "Use a forma curta owner/repo ou um URL completo de clone https:// ou git@.",
memoryProviderLabel: "Fornecedor de memória",
@@ -367,6 +368,8 @@ export const pt: Translations = {
description: "Gerir chaves de API e segredos armazenados em",
hideAdvanced: "Ocultar avançadas",
showAdvanced: "Mostrar avançadas",
showLess: "Mostrar menos",
showMore: "Mostrar mais",
llmProviders: "Fornecedores LLM",
providersConfigured: "{configured} de {total} fornecedores configurados",
getKey: "Obter chave",
@@ -392,7 +395,7 @@ export const pt: Translations = {
disconnect: "Desligar",
managedExternally: "Gerido externamente",
copied: "Copiado ✓",
cli: "CLI",
cli: "Copiar",
copyCliCommand: "Copiar comando CLI (para externo / fallback)",
connect: "Ligar",
sessionExpires: "A sessão expira em {time}",
+5 -2
View File
@@ -127,6 +127,7 @@ export const ru: Translations = {
sessions: {
title: "Сессии",
overview: "Обзор",
searchPlaceholder: "Поиск по содержимому сообщений...",
noSessions: "Сессий пока нет",
noMatch: "Нет сессий, соответствующих запросу",
@@ -269,7 +270,7 @@ export const ru: Translations = {
"Поиск, установка, включение и обновление плагинов Hermes (аналог `hermes plugins`).",
identifierLabel: "Git URL или owner/repo",
inactive: "неактивно",
installBtn: "Установить из Git",
installBtn: "Установить",
installHeading: "Установка из GitHub / Git URL",
installHint: "Используйте сокращение owner/repo или полный https:// или git@ URL для клонирования.",
memoryProviderLabel: "Провайдер памяти",
@@ -367,6 +368,8 @@ export const ru: Translations = {
description: "Управление API-ключами и секретами, хранящимися в",
hideAdvanced: "Скрыть расширенные",
showAdvanced: "Показать расширенные",
showLess: "Показать меньше",
showMore: "Показать больше",
llmProviders: "Провайдеры LLM",
providersConfigured: "Настроено {configured} из {total} провайдеров",
getKey: "Получить ключ",
@@ -392,7 +395,7 @@ export const ru: Translations = {
disconnect: "Отключить",
managedExternally: "Управляется извне",
copied: "Скопировано ✓",
cli: "CLI",
cli: "Копировать",
copyCliCommand: "Скопировать CLI-команду (для внешнего / резервного варианта)",
connect: "Подключить",
sessionExpires: "Сессия истечёт через {time}",
+5 -2
View File
@@ -127,6 +127,7 @@ export const tr: Translations = {
sessions: {
title: "Oturumlar",
overview: "Genel bakış",
searchPlaceholder: "Mesaj içeriğinde ara...",
noSessions: "Henüz oturum yok",
noMatch: "Aramanızla eşleşen oturum yok",
@@ -269,7 +270,7 @@ export const tr: Translations = {
"Hermes eklentilerini keşfedin, yükleyin, etkinleştirin ve güncelleyin (`hermes plugins` ile eşdeğer).",
identifierLabel: "Git URL veya owner/repo",
inactive: "pasif",
installBtn: "Git'ten yükle",
installBtn: "Yükle",
installHeading: "GitHub / Git URL'sinden yükle",
installHint: "owner/repo kısayolunu veya tam https:// ya da git@ klon URL'sini kullanın.",
memoryProviderLabel: "Bellek sağlayıcısı",
@@ -367,6 +368,8 @@ export const tr: Translations = {
description: "Şurada saklanan API anahtarlarını ve sırları yönetin",
hideAdvanced: "Gelişmişi Gizle",
showAdvanced: "Gelişmişi Göster",
showLess: "Daha az göster",
showMore: "Daha fazla göster",
llmProviders: "LLM Sağlayıcıları",
providersConfigured: "{configured}/{total} sağlayıcı yapılandırıldı",
getKey: "Anahtar al",
@@ -392,7 +395,7 @@ export const tr: Translations = {
disconnect: "Bağlantıyı kes",
managedExternally: "Harici olarak yönetiliyor",
copied: "Kopyalandı ✓",
cli: "CLI",
cli: "Kopyala",
copyCliCommand: "CLI komutunu kopyala (harici / yedek için)",
connect: "Bağlan",
sessionExpires: "Oturumun süresi {time} sonra dolacak",
+3
View File
@@ -145,6 +145,7 @@ export interface Translations {
// ── Sessions page ──
sessions: {
title: string;
overview: string;
searchPlaceholder: string;
noSessions: string;
noMatch: string;
@@ -396,6 +397,8 @@ export interface Translations {
providersConfigured: string;
replaceCurrentValue: string;
showAdvanced: string;
showLess: string;
showMore: string;
showValue: string;
};
+5 -2
View File
@@ -127,6 +127,7 @@ export const uk: Translations = {
sessions: {
title: "Сесії",
overview: "Огляд",
searchPlaceholder: "Пошук у вмісті повідомлень...",
noSessions: "Поки немає сесій",
noMatch: "Жодна сесія не відповідає вашому пошуку",
@@ -269,7 +270,7 @@ export const uk: Translations = {
"Знаходьте, встановлюйте, вмикайте та оновлюйте плагіни Hermes (паритет з `hermes plugins`).",
identifierLabel: "Git URL або owner/repo",
inactive: "неактивний",
installBtn: "Встановити з Git",
installBtn: "Встановити",
installHeading: "Встановити з GitHub / Git URL",
installHint: "Використовуйте скорочення owner/repo або повну https:// чи git@ URL для клонування.",
memoryProviderLabel: "Постачальник пам'яті",
@@ -367,6 +368,8 @@ export const uk: Translations = {
description: "Керуйте API-ключами та секретами, що зберігаються в",
hideAdvanced: "Сховати розширене",
showAdvanced: "Показати розширене",
showLess: "Показати менше",
showMore: "Показати більше",
llmProviders: "Постачальники LLM",
providersConfigured: "Налаштовано {configured} з {total} постачальників",
getKey: "Отримати ключ",
@@ -392,7 +395,7 @@ export const uk: Translations = {
disconnect: "Відключити",
managedExternally: "Керується ззовні",
copied: "Скопійовано ✓",
cli: "CLI",
cli: "Копіювати",
copyCliCommand: "Скопіювати CLI-команду (для зовнішнього / резервного варіанту)",
connect: "Підключити",
sessionExpires: "Сесія завершиться через {time}",
+5 -2
View File
@@ -127,6 +127,7 @@ export const zhHant: Translations = {
sessions: {
title: "工作階段",
overview: "總覽",
searchPlaceholder: "搜尋訊息內容...",
noSessions: "尚無工作階段",
noMatch: "沒有符合的工作階段",
@@ -269,7 +270,7 @@ export const zhHant: Translations = {
"探索、安裝、啟用並更新 Hermes 外掛(對齊 `hermes plugins` CLI)。",
identifierLabel: "Git 網址或 owner/repo",
inactive: "未啟用",
installBtn: "從 Git 安裝",
installBtn: "安裝",
installHeading: "從 GitHub / Git URL 安裝",
installHint: "可使用 owner/repo 簡寫或完整的 https:// 或 git@ 複製網址。",
memoryProviderLabel: "記憶提供者",
@@ -367,6 +368,8 @@ export const zhHant: Translations = {
description: "管理儲存於下列位置的 API 金鑰與密鑰",
hideAdvanced: "隱藏進階選項",
showAdvanced: "顯示進階選項",
showLess: "顯示較少",
showMore: "顯示更多",
llmProviders: "LLM 提供者",
providersConfigured: "已設定 {configured}/{total} 個提供者",
getKey: "取得金鑰",
@@ -392,7 +395,7 @@ export const zhHant: Translations = {
disconnect: "中斷連線",
managedExternally: "由外部管理",
copied: "已複製 ✓",
cli: "CLI",
cli: "複製",
copyCliCommand: "複製 CLI 指令(外部 / 備援用)",
connect: "連線",
sessionExpires: "工作階段將於 {time} 後過期",
+5 -2
View File
@@ -126,6 +126,7 @@ export const zh: Translations = {
sessions: {
title: "会话",
overview: "概览",
searchPlaceholder: "搜索消息内容...",
noSessions: "暂无会话",
noMatch: "没有匹配的会话",
@@ -265,7 +266,7 @@ export const zh: Translations = {
headline: "发现、安装、启用和更新 Hermes 插件(对齐 `hermes plugins` CLI)。",
identifierLabel: "Git 地址或 owner/repo",
inactive: "未启用",
installBtn: "从 Git 安装",
installBtn: "安装",
installHeading: "从 GitHub / Git 地址安装",
installHint: "使用 owner/repo 简写或完整的 https:// / git@ 克隆地址。",
memoryProviderLabel: "记忆提供方",
@@ -362,6 +363,8 @@ export const zh: Translations = {
description: "管理存储在以下位置的 API 密钥和凭据",
hideAdvanced: "隐藏高级选项",
showAdvanced: "显示高级选项",
showLess: "显示更少",
showMore: "显示更多",
llmProviders: "LLM 提供商",
providersConfigured: "已配置 {configured}/{total} 个提供商",
getKey: "获取密钥",
@@ -387,7 +390,7 @@ export const zh: Translations = {
disconnect: "断开连接",
managedExternally: "外部管理",
copied: "已复制 ✓",
cli: "CLI",
cli: "复制",
copyCliCommand: "复制 CLI 命令(用于外部/备用方式)",
connect: "连接",
sessionExpires: "会话将在 {time} 后过期",
+5 -1
View File
@@ -146,7 +146,11 @@ code { font-size: 0.875rem; }
--color-secondary: color-mix(in srgb, var(--midground-base) 6%, var(--background-base));
--color-secondary-foreground: var(--midground);
--color-muted: color-mix(in srgb, var(--midground-base) 8%, var(--background-base));
--color-muted-foreground: color-mix(in srgb, var(--midground-base) 55%, transparent);
/* Routes the shadcn `muted-foreground` slot through the DS semantic
text-secondary token (defaults to midground 80%) so legacy call
sites that use `text-muted-foreground` get a readable color
instead of the old 55%-transparent default. */
--color-muted-foreground: var(--color-text-secondary);
--color-accent: color-mix(in srgb, var(--midground-base) 10%, var(--background-base));
--color-accent-foreground: var(--midground);
--color-destructive: #fb2c36;
+10 -5
View File
@@ -51,6 +51,11 @@ export async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T>
return res.json();
}
/** Encode a plugin registry key for URL paths (preserves `/` segment separators). */
function pluginPath(name: string): string {
return name.split("/").map(encodeURIComponent).join("/");
}
async function getSessionToken(): Promise<string> {
if (_sessionToken) return _sessionToken;
const injected = window.__HERMES_SESSION_TOKEN__;
@@ -293,25 +298,25 @@ export const api = {
enableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/enable`,
`/api/dashboard/agent-plugins/${pluginPath(name)}/enable`,
{ method: "POST" },
),
disableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/disable`,
`/api/dashboard/agent-plugins/${pluginPath(name)}/disable`,
{ method: "POST" },
),
updateAgentPlugin: (name: string) =>
fetchJSON<AgentPluginUpdateResponse>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/update`,
`/api/dashboard/agent-plugins/${pluginPath(name)}/update`,
{ method: "POST" },
),
removeAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}`,
`/api/dashboard/agent-plugins/${pluginPath(name)}`,
{ method: "DELETE" },
),
@@ -324,7 +329,7 @@ export const api = {
setPluginVisibility: (name: string, hidden: boolean) =>
fetchJSON<{ ok: boolean; name: string; hidden: boolean }>(
`/api/dashboard/plugins/${encodeURIComponent(name)}/visibility`,
`/api/dashboard/plugins/${pluginPath(name)}/visibility`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
+9
View File
@@ -5,6 +5,15 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Mondwest font only — use on layout shells; do not force normal-case here or `text-display` chrome (Segmented, badges) stops uppercasing. */
export const themedFont = "font-mondwest";
/** Mondwest body copy — sentence-case themed text (not uppercase chrome). */
export const themedBody = "font-mondwest normal-case";
/** Mondwest brand chrome — uppercase section headers and nav labels. */
export const themedChrome = "font-mondwest text-display";
/** Relative time from a Unix epoch timestamp (seconds). */
export function timeAgo(ts: number): string {
const delta = Date.now() / 1000 - ts;
-1
View File
@@ -1,6 +1,5 @@
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "flag-icons/css/flag-icons.min.css";
import "./index.css";
import App from "./App";
import { SystemActionsProvider } from "./contexts/SystemActions";
+24 -22
View File
@@ -119,7 +119,7 @@ function SortHeader({
<ArrowDown className="h-3.5 w-3.5 text-foreground/80 shrink-0" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-muted-foreground/40 shrink-0" />
<ArrowUpDown className="h-3 w-3 text-text-tertiary shrink-0" />
)}
</span>
</th>
@@ -146,7 +146,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
{t.analytics.dailyTokenUsage}
</CardTitle>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-4 font-mondwest normal-case text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
{t.analytics.input}
@@ -177,7 +177,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
style={{ height: CHART_HEIGHT_PX }}
>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
<div className="bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
<div className="font-mondwest normal-case bg-card border border-border px-2.5 py-1.5 text-xs text-foreground shadow-lg whitespace-nowrap">
<div className="font-medium">{formatDate(d.day)}</div>
<div>
{t.analytics.input}: {formatTokens(d.input_tokens)}
@@ -207,7 +207,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
})}
</div>
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground">
<div className="flex justify-between mt-2 font-mondwest normal-case text-xs text-text-tertiary">
<span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
{daily.length > 2 && (
<span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
@@ -239,7 +239,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full font-mondwest normal-case text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.date} col="day" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
@@ -298,7 +298,7 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full font-mondwest normal-case text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.model} col="model" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
@@ -353,7 +353,7 @@ function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) {
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full font-mondwest normal-case text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.skill} col="skill" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
@@ -430,11 +430,23 @@ export default function AnalyticsPage() {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
setAfterTitle(
<span className="flex items-center gap-2">
{loading && <Spinner className="shrink-0 text-base text-primary" />}
<Badge tone="secondary" className="text-[10px]">
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{periodLabel}
</Badge>
{showTokens !== false && (
<Button
type="button"
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={load}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
)}
</span>,
);
setEnd(
@@ -453,16 +465,6 @@ export default function AnalyticsPage() {
</Button>
))}
</div>
<Button
type="button"
size="sm"
outlined
onClick={load}
disabled={loading}
prefix={loading ? <Spinner /> : <RefreshCw />}
>
{t.common.refresh}
</Button>
</div>
),
);
@@ -484,7 +486,7 @@ export default function AnalyticsPage() {
<Card>
<CardContent className="py-12">
<div className="mx-auto flex max-w-2xl flex-col gap-3 text-sm text-muted-foreground">
<h2 className="font-display text-base tracking-wider uppercase text-foreground">
<h2 className="font-mondwest text-display text-base tracking-wider text-foreground">
Token analytics hidden
</h2>
<p>
@@ -586,7 +588,7 @@ export default function AnalyticsPage() {
<div className="flex flex-col items-center text-muted-foreground">
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">{t.analytics.noUsageData}</p>
<p className="text-xs mt-1 text-muted-foreground/60">
<p className="text-xs mt-1 text-text-tertiary">
{t.analytics.startSession}
</p>
</div>
+10 -11
View File
@@ -233,8 +233,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
aria-controls="chat-side-panel"
className={cn(
"shrink-0 rounded border border-current/20",
"px-2 py-1 text-[0.65rem] font-medium tracking-wide normal-case",
"text-midground/80 hover:text-midground hover:bg-midground/5",
"px-2 py-1 text-xs font-medium tracking-wide",
"text-text-secondary hover:text-midground hover:bg-midground/5",
)}
>
<span className="inline-flex items-center gap-1.5">
@@ -708,9 +708,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// model badge, tool-call list, model picker. Best-effort: if the
// sidecar fails to connect the terminal pane keeps working.
//
// `normal-case` opts out of the dashboard's global `uppercase` rule on
// the root `<div>` in App.tsx — terminal output must preserve case.
//
// Mobile model/tools sheet is portaled to `document.body` so it stacks
// above the app sidebar (`z-50`) and mobile chrome (`z-40`). The main
// dashboard column uses `relative z-2`, which traps `position:fixed`
@@ -756,7 +753,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
)}
>
<Typography
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
mondwest
className="text-display font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.modelToolsSheetTitle}
@@ -769,7 +767,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
size="icon"
onClick={closeMobilePanel}
aria-label={t.app.closeModelTools}
className="text-midground/70 hover:text-midground"
className="text-text-secondary hover:text-midground"
>
<X />
</Button>
@@ -789,7 +787,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2 normal-case">
<div className="flex min-h-0 flex-1 flex-col gap-2">
<PluginSlot name="chat:top" />
{mobileModelToolsPortal}
@@ -822,11 +820,12 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
aria-label="Copy last assistant response"
className={cn(
"absolute z-10",
"normal-case tracking-normal font-normal",
"rounded border border-current/30",
"bg-black/20 backdrop-blur-sm",
"opacity-60 hover:opacity-100 hover:border-current/60",
"transition-opacity duration-150 normal-case font-normal tracking-normal",
"bottom-2 right-2 px-2 py-1 text-[0.65rem] sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5 sm:text-xs",
"opacity-70 hover:opacity-100 hover:border-current/60",
"transition-opacity duration-150",
"bottom-2 right-2 px-2 py-1 text-xs sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5",
"lg:bottom-4 lg:right-4",
)}
style={{ color: TERMINAL_THEME.foreground }}
+13 -14
View File
@@ -4,7 +4,6 @@ import {
Download,
FormInput,
RotateCcw,
Save,
Search,
Upload,
X,
@@ -385,7 +384,7 @@ export default function ConfigPage() {
category={cat}
className="h-4 w-4 text-muted-foreground"
/>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
{prettyCategoryName(cat)}
</span>
<div className="flex-1 border-t border-border" />
@@ -393,7 +392,7 @@ export default function ConfigPage() {
)}
{showSection && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
{section.replace(/_/g, " ")}
</span>
<div className="flex-1 border-t border-border" />
@@ -486,18 +485,18 @@ export default function ConfigPage() {
{yamlMode ? (
<Button
size="sm"
className="uppercase"
onClick={handleYamlSave}
disabled={yamlSaving}
prefix={<Save />}
>
{yamlSaving ? t.common.saving : t.common.save}
</Button>
) : (
<Button
size="sm"
className="uppercase"
onClick={handleSave}
disabled={saving}
prefix={<Save />}
>
{saving ? t.common.saving : t.common.save}
</Button>
@@ -534,13 +533,13 @@ export default function ConfigPage() {
<div className="sm:sticky sm:top-4">
<div className="flex flex-col border border-border bg-muted/20">
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
<Filter className="h-3 w-3 text-muted-foreground" />
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
<Filter className="h-3 w-3 text-text-tertiary" />
<span className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.config.filters}
</span>
</div>
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary">
{t.config.sections}
</div>
@@ -556,7 +555,7 @@ export default function ConfigPage() {
setSearchQuery("");
setActiveCategory(cat);
}}
className="rounded-sm whitespace-nowrap px-2 py-1 text-[11px]"
className="rounded-none whitespace-nowrap px-2 py-1 text-xs"
>
<CategoryIcon
category={cat}
@@ -566,10 +565,10 @@ export default function ConfigPage() {
{prettyCategoryName(cat)}
</span>
<span
className={`text-[10px] tabular-nums ${
className={`text-xs tabular-nums ${
isActive
? "text-foreground/60"
: "text-muted-foreground/50"
? "text-text-secondary"
: "text-text-tertiary"
}`}
>
{categoryCounts[cat] || 0}
@@ -591,7 +590,7 @@ export default function ConfigPage() {
<Search className="h-4 w-4" />
{t.config.searchResults}
</CardTitle>
<Badge tone="secondary" className="text-[10px]">
<Badge tone="secondary" className="text-xs">
{searchMatchedFields.length}{" "}
{t.config.fields.replace(
"{s}",
@@ -622,7 +621,7 @@ export default function ConfigPage() {
/>
{prettyCategoryName(activeCategory)}
</CardTitle>
<Badge tone="secondary" className="text-[10px]">
<Badge tone="secondary" className="text-xs">
{activeFields.length}{" "}
{t.config.fields.replace(
"{s}",
+7 -5
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Clock, Pause, Play, Plus, Trash2, X, Zap } from "lucide-react";
import { Clock, Pause, Play, Trash2, X, Zap } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
@@ -18,6 +18,7 @@ import { Label } from "@/components/ui/label";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
import { cn, themedBody } from "@/lib/utils";
function formatTime(iso?: string | null): string {
if (!iso) return "—";
@@ -228,10 +229,10 @@ export default function CronPage() {
useLayoutEffect(() => {
setEnd(
<Button
className="uppercase"
size="sm"
onClick={() => setCreateModalOpen(true)}
>
<Plus className="h-3 w-3" />
{t.common.create}
</Button>,
);
@@ -282,7 +283,7 @@ export default function CronPage() {
aria-modal="true"
aria-labelledby="create-cron-title"
>
<div className="relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col">
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
@@ -296,7 +297,7 @@ export default function CronPage() {
<header className="p-5 pb-3 border-b border-border">
<h2
id="create-cron-title"
className="font-display text-base tracking-wider uppercase"
className="font-mondwest text-display text-base tracking-wider"
>
{t.cron.newJob}
</h2>
@@ -379,10 +380,11 @@ export default function CronPage() {
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleCreate}
disabled={creating}
prefix={creating ? <Spinner /> : <Plus />}
prefix={creating ? <Spinner /> : undefined}
>
{creating ? t.common.creating : t.common.create}
</Button>
+99 -112
View File
@@ -133,12 +133,12 @@ function EnvVarRow({
// Compact inline row for unset, non-editing keys (used inside provider groups)
if (compact && !info.is_set && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 min-w-0 overflow-hidden opacity-50 hover:opacity-100 transition-opacity">
<div className="flex items-center justify-between gap-3 py-1.5 min-w-0 overflow-hidden text-text-secondary hover:text-foreground transition-colors">
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">
<span className="font-mono-ui text-xs">
{varKey}
</span>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">
<span className="text-xs text-text-tertiary truncate hidden sm:block">
{info.description}
</span>
</div>
@@ -148,7 +148,7 @@ function EnvVarRow({
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
@@ -169,12 +169,12 @@ function EnvVarRow({
// Non-compact unset row
if (!info.is_set && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 min-w-0 overflow-hidden opacity-60 hover:opacity-100 transition-opacity">
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 min-w-0 overflow-hidden text-text-secondary hover:text-foreground transition-colors">
<div className="flex items-center gap-3 min-w-0">
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">
<Label className="font-mono-ui text-xs">
{varKey}
</Label>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">
<span className="text-xs text-text-tertiary truncate hidden sm:block">
{info.description}
</span>
</div>
@@ -184,7 +184,7 @@ function EnvVarRow({
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
@@ -207,7 +207,7 @@ function EnvVarRow({
<div className="grid gap-2 border border-border p-4 min-w-0 overflow-hidden">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label>
<Label className="font-mono-ui text-xs">{varKey}</Label>
<Badge tone={info.is_set ? "success" : "outline"}>
{info.is_set ? t.common.set : t.env.notSet}
</Badge>
@@ -217,7 +217,7 @@ function EnvVarRow({
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
@@ -232,7 +232,7 @@ function EnvVarRow({
<Badge
key={tool}
tone="secondary"
className="text-[0.6rem] py-0 px-1.5"
className="text-xs py-0 px-1.5"
>
{tool}
</Badge>
@@ -396,7 +396,7 @@ function ProviderGroupCard({
{group.name === "Other" ? t.common.other : group.name}
</span>
{hasAnyConfigured && (
<Badge tone="success" className="text-[0.6rem]">
<Badge tone="success" className="text-xs">
{configuredCount} {t.common.set.toLowerCase()}
</Badge>
)}
@@ -407,13 +407,13 @@ function ProviderGroupCard({
href={keyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<span className="text-[0.65rem] text-muted-foreground/60">
<span className="text-xs text-text-tertiary">
{t.env.keysCount
.replace("{count}", String(group.entries.length))
.replace("{s}", group.entries.length !== 1 ? "s" : "")}
@@ -546,7 +546,7 @@ export default function EnvPage() {
key={s.id}
type="button"
onClick={() => scrollTo(s.id)}
className="shrink-0 cursor-pointer px-2 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
className="shrink-0 cursor-pointer px-2 py-0.5 font-mondwest text-display text-xs tracking-wider text-text-secondary hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
>
{s.label}
</button>
@@ -745,7 +745,7 @@ export default function EnvPage() {
<p className="text-sm text-muted-foreground">
{t.env.description} <code>~/.hermes/.env</code>
</p>
<p className="text-[0.7rem] text-muted-foreground/70">
<p className="text-xs text-text-tertiary">
{t.env.changesNote}
</p>
</div>
@@ -797,80 +797,36 @@ export default function EnvPage() {
</CardContent>
</Card>
{nonProviderGrouped.map(
({
label,
icon: Icon,
setEntries,
unsetEntries,
totalEntries,
category,
}) => {
if (totalEntries === 0) return null;
{nonProviderGrouped.map((section) => {
if (section.totalEntries === 0) return null;
return (
<Card key={category} id={`section-${category}`}>
<CardHeader className="border-b border-border bg-card">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{label}</CardTitle>
</div>
<CardDescription>
{setEntries.length} {t.common.of} {totalEntries}{" "}
{t.common.configured}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 pt-4 overflow-hidden">
{setEntries.map(([key, info]) => (
<EnvVarRow
key={key}
varKey={key}
info={info}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={handleSave}
onClear={keyClear.requestDelete}
onReveal={handleReveal}
onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
))}
{unsetEntries.length > 0 && (
<CollapsibleUnset
category={category}
unsetEntries={unsetEntries}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={handleSave}
onClear={keyClear.requestDelete}
onReveal={handleReveal}
onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
)}
</CardContent>
</Card>
);
},
)}
return (
<EnvCategoryCard
key={section.category}
section={section}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={handleSave}
onClear={keyClear.requestDelete}
onReveal={handleReveal}
onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
);
})}
<PluginSlot name="env:bottom" />
</div>
);
}
/* ------------------------------------------------------------------ */
/* CollapsibleUnset — for non-provider categories */
/* EnvCategoryCard — keys / messaging / settings sections */
/* ------------------------------------------------------------------ */
function CollapsibleUnset({
category: _category,
unsetEntries,
function EnvCategoryCard({
section,
edits,
setEdits,
revealed,
@@ -881,8 +837,14 @@ function CollapsibleUnset({
onCancelEdit,
clearDialogOpen = false,
}: {
category: string;
unsetEntries: [string, EnvVarInfo][];
section: {
category: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
setEntries: [string, EnvVarInfo][];
totalEntries: number;
unsetEntries: [string, EnvVarInfo][];
};
edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>;
@@ -893,39 +855,64 @@ function CollapsibleUnset({
onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean;
}) {
const [collapsed, setCollapsed] = useState(true);
const noneConfigured = section.setEntries.length === 0;
const [showAll, setShowAll] = useState(noneConfigured);
const { t } = useI18n();
const Icon = section.icon;
const hasContent = section.setEntries.length > 0 || showAll;
const rowProps = {
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
clearDialogOpen,
};
return (
<>
<Button
ghost
size="sm"
prefix={collapsed ? <ChevronRight /> : <ChevronDown />}
onClick={() => setCollapsed(!collapsed)}
aria-expanded={!collapsed}
className="self-start mt-1 normal-case tracking-normal text-xs text-muted-foreground hover:text-foreground"
<Card id={`section-${section.category}`}>
<CardHeader
className={`bg-card${hasContent ? " border-b border-border" : ""}`}
>
{t.env.notConfigured.replace("{count}", String(unsetEntries.length))}
</Button>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Icon className="h-5 w-5 shrink-0 text-muted-foreground" />
<CardTitle className="text-base">{section.label}</CardTitle>
</div>
{!collapsed &&
unsetEntries.map(([key, info]) => (
<EnvVarRow
key={key}
varKey={key}
info={info}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={onSave}
onClear={onClear}
onReveal={onReveal}
onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
</>
{section.unsetEntries.length > 0 && (
<button
type="button"
onClick={() => setShowAll((open) => !open)}
aria-expanded={showAll}
className="shrink-0 cursor-pointer border-0 bg-transparent p-0 font-mondwest text-xs tracking-[0.08em] text-text-secondary transition-colors hover:text-foreground"
>
{showAll ? t.env.showLess : t.env.showMore}
</button>
)}
</div>
<CardDescription>
{section.setEntries.length} {t.common.of} {section.totalEntries}{" "}
{t.common.configured}
</CardDescription>
</CardHeader>
{hasContent && (
<CardContent className="grid gap-3 overflow-hidden pt-4">
{section.setEntries.map(([key, info]) => (
<EnvVarRow key={key} varKey={key} info={info} {...rowProps} />
))}
{showAll &&
section.unsetEntries.map(([key, info]) => (
<EnvVarRow key={key} varKey={key} info={info} {...rowProps} />
))}
</CardContent>
)}
</Card>
);
}
+27 -24
View File
@@ -40,11 +40,13 @@ const LINE_COLORS: Record<string, string> = {
error: "text-destructive",
warning: "text-warning",
info: "text-foreground",
debug: "text-muted-foreground/60",
debug: "text-text-tertiary",
};
const toOptions = <T extends string>(values: readonly T[]) =>
values.map((v) => ({ value: v, label: v }));
const formatFilterLabel = (value: string) => value.toUpperCase();
const toSegmentOptions = <T extends string>(values: readonly T[]) =>
values.map((v) => ({ value: v, label: formatFilterLabel(v) }));
const filterGroupClass =
"flex min-w-0 w-full flex-col items-start gap-1.5 sm:w-auto sm:max-w-full sm:flex-row sm:items-center";
@@ -85,41 +87,42 @@ export default function LogsPage() {
useLayoutEffect(() => {
setAfterTitle(
<span className="flex items-center gap-2">
{loading && <Spinner className="shrink-0 text-base text-primary" />}
<Badge tone="secondary" className="text-[10px]">
{file} · {level} · {component}
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{formatFilterLabel(file)} · {formatFilterLabel(level)} ·{" "}
{formatFilterLabel(component)}
</Badge>
<Button
type="button"
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={fetchLogs}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-3">
<div className="flex items-center gap-2">
<Label htmlFor="logs-auto-refresh" className="text-xs cursor-pointer">
{t.logs.autoRefresh}
</Label>
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
id="logs-auto-refresh"
/>
<Label htmlFor="logs-auto-refresh" className="text-xs cursor-pointer">
{t.logs.autoRefresh}
</Label>
{autoRefresh && (
<Badge tone="success" className="text-[10px]">
<Badge tone="success" className="text-xs">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
)}
</div>
<Button
type="button"
size="sm"
outlined
onClick={fetchLogs}
disabled={loading}
prefix={loading ? <Spinner /> : <RefreshCw />}
>
{t.common.refresh}
</Button>
</div>,
);
return () => {
@@ -163,7 +166,7 @@ export default function LogsPage() {
className={segmentedClass}
value={file}
onChange={setFile}
options={toOptions(FILES)}
options={toSegmentOptions(FILES)}
/>
</FilterGroup>
@@ -172,7 +175,7 @@ export default function LogsPage() {
className={segmentedClass}
value={level}
onChange={setLevel}
options={toOptions(LEVELS)}
options={toSegmentOptions(LEVELS)}
/>
</FilterGroup>
@@ -181,7 +184,7 @@ export default function LogsPage() {
className={segmentedClass}
value={component}
onChange={setComponent}
options={toOptions(COMPONENTS)}
options={toSegmentOptions(COMPONENTS)}
/>
</FilterGroup>
+60 -57
View File
@@ -19,7 +19,7 @@ import type {
ModelsAnalyticsModelEntry,
ModelsAnalyticsResponse,
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { timeAgo, cn, themedBody } from "@/lib/utils";
import { formatTokenCount } from "@/lib/format";
import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
@@ -44,11 +44,13 @@ const AUX_TASKS: readonly { key: string; label: string; hint: string }[] = [
{ key: "vision", label: "Vision", hint: "Image analysis" },
{ key: "web_extract", label: "Web Extract", hint: "Page summarization" },
{ key: "compression", label: "Compression", hint: "Context compaction" },
{ key: "session_search", label: "Session Search", hint: "Recall queries" },
{ key: "skills_hub", label: "Skills Hub", hint: "Skill search" },
{ key: "approval", label: "Approval", hint: "Smart auto-approve" },
{ key: "mcp", label: "MCP", hint: "MCP tool routing" },
{ key: "title_generation", label: "Title Gen", hint: "Session titles" },
{ key: "triage_specifier", label: "Triage Specifier", hint: "Kanban spec fleshing" },
{ key: "kanban_decomposer", label: "Kanban Decomposer", hint: "Task decomposition" },
{ key: "profile_describer", label: "Profile Describer", hint: "Auto profile descriptions" },
{ key: "curator", label: "Curator", hint: "Skill-usage review" },
] as const;
@@ -123,7 +125,7 @@ function TokenBar({
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-text-secondary">
{segments.map((s, i) => (
<span key={i} className="flex items-center gap-1">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.dotColor}`} />
@@ -150,22 +152,22 @@ function CapabilityBadges({
return (
<div className="flex flex-wrap items-center gap-1.5">
{capabilities.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
{capabilities.supports_vision && (
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-1.5 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
<Eye className="h-2.5 w-2.5" /> Vision
</span>
)}
{capabilities.supports_reasoning && (
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-1.5 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400">
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-1.5 py-0.5 text-xs font-medium text-purple-600 dark:text-purple-400">
<Brain className="h-2.5 w-2.5" /> Reasoning
</span>
)}
{capabilities.model_family && (
<span className="inline-flex items-center bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
<span className="inline-flex items-center bg-muted px-1.5 py-0.5 text-xs font-medium text-text-secondary">
{capabilities.model_family}
</span>
)}
@@ -235,7 +237,7 @@ function UseAsMenu({
outlined
onClick={() => setOpen((v) => !v)}
disabled={busy}
className="text-[10px] h-6 px-2"
className="h-6 px-2 text-xs uppercase"
prefix={busy ? <Spinner /> : null}
>
Use as <ChevronDown className="h-3 w-3" />
@@ -246,20 +248,20 @@ function UseAsMenu({
type="button"
onClick={() => assign("main", "")}
disabled={busy}
className="flex w-full items-center justify-between px-3 py-2 text-xs hover:bg-muted/50 disabled:opacity-40"
className="flex w-full items-center justify-between px-3 py-2 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
>
<span className="flex items-center gap-2">
<Star className="h-3 w-3" />
Main model
</span>
{isMain && (
<span className="text-[9px] uppercase tracking-wider text-primary/80">
<span className="text-display text-xs tracking-wider text-primary">
current
</span>
)}
</button>
<div className="border-t border-border/50 px-3 py-1.5 text-[9px] uppercase tracking-wider text-muted-foreground">
<div className="border-t border-border/50 px-3 py-1.5 text-display text-xs tracking-wider text-text-tertiary">
Auxiliary task
</div>
@@ -267,7 +269,7 @@ function UseAsMenu({
type="button"
onClick={() => assign("auxiliary", "")}
disabled={busy}
className="flex w-full items-center justify-between px-3 py-1.5 text-xs hover:bg-muted/50 disabled:opacity-40"
className="flex w-full items-center justify-between px-3 py-1.5 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
>
<span>All auxiliary tasks</span>
</button>
@@ -278,11 +280,11 @@ function UseAsMenu({
type="button"
onClick={() => assign("auxiliary", t.key)}
disabled={busy}
className="flex w-full items-center justify-between px-3 py-1.5 text-xs hover:bg-muted/50 disabled:opacity-40"
className="flex w-full items-center justify-between px-3 py-1.5 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
>
<span>{t.label}</span>
{mainAuxTask === t.key && (
<span className="text-[9px] uppercase tracking-wider text-primary/80">
<span className="text-display text-xs tracking-wider text-primary">
current
</span>
)}
@@ -290,7 +292,7 @@ function UseAsMenu({
))}
{error && (
<div className="px-3 py-2 text-[10px] text-destructive border-t border-border/50">
<div className="px-3 py-2 text-xs text-destructive border-t border-border/50">
{error}
</div>
)}
@@ -343,36 +345,36 @@ function ModelCard({
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-muted-foreground/50 text-xs font-mono">
<span className="text-text-tertiary text-xs font-mono">
#{rank}
</span>
<CardTitle className="text-sm font-mono-ui truncate">
{shortModelName(entry.model)}
</CardTitle>
{isMain && (
<span className="inline-flex items-center gap-0.5 bg-primary/15 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-primary">
<span className="inline-flex items-center gap-0.5 bg-primary/15 px-1.5 py-0.5 text-display text-xs font-medium tracking-wider text-primary">
<Star className="h-2.5 w-2.5" /> main
</span>
)}
{mainAuxTask && (
<span className="inline-flex items-center bg-purple-500/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-purple-600 dark:text-purple-400">
<span className="inline-flex items-center bg-purple-500/10 px-1.5 py-0.5 text-display text-xs font-medium tracking-wider text-purple-600 dark:text-purple-400">
aux · {mainAuxTask}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1">
{provider && (
<Badge tone="secondary" className="text-[9px]">
<Badge tone="secondary" className="text-xs">
{provider}
</Badge>
)}
{caps.context_window && caps.context_window > 0 && (
<span className="text-[10px] text-muted-foreground">
<span className="text-xs text-text-secondary">
{formatTokenCount(caps.context_window)} ctx
</span>
)}
{caps.max_output_tokens && caps.max_output_tokens > 0 && (
<span className="text-[10px] text-muted-foreground">
<span className="text-xs text-text-secondary">
{formatTokenCount(caps.max_output_tokens)} out
</span>
)}
@@ -384,7 +386,7 @@ function ModelCard({
<div className="text-xs font-mono font-semibold">
{formatTokens(totalTokens)}
</div>
<div className="text-[10px] text-muted-foreground">
<div className="text-xs text-text-tertiary">
{t.models.tokens}
</div>
</div>
@@ -394,7 +396,7 @@ function ModelCard({
<div className="text-xs font-mono font-semibold">
{entry.sessions}
</div>
<div className="text-[10px] text-muted-foreground">
<div className="text-xs text-text-tertiary">
{t.models.sessions}
</div>
</div>
@@ -423,7 +425,7 @@ function ModelCard({
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center">
<div className="font-mono font-semibold">{entry.sessions}</div>
<div className="text-[10px] text-muted-foreground">
<div className="text-xs text-text-tertiary">
{t.models.sessions}
</div>
</div>
@@ -431,7 +433,7 @@ function ModelCard({
<div className="font-mono font-semibold">
{formatTokens(entry.avg_tokens_per_session)}
</div>
<div className="text-[10px] text-muted-foreground">
<div className="text-xs text-text-tertiary">
{t.models.avgPerSession}
</div>
</div>
@@ -439,7 +441,7 @@ function ModelCard({
<div className="font-mono font-semibold">
{entry.api_calls > 0 ? formatTokens(entry.api_calls) : "—"}
</div>
<div className="text-[10px] text-muted-foreground">
<div className="text-xs text-text-tertiary">
{t.models.apiCalls}
</div>
</div>
@@ -447,7 +449,7 @@ function ModelCard({
</>
)}
<div className="flex items-center justify-between text-[10px] text-muted-foreground border-t border-border/30 pt-2">
<div className="flex items-center justify-between text-xs text-text-secondary border-t border-border/30 pt-2">
<div className="flex items-center gap-3">
{showTokens && entry.estimated_cost > 0 && (
<span className="flex items-center gap-0.5">
@@ -522,7 +524,7 @@ function AuxiliaryTasksModal({
aria-modal="true"
aria-labelledby="aux-modal-title"
>
<div className="relative w-full max-w-2xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col">
<div className={cn(themedBody, "relative w-full max-w-2xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
@@ -537,7 +539,7 @@ function AuxiliaryTasksModal({
<div className="flex items-center justify-between gap-3 pr-8">
<h2
id="aux-modal-title"
className="font-display text-base tracking-wider uppercase"
className="font-mondwest text-display text-base tracking-wider"
>
Auxiliary Tasks
</h2>
@@ -546,13 +548,13 @@ function AuxiliaryTasksModal({
outlined
onClick={() => setConfirmReset(true)}
disabled={resetBusy}
className="text-[10px] h-6"
className="h-6 text-xs uppercase"
prefix={resetBusy ? <Spinner /> : null}
>
Reset all to auto
</Button>
</div>
<p className="text-[10px] text-muted-foreground/80 mt-2">
<p className="text-xs text-text-secondary mt-2">
Auxiliary tasks handle side-jobs like vision, session search, and
compression. <span className="font-mono">auto</span> means
&quot;use the main model&quot;. Override per-task when you want a
@@ -573,11 +575,11 @@ function AuxiliaryTasksModal({
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium">{t.label}</span>
<span className="text-[10px] text-muted-foreground/60">
<span className="text-xs text-text-tertiary">
{t.hint}
</span>
</div>
<div className="text-[10px] font-mono text-muted-foreground truncate">
<div className="text-xs font-mono text-text-secondary truncate">
{isAuto
? "auto (use main model)"
: `${cur?.provider} · ${cur?.model || "(provider default)"}`}
@@ -587,7 +589,7 @@ function AuxiliaryTasksModal({
size="sm"
outlined
onClick={() => setPicker({ kind: "aux", task: t.key })}
className="text-[10px] h-6"
className="h-6 text-xs uppercase"
>
Change
</Button>
@@ -673,7 +675,7 @@ function ModelSettingsPanel({
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<CardTitle className="text-sm">Model Settings</CardTitle>
<span className="max-w-full min-w-0 text-[10px] text-muted-foreground [overflow-wrap:anywhere]">
<span className="max-w-full min-w-0 text-xs text-text-secondary [overflow-wrap:anywhere]">
applies to new sessions
</span>
</div>
@@ -685,11 +687,11 @@ function ModelSettingsPanel({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5">
<Star className="h-3 w-3 text-primary" />
<span className="text-xs font-medium uppercase tracking-wider">
<span className="text-display text-xs font-medium tracking-wider">
Main model
</span>
</div>
<div className="text-xs font-mono text-muted-foreground truncate">
<div className="text-xs font-mono text-text-secondary truncate">
{mainProv || "(unset)"}
{mainProv && mainModel && " · "}
{mainModel || "(unset)"}
@@ -698,7 +700,7 @@ function ModelSettingsPanel({
<Button
size="sm"
onClick={() => setPicker({ kind: "main" })}
className="shrink-0 self-start text-xs sm:self-center"
className="shrink-0 self-start text-xs uppercase sm:self-center"
>
Change
</Button>
@@ -708,12 +710,12 @@ function ModelSettingsPanel({
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5">
<Cpu className="h-3 w-3 text-muted-foreground" />
<span className="text-xs font-medium uppercase tracking-wider">
<Cpu className="h-3 w-3 text-text-tertiary" />
<span className="text-display text-xs font-medium tracking-wider">
Auxiliary tasks
</span>
</div>
<div className="text-xs font-mono text-muted-foreground truncate">
<div className="text-xs font-mono text-text-secondary truncate">
{auxOverrideCount > 0
? `${auxOverrideCount} override${auxOverrideCount > 1 ? "s" : ""} · ${AUX_TASKS.length - auxOverrideCount} auto`
: `${AUX_TASKS.length} tasks · all auto`}
@@ -723,7 +725,7 @@ function ModelSettingsPanel({
size="sm"
outlined
onClick={() => setAuxModalOpen(true)}
className="shrink-0 self-start text-xs sm:self-center"
className="shrink-0 self-start text-xs uppercase sm:self-center"
>
Configure
</Button>
@@ -819,11 +821,21 @@ export default function ModelsPage() {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
setAfterTitle(
<span className="flex items-center gap-2">
{loading && <Spinner className="shrink-0 text-base text-primary" />}
<Badge tone="secondary" className="text-[10px]">
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{periodLabel}
</Badge>
<Button
type="button"
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={load}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</span>,
);
setEnd(
@@ -836,21 +848,12 @@ export default function ModelsPage() {
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
className="uppercase"
>
{p.label}
</Button>
))}
</div>
<Button
type="button"
size="sm"
outlined
onClick={load}
disabled={loading}
prefix={loading ? <Spinner /> : <RefreshCw />}
>
{t.common.refresh}
</Button>
</div>,
);
return () => {
@@ -924,7 +927,7 @@ export default function ModelsPage() {
/>
</div>
{!showTokens && (
<p className="mt-4 text-[10px] text-muted-foreground/70 leading-relaxed">
<p className="mt-4 text-xs text-text-tertiary leading-relaxed">
Token & cost analytics are hidden because the local counts
exclude auxiliary calls (compression, vision, web extract,
) and provider retries, so they diverge from your provider
@@ -975,7 +978,7 @@ export default function ModelsPage() {
<div className="flex flex-col items-center text-muted-foreground">
<Cpu className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">{t.models.noModelsData}</p>
<p className="text-xs mt-1 text-muted-foreground/60">
<p className="text-xs mt-1 text-text-tertiary">
{t.models.startSession}
</p>
</div>
+65 -68
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { ExternalLink, RefreshCw, Puzzle, Trash2, Eye, EyeOff } from "lucide-react";
import { ExternalLink, RefreshCw, Trash2, Eye, EyeOff } from "lucide-react";
import type { Translations } from "@/i18n/types";
import { Link } from "react-router-dom";
import { api } from "@/lib/api";
@@ -39,7 +39,7 @@ export default function PluginsPage() {
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setEnd } = usePageHeader();
const { setAfterTitle } = usePageHeader();
const loadHub = useCallback(() => {
return api
@@ -59,22 +59,20 @@ export default function PluginsPage() {
}, [loadHub]);
useEffect(() => {
setEnd(
<div className="flex w-full min-w-0 justify-start sm:justify-end">
<Button
ghost
size="sm"
className="w-max max-w-full shrink-0 gap-2"
disabled={loading || rescanBusy}
onClick={() => void onRescan()}
>
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
{t.pluginsPage.refreshDashboard}
</Button>
</div>,
setAfterTitle(
<Button
ghost
size="icon"
className="shrink-0 text-muted-foreground hover:text-foreground"
disabled={loading || rescanBusy}
onClick={() => void onRescan()}
aria-label={t.pluginsPage.refreshDashboard}
>
{rescanBusy ? <Spinner /> : <RefreshCw />}
</Button>,
);
return () => setEnd(null);
}, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]);
return () => setAfterTitle(null);
}, [loading, rescanBusy, setAfterTitle, t.pluginsPage.refreshDashboard]);
const onInstall = async () => {
const id = installId.trim();
@@ -160,7 +158,7 @@ export default function PluginsPage() {
<Card>
<CardHeader>
<CardTitle>{t.pluginsPage.providersHeading}</CardTitle>
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
<p className="text-xs tracking-[0.08em] text-text-tertiary">
{t.pluginsPage.providersHint}
</p>
</CardHeader>
@@ -212,13 +210,13 @@ export default function PluginsPage() {
</div>
<Button
className="w-fit gap-2"
className="w-fit uppercase"
size="sm"
disabled={providerBusy}
onClick={() => void onSaveProviders()}
prefix={providerBusy ? <Spinner /> : undefined}
>
{providerBusy ? <Spinner /> : null}
{t.pluginsPage.saveProviders}
{t.common.save}
</Button>
</CardContent>
</Card>
@@ -227,7 +225,7 @@ export default function PluginsPage() {
<Card>
<CardHeader>
<CardTitle>{t.pluginsPage.installHeading}</CardTitle>
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
<p className="text-xs tracking-[0.08em] text-text-tertiary">
{t.pluginsPage.installHint}
</p>
</CardHeader>
@@ -240,7 +238,7 @@ export default function PluginsPage() {
<Label htmlFor="install-url">{t.pluginsPage.identifierLabel}</Label>
<Input
className="normal-case font-sans lowercase"
className="font-mono-ui lowercase"
id="install-url"
placeholder="owner/repo or https://..."
spellCheck={false}
@@ -256,7 +254,7 @@ export default function PluginsPage() {
<Switch checked={installForce} onCheckedChange={setInstallForce} />
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
<span className="text-xs tracking-[0.06em] text-text-secondary">
{t.pluginsPage.forceReinstall}
</span>
</div>
@@ -265,27 +263,27 @@ export default function PluginsPage() {
<Switch checked={installEnable} onCheckedChange={setInstallEnable} />
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
<span className="text-xs tracking-[0.06em] text-text-secondary">
{t.pluginsPage.enableAfterInstall}
</span>
</div>
</div>
<Button
className="w-fit gap-2"
className="w-fit uppercase"
size="sm"
disabled={installBusy}
onClick={() => void onInstall()}
prefix={installBusy ? <Spinner /> : undefined}
>
{installBusy ? <Spinner /> : <Puzzle className="h-3.5 w-3.5" />}
{t.pluginsPage.installBtn}
</Button>
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
<p className="text-xs tracking-[0.06em] text-text-tertiary">
{t.pluginsPage.rescanHint}
</p>
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
<p className="text-xs tracking-[0.06em] text-text-tertiary">
{t.pluginsPage.removeHint}
</p>
</CardContent>
@@ -293,20 +291,20 @@ export default function PluginsPage() {
<div className="flex flex-col gap-3">
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midground/85">
<h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.pluginsPage.pluginListHeading}
</h3>
{loading ? (
<div className="flex items-center gap-2 py-8 text-[0.8rem] text-midforeground/65">
<div className="flex items-center gap-2 py-8 text-xs text-text-tertiary">
<Spinner />
<span>{t.common.loading}</span>
</div>
) : rows.length === 0 ? (
<p className="text-[0.75rem] text-midforeground/55 normal-case">{t.common.noResults}</p>
<p className="text-xs text-text-tertiary">{t.common.noResults}</p>
) : (
<ul className="flex flex-col gap-3">
@@ -331,7 +329,7 @@ export default function PluginsPage() {
<div className="flex flex-col gap-3 opacity-95">
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midforeground/85">
<h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.pluginsPage.orphanHeading}
</h3>
@@ -339,7 +337,7 @@ export default function PluginsPage() {
{hub!.orphan_dashboard_plugins.map((m) => (
<li className="text-[0.7rem] normal-case opacity-85" key={m.name}>
<li className="text-xs text-text-secondary" key={m.name}>
{m.label ?? m.name} {m.description || m.tab?.path}
@@ -433,36 +431,35 @@ function PluginRowCard(props: PluginRowCardProps) {
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
<Button
disabled={busy || row.runtime_status === "enabled"}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.enableAgentPlugin(row.name);
showToast(t.pluginsPage.enableRuntime, "success");
});
}}
>
{t.pluginsPage.enableRuntime}
</Button>
<Button
disabled={busy || row.runtime_status === "disabled"}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.disableAgentPlugin(row.name);
showToast(t.pluginsPage.disableRuntime, "success");
});
}}
>
{t.pluginsPage.disableRuntime}
</Button>
{row.runtime_status === "enabled" ? (
<Button
disabled={busy}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.disableAgentPlugin(row.name);
showToast(t.pluginsPage.disableRuntime, "success");
});
}}
>
{t.pluginsPage.disableRuntime}
</Button>
) : (
<Button
disabled={busy}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.enableAgentPlugin(row.name);
showToast(t.pluginsPage.enableRuntime, "success");
});
}}
>
{t.pluginsPage.enableRuntime}
</Button>
)}
{tabPath ? (
@@ -470,7 +467,7 @@ function PluginRowCard(props: PluginRowCardProps) {
className={cn(
"inline-flex items-center rounded-none px-3 py-1.5",
"border border-current/25 hover:bg-current/10",
"font-mondwest text-[0.65rem] tracking-[0.1em] uppercase",
"font-mondwest text-display text-xs tracking-[0.1em]",
)}
to={tabPath}
>
@@ -535,14 +532,14 @@ function PluginRowCard(props: PluginRowCardProps) {
</div>
{row.description ? (
<p className="min-w-0 w-full text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case break-words">
<p className="min-w-0 w-full text-xs tracking-[0.06em] text-text-secondary break-words">
{row.description}
</p>
) : null}
{dm?.slots?.length ? (
<p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case">
<p className="text-xs tracking-[0.05em] text-text-tertiary">
{t.pluginsPage.dashboardSlots}: {dm.slots.join(", ")}
</p>
) : null}
@@ -557,7 +554,7 @@ function PluginRowCard(props: PluginRowCardProps) {
{!row.has_dashboard_manifest && !dm ? (
<p className="text-[0.65rem] italic text-midforeground/45 normal-case">
<p className="text-xs italic text-text-disabled">
{t.pluginsPage.noDashboardTab}
</p>
) : null}
+19 -14
View File
@@ -8,7 +8,6 @@ import {
import {
ChevronDown,
Pencil,
Plus,
Terminal,
Trash2,
Users,
@@ -31,6 +30,7 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { cn, themedBody } from "@/lib/utils";
// Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
@@ -231,8 +231,11 @@ export default function ProfilesPage() {
// Put "Create" button in page header
useLayoutEffect(() => {
setEnd(
<Button size="sm" onClick={() => setCreateModalOpen(true)}>
<Plus className="h-3 w-3" />
<Button
className="uppercase"
size="sm"
onClick={() => setCreateModalOpen(true)}
>
{t.common.create}
</Button>,
);
@@ -256,10 +259,7 @@ export default function ProfilesPage() {
}
return (
// Profile names, model slugs, and paths are case-sensitive; opt out of
// the app shell's global ``uppercase`` so they render as the user typed.
// Children that explicitly opt back in (Badges, etc.) keep their casing.
<div className="flex flex-col gap-6 normal-case">
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<DeleteConfirmDialog
@@ -287,7 +287,7 @@ export default function ProfilesPage() {
aria-modal="true"
aria-labelledby="create-profile-title"
>
<div className="relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col">
<div className={cn(themedBody, "relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
@@ -301,7 +301,7 @@ export default function ProfilesPage() {
<header className="p-5 pb-3 border-b border-border">
<h2
id="create-profile-title"
className="font-display text-base tracking-wider uppercase"
className="font-mondwest text-display text-base tracking-wider"
>
{t.profiles.newProfile}
</h2>
@@ -339,7 +339,7 @@ export default function ProfilesPage() {
/>
<Label
className="font-sans normal-case tracking-normal text-sm cursor-pointer"
className="font-mondwest normal-case tracking-normal text-sm cursor-pointer"
htmlFor="clone-from-default"
>
{t.profiles.cloneFromDefault}
@@ -347,8 +347,12 @@ export default function ProfilesPage() {
</div>
<div className="flex justify-end">
<Button size="sm" onClick={handleCreate} disabled={creating}>
<Plus className="h-3 w-3" />
<Button
className="uppercase"
size="sm"
onClick={handleCreate}
disabled={creating}
>
{creating ? t.common.creating : t.common.create}
</Button>
</div>
@@ -523,7 +527,7 @@ export default function ProfilesPage() {
<div className="border-t border-border px-4 pb-4 pt-3 flex flex-col gap-2">
<Label
htmlFor={`soul-editor-${p.name}`}
className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground"
className="flex items-center gap-2 font-mondwest text-display text-xs tracking-wider text-muted-foreground"
>
{t.profiles.soulSection}
</Label>
@@ -537,10 +541,11 @@ export default function ProfilesPage() {
<div>
<Button
size="sm"
className="uppercase"
onClick={() => handleSaveSoul(p.name)}
disabled={soulSaving}
>
{soulSaving ? t.common.saving : t.profiles.saveSoul}
{soulSaving ? t.common.saving : t.common.save}
</Button>
</div>
</div>
+312 -227
View File
@@ -37,6 +37,7 @@ import { PlatformsCard } from "@/components/PlatformsCard";
import { Toast } from "@/components/Toast";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Segmented } from "@nous-research/ui/ui/components/segmented";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -83,7 +84,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
parts.push(snippet.slice(last));
}
return (
<p className="mt-0.5 min-w-0 max-w-full truncate text-xs text-muted-foreground/80">
<p className="font-mondwest normal-case mt-0.5 min-w-0 max-w-full truncate text-xs text-text-secondary">
{parts}
</p>
);
@@ -191,12 +192,12 @@ function MessageBubble({
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
{isHit && (
<Badge tone="warning" className="text-[9px] py-0 px-1.5">
<Badge tone="warning" className="text-xs py-0 px-1.5">
{t.common.match}
</Badge>
)}
{msg.timestamp && (
<span className="text-[10px] text-muted-foreground">
<span className="text-xs text-text-tertiary">
{timeAgo(msg.timestamp)}
</span>
)}
@@ -294,6 +295,43 @@ function SessionRow({
const SourceIcon = sourceInfo.icon;
const hasTitle = session.title && session.title !== "Untitled";
const actionButtons = (
<>
<Badge tone="outline" className="text-xs">
{session.source ?? "local"}
</Badge>
{resumeInChatEnabled && (
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-success"
aria-label={t.sessions.resumeInChat}
title={t.sessions.resumeInChat}
onClick={(e) => {
e.stopPropagation();
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
}}
>
<Play />
</Button>
)}
<Button
ghost
destructive
size="icon"
aria-label={t.sessions.deleteSession}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 />
</Button>
</>
);
return (
<div
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
@@ -310,76 +348,54 @@ function SessionRow({
<SourceIcon className="h-4 w-4" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span
className={`min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
>
{hasTitle
? session.title
: session.preview
? session.preview.slice(0, 60)
: t.sessions.untitledSession}
</span>
{session.is_active && (
<Badge tone="success" className="shrink-0 text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
)}
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span
className={`font-mondwest normal-case min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
>
{hasTitle
? session.title
: session.preview
? session.preview.slice(0, 60)
: t.sessions.untitledSession}
</span>
{session.is_active && (
<Badge tone="success" className="shrink-0 text-xs">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
)}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
<span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]">
{(session.model ?? t.common.unknown).split("/").pop()}
</span>
<span className="text-border">&#183;</span>
<span className="shrink-0">
{session.message_count} {t.common.msgs}
</span>
{session.tool_call_count > 0 && (
<>
<span className="text-border">&#183;</span>
<span className="shrink-0">
{session.tool_call_count} {t.common.tools}
</span>
</>
)}
<span className="text-border">&#183;</span>
<span className="shrink-0">{timeAgo(session.last_active)}</span>
</div>
{snippet && <SnippetHighlight snippet={snippet} />}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
<span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]">
{(session.model ?? t.common.unknown).split("/").pop()}
</span>
<span className="text-border">&#183;</span>
<span className="shrink-0">
{session.message_count} {t.common.msgs}
</span>
{session.tool_call_count > 0 && (
<>
<span className="text-border">&#183;</span>
<span className="shrink-0">
{session.tool_call_count} {t.common.tools}
</span>
</>
)}
<span className="text-border">&#183;</span>
<span className="shrink-0">{timeAgo(session.last_active)}</span>
<div className="hidden shrink-0 items-center gap-2 sm:flex">
{actionButtons}
</div>
</div>
{snippet && <SnippetHighlight snippet={snippet} />}
<div className="flex flex-wrap items-center gap-2">
<Badge tone="outline" className="text-[10px]">
{session.source ?? "local"}
</Badge>
{resumeInChatEnabled && (
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-success"
aria-label={t.sessions.resumeInChat}
title={t.sessions.resumeInChat}
onClick={(e) => {
e.stopPropagation();
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
}}
>
<Play />
</Button>
)}
<Button
ghost
destructive
size="icon"
aria-label={t.sessions.deleteSession}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 />
</Button>
<div className="flex flex-wrap items-center gap-2 sm:hidden">
{actionButtons}
</div>
</div>
</div>
@@ -408,11 +424,62 @@ function SessionRow({
);
}
type SessionsView = "list" | "overview";
const PAGE_SIZE = 20;
function SessionsPagination({
className,
compact = false,
onPageChange,
page,
total,
}: SessionsPaginationProps) {
const { t } = useI18n();
const pageCount = Math.ceil(total / PAGE_SIZE);
return (
<div
className={`flex items-center ${compact ? "gap-1" : "justify-between pt-2"}${className ? ` ${className}` : ""}`}
>
{!compact && (
<span className="text-xs text-muted-foreground">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)}{" "}
{t.common.of} {total}
</span>
)}
<div className="flex items-center gap-1">
<Button
outlined
size="icon"
disabled={page === 0}
onClick={() => onPageChange(page - 1)}
aria-label={t.sessions.previousPage}
>
<ChevronLeft />
</Button>
<span className="px-2 text-xs text-muted-foreground">
{t.common.page} {page + 1} {t.common.of} {pageCount}
</span>
<Button
outlined
size="icon"
disabled={(page + 1) * PAGE_SIZE >= total}
onClick={() => onPageChange(page + 1)}
aria-label={t.sessions.nextPage}
>
<ChevronRight />
</Button>
</div>
</div>
);
}
export default function SessionsPage() {
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const PAGE_SIZE = 20;
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -424,16 +491,16 @@ export default function SessionsPage() {
const logScrollRef = useRef<HTMLPreElement | null>(null);
const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
const [view, setView] = useState<SessionsView>("overview");
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const { setAfterTitle } = usePageHeader();
const { activeAction, actionStatus, dismissLog } = useSystemActions();
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
setEnd(null);
return;
}
setAfterTitle(
@@ -441,46 +508,10 @@ export default function SessionsPage() {
{total}
</Badge>,
);
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
{searching ? (
<Spinner className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[0.875rem] text-primary" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pr-7 pl-8 text-xs"
/>
{search && (
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
aria-label={t.common.clear}
>
<X />
</Button>
)}
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [
loading,
search,
searching,
setAfterTitle,
setEnd,
t.common.clear,
t.sessions.searchPlaceholder,
total,
]);
}, [loading, setAfterTitle, total]);
const loadSessions = useCallback((p: number) => {
setLoading(true);
@@ -591,6 +622,16 @@ export default function SessionsPage() {
.filter((s) => !s.is_active)
.slice(0, 5);
const isSearching = Boolean(search.trim());
const showOverviewTab =
platformEntries.length > 0 || recentSessions.length > 0;
const showList = view === "list" || isSearching || !showOverviewTab;
const showPagination = showList && !searchResults && total > PAGE_SIZE;
useEffect(() => {
if (isSearching) setView("list");
}, [isSearching]);
const alerts: { message: string; detail?: string }[] = [];
if (status) {
if (status.gateway_state === "startup_failed") {
@@ -692,7 +733,7 @@ export default function SessionsPage() {
? "destructive"
: "outline"
}
className="text-[10px] shrink-0"
className="text-xs shrink-0"
>
{actionStatus?.running
? t.status.running
@@ -708,7 +749,7 @@ export default function SessionsPage() {
ghost
size="icon"
onClick={dismissLog}
className="shrink-0 opacity-60 hover:opacity-100"
className="shrink-0 text-text-secondary hover:text-foreground"
aria-label={t.common.close}
>
<X />
@@ -717,7 +758,7 @@ export default function SessionsPage() {
<pre
ref={logScrollRef}
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-[11px] leading-relaxed whitespace-pre-wrap break-all"
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-xs leading-relaxed whitespace-pre-wrap break-all"
>
{actionStatus?.lines && actionStatus.lines.length > 0
? actionStatus.lines.join("\n")
@@ -726,126 +767,170 @@ export default function SessionsPage() {
</div>
)}
{platformEntries.length > 0 && status && (
<PlatformsCard platforms={platformEntries} />
)}
{recentSessions.length > 0 && (
<Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<Clock className="h-5 w-5 shrink-0 text-muted-foreground" />
<CardTitle className="min-w-0 truncate text-base">
{t.status.recentSessions}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid min-w-0 gap-3">
{recentSessions.map((s) => (
<div
key={s.id}
className="flex min-w-0 max-w-full flex-col gap-2 border border-border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="min-w-0 truncate text-sm font-medium">
{s.title ?? t.common.untitled}
</span>
<span className="min-w-0 break-words text-xs text-muted-foreground">
<span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "}
· {s.message_count} {t.common.msgs} ·{" "}
{timeAgo(s.last_active)}
</span>
{s.preview && (
<p className="min-w-0 max-w-full text-xs leading-snug text-muted-foreground/70 [overflow-wrap:anywhere]">
{s.preview}
</p>
)}
</div>
<Badge
tone="outline"
className="shrink-0 self-start text-[10px] sm:self-center"
>
<Database className="mr-1 h-3 w-3" />
{s.source ?? "local"}
</Badge>
</div>
))}
</CardContent>
</Card>
)}
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Clock className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">
{search ? t.sessions.noMatch : t.sessions.noSessions}
</p>
{!search && (
<p className="text-xs mt-1 text-muted-foreground/60">
{t.sessions.startConversation}
</p>
)}
</div>
) : (
<>
<div className="flex min-w-0 flex-col gap-1.5">
{filtered.map((s) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => sessionDelete.requestDelete(s.id)}
resumeInChatEnabled={resumeInChatEnabled}
{(showOverviewTab && !isSearching) || showList ? (
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 sm:gap-3">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 sm:gap-3">
{showOverviewTab && !isSearching && (
<Segmented
className="w-fit shrink-0"
size="md"
value={view}
onChange={setView}
options={[
{ value: "overview", label: t.sessions.overview },
{ value: "list", label: t.sessions.title },
]}
/>
))}
)}
{showList && (
<div className="relative min-w-0 w-full sm:w-auto sm:min-w-[12rem] sm:max-w-md sm:flex-1">
{searching ? (
<Spinner className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[0.875rem] text-primary" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 py-0 pr-7 pl-8 text-xs leading-none"
/>
{search && (
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
aria-label={t.common.clear}
>
<X />
</Button>
)}
</div>
)}
</div>
{!searchResults && total > PAGE_SIZE && (
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-muted-foreground">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)}{" "}
{t.common.of} {total}
</span>
<div className="flex items-center gap-1">
<Button
outlined
size="icon"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
aria-label={t.sessions.previousPage}
>
<ChevronLeft />
</Button>
<span className="text-xs text-muted-foreground px-2">
{t.common.page} {page + 1} {t.common.of}{" "}
{Math.ceil(total / PAGE_SIZE)}
</span>
<Button
outlined
size="icon"
disabled={(page + 1) * PAGE_SIZE >= total}
onClick={() => setPage((p) => p + 1)}
aria-label={t.sessions.nextPage}
>
<ChevronRight />
</Button>
</div>
</div>
{showPagination && (
<SessionsPagination
compact
className="shrink-0 sm:ml-auto"
page={page}
total={total}
onPageChange={setPage}
/>
)}
</>
</div>
) : null}
{showList ? (
filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Clock className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">
{search ? t.sessions.noMatch : t.sessions.noSessions}
</p>
{!search && (
<p className="text-xs mt-1 text-text-tertiary">
{t.sessions.startConversation}
</p>
)}
</div>
) : (
<>
<div className="flex min-w-0 flex-col gap-1.5">
{filtered.map((s) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => sessionDelete.requestDelete(s.id)}
resumeInChatEnabled={resumeInChatEnabled}
/>
))}
</div>
{showPagination && (
<SessionsPagination
page={page}
total={total}
onPageChange={setPage}
/>
)}
</>
)
) : (
<div className="flex min-w-0 flex-col gap-4">
{platformEntries.length > 0 && status && (
<PlatformsCard platforms={platformEntries} />
)}
{recentSessions.length > 0 && (
<Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<Clock className="h-5 w-5 shrink-0 text-muted-foreground" />
<CardTitle className="min-w-0 truncate text-base">
{t.status.recentSessions}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid min-w-0 gap-3">
{recentSessions.map((s) => (
<div
key={s.id}
className="flex min-w-0 max-w-full flex-col gap-2 border border-border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="font-mondwest normal-case min-w-0 truncate text-sm font-medium">
{s.title ?? t.common.untitled}
</span>
<span className="min-w-0 break-words text-xs text-muted-foreground">
<span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "}
· {s.message_count} {t.common.msgs} ·{" "}
{timeAgo(s.last_active)}
</span>
{s.preview && (
<p className="font-mondwest normal-case min-w-0 max-w-full text-xs leading-snug text-text-tertiary [overflow-wrap:anywhere]">
{s.preview}
</p>
)}
</div>
<Badge
tone="outline"
className="shrink-0 self-start text-xs sm:self-center"
>
<Database className="mr-1 h-3 w-3" />
{s.source ?? "local"}
</Badge>
</div>
))}
</CardContent>
</Card>
)}
</div>
)}
<PluginSlot name="sessions:bottom" />
</div>
);
}
interface SessionsPaginationProps {
className?: string;
compact?: boolean;
onPageChange: (page: number) => void;
page: number;
total: number;
}
+14 -14
View File
@@ -258,8 +258,8 @@ export default function SkillsPage() {
<div className="sm:sticky sm:top-0">
<div className="flex flex-col rounded-none border border-border bg-muted/20">
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
<Filter className="h-3 w-3 text-muted-foreground" />
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
<Filter className="h-3 w-3 text-text-tertiary" />
<span className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.skills.filters}
</span>
</div>
@@ -290,7 +290,7 @@ export default function SkillsPage() {
!isSearching &&
allCategories.length > 0 && (
<div className="hidden sm:flex flex-col border-t border-border">
<div className="px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
<div className="px-3 pt-2 pb-1 font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary">
{t.skills.categories}
</div>
<div className="flex flex-col p-2 pt-1 gap-px max-h-[calc(100vh-340px)] overflow-y-auto">
@@ -304,14 +304,14 @@ export default function SkillsPage() {
onClick={() =>
setActiveCategory(isActive ? null : key)
}
className="rounded-none px-2 py-1 text-[11px]"
className="rounded-none px-2 py-1 text-xs"
>
<span className="flex-1 truncate">{name}</span>
<span
className={`text-[10px] tabular-nums ${
className={`text-xs tabular-nums ${
isActive
? "text-foreground/60"
: "text-muted-foreground/50"
? "text-text-secondary"
: "text-text-tertiary"
}`}
>
{count}
@@ -335,7 +335,7 @@ export default function SkillsPage() {
<Search className="h-4 w-4" />
{t.skills.title}
</CardTitle>
<Badge tone="secondary" className="text-[10px]">
<Badge tone="secondary" className="text-xs">
{t.skills.resultCount
.replace("{count}", String(searchMatchedSkills.length))
.replace(
@@ -379,7 +379,7 @@ export default function SkillsPage() {
)
: t.skills.all}
</CardTitle>
<Badge tone="secondary" className="text-[10px]">
<Badge tone="secondary" className="text-xs">
{t.skills.skillCount
.replace("{count}", String(activeSkills.length))
.replace("{s}", activeSkills.length !== 1 ? "s" : "")}
@@ -437,18 +437,18 @@ export default function SkillsPage() {
</span>
<Badge
tone={ts.enabled ? "success" : "outline"}
className="text-[10px]"
className="text-xs"
>
{ts.enabled
? t.common.active
: t.common.inactive}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
<p className="text-xs text-text-secondary mb-2">
{ts.description}
</p>
{ts.enabled && !ts.configured && (
<p className="text-[10px] text-amber-300/80 mb-2">
<p className="text-xs text-amber-300 mb-2">
{t.skills.setupNeeded}
</p>
)}
@@ -458,7 +458,7 @@ export default function SkillsPage() {
<Badge
key={tool}
tone="secondary"
className="text-[10px] font-mono"
className="text-xs font-mono"
>
{tool}
</Badge>
@@ -466,7 +466,7 @@ export default function SkillsPage() {
</div>
)}
{ts.tools.length === 0 && (
<span className="text-[10px] text-muted-foreground/60">
<span className="text-xs text-text-tertiary">
{ts.enabled
? t.skills.toolsetLabel.replace(
"{name}",
+2 -2
View File
@@ -35,7 +35,7 @@ export function PluginPage({ name }: { name: string }) {
<div
className={cn(
"max-w-lg p-4",
"font-mondwest text-sm tracking-[0.08em] text-midground/80",
"font-mondwest text-sm tracking-[0.08em] text-text-secondary",
)}
role="alert"
>
@@ -48,7 +48,7 @@ export function PluginPage({ name }: { name: string }) {
<div
className={cn(
"flex items-center gap-2 p-4",
"font-mondwest text-sm tracking-[0.1em] text-midground/60",
"font-mondwest text-sm tracking-[0.1em] text-text-tertiary",
)}
>
<Spinner className="shrink-0" />
+1 -1
View File
@@ -39,7 +39,7 @@ model:
# LM Studio is first-class and uses provider: "lmstudio".
# It works with both no-auth and auth-enabled server modes.
#
# Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var.
# Can also be overridden for a single invocation with the --provider flag.
provider: "auto"
# API configuration (falls back to OPENROUTER_API_KEY env var)
+452 -132
View File
@@ -51,6 +51,8 @@ os.environ["HERMES_QUIET"] = "1" # Our own modules
import yaml
from hermes_cli.fallback_config import get_fallback_chain
# prompt_toolkit for fixed input area TUI
from prompt_toolkit.history import FileHistory
from prompt_toolkit.styles import Style as PTStyle
@@ -81,17 +83,73 @@ except Exception:
import threading
import queue
from agent.usage_pricing import (
CanonicalUsage,
estimate_usage_cost,
format_duration_compact,
format_token_count_compact,
)
from agent.markdown_tables import (
is_table_divider,
looks_like_table_row,
realign_markdown_tables,
)
def CanonicalUsage(*args, **kwargs):
from agent.usage_pricing import CanonicalUsage as _CanonicalUsage
return _CanonicalUsage(*args, **kwargs)
def estimate_usage_cost(*args, **kwargs):
from agent.usage_pricing import estimate_usage_cost as _estimate_usage_cost
return _estimate_usage_cost(*args, **kwargs)
def format_duration_compact(*args, **kwargs):
seconds = float(args[0] if args else kwargs.get("seconds", 0.0))
if seconds < 60:
return f"{seconds:.0f}s"
minutes = seconds / 60
if minutes < 60:
return f"{minutes:.0f}m"
hours = minutes / 60
if hours < 24:
remaining_min = int(minutes % 60)
return f"{int(hours)}h {remaining_min}m" if remaining_min else f"{int(hours)}h"
days = hours / 24
return f"{days:.1f}d"
def format_token_count_compact(*args, **kwargs):
value = int(args[0] if args else kwargs.get("value", 0))
abs_value = abs(value)
if abs_value < 1_000:
return str(value)
sign = "-" if value < 0 else ""
units = ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K"))
for threshold, suffix in units:
if abs_value >= threshold:
scaled = abs_value / threshold
if scaled < 10:
text = f"{scaled:.2f}"
elif scaled < 100:
text = f"{scaled:.1f}"
else:
text = f"{scaled:.0f}"
if "." in text:
text = text.rstrip("0").rstrip(".")
return f"{sign}{text}{suffix}"
return f"{value:,}"
def is_table_divider(*args, **kwargs):
from agent.markdown_tables import is_table_divider as _is_table_divider
return _is_table_divider(*args, **kwargs)
def looks_like_table_row(*args, **kwargs):
from agent.markdown_tables import looks_like_table_row as _looks_like_table_row
return _looks_like_table_row(*args, **kwargs)
def realign_markdown_tables(*args, **kwargs):
from agent.markdown_tables import realign_markdown_tables as _realign_markdown_tables
return _realign_markdown_tables(*args, **kwargs)
# NOTE: `from agent.account_usage import ...` is deliberately NOT at module
# top — it transitively pulls the OpenAI SDK chain (~230 ms cold) and is only
# needed when the user runs `/limits`. Lazy-imported inside the handler below.
@@ -357,6 +415,12 @@ def load_cli_config() -> Dict[str, Any]:
"display": {
"compact": False,
"resume_display": "full",
# Recap tuning for /resume — see hermes_cli/config.py DEFAULT_CONFIG.
"resume_exchanges": 10,
"resume_max_user_chars": 300,
"resume_max_assistant_chars": 200,
"resume_max_assistant_lines": 3,
"resume_skip_tool_only": True,
"show_reasoning": False,
"streaming": True,
"busy_input_mode": "interrupt",
@@ -410,7 +474,9 @@ def load_cli_config() -> Dict[str, Any]:
if config_path.exists():
try:
with open(config_path, "r", encoding="utf-8") as f:
file_config = yaml.safe_load(f) or {}
from hermes_cli.config import _normalize_root_model_keys
file_config = _normalize_root_model_keys(yaml.safe_load(f) or {})
_file_has_terminal_config = "terminal" in file_config
@@ -431,21 +497,6 @@ def load_cli_config() -> Dict[str, Any]:
if "model" in file_config["model"] and "default" not in file_config["model"]:
defaults["model"]["default"] = file_config["model"]["model"]
# Legacy root-level provider/base_url fallback.
# Some users (or old code) put provider: / base_url: at the
# config root instead of inside the model: section. These are
# only used as a FALLBACK when model.provider / model.base_url
# is not already set — never as an override. The canonical
# location is model.provider (written by `hermes model`).
if not defaults["model"].get("provider"):
root_provider = file_config.get("provider")
if root_provider:
defaults["model"]["provider"] = root_provider
if not defaults["model"].get("base_url"):
root_base_url = file_config.get("base_url")
if root_base_url:
defaults["model"]["base_url"] = root_base_url
# Deep merge file_config into defaults.
# First: merge keys that exist in both (deep-merge dicts, overwrite scalars)
for key in defaults:
@@ -717,31 +768,142 @@ from rich.markup import escape as _escape
from rich.panel import Panel
from rich.text import Text as _RichText
import fire
# Import agent and tool systems lazily. Bare interactive startup only needs the
# prompt; the full agent/tool registry is initialized on first use.
def AIAgent(*args, **kwargs):
from run_agent import AIAgent as _AIAgent
# Import the agent and tool systems
from run_agent import AIAgent
from model_tools import get_tool_definitions, get_toolset_for_tool
return _AIAgent(*args, **kwargs)
def get_tool_definitions(*args, **kwargs):
from model_tools import get_tool_definitions as _get_tool_definitions
return _get_tool_definitions(*args, **kwargs)
def get_toolset_for_tool(*args, **kwargs):
from model_tools import get_toolset_for_tool as _get_toolset_for_tool
return _get_toolset_for_tool(*args, **kwargs)
# Extracted CLI modules (Phase 3)
from hermes_cli.banner import build_welcome_banner
from hermes_cli.commands import SlashCommandCompleter, SlashCommandAutoSuggest
from toolsets import get_all_toolsets, get_toolset_info, validate_toolset
def get_all_toolsets(*args, **kwargs):
from toolsets import get_all_toolsets as _get_all_toolsets
return _get_all_toolsets(*args, **kwargs)
def get_toolset_info(*args, **kwargs):
from toolsets import get_toolset_info as _get_toolset_info
return _get_toolset_info(*args, **kwargs)
def validate_toolset(*args, **kwargs):
from toolsets import validate_toolset as _validate_toolset
return _validate_toolset(*args, **kwargs)
def _sync_process_session_id(session_id: str) -> None:
"""Keep process-local session-id consumers aligned after CLI switches."""
from gateway.session_context import set_current_session_id
set_current_session_id(session_id)
# Cron job system for scheduled tasks (execution is handled by the gateway)
from cron import get_job
def get_job(*args, **kwargs):
from cron import get_job as _get_job
return _get_job(*args, **kwargs)
# Resource cleanup imports for safe shutdown (terminal VMs, browser sessions)
from tools.terminal_tool import cleanup_all_environments as _cleanup_all_terminals
from tools.terminal_tool import set_sudo_password_callback, set_approval_callback
from tools.skills_tool import set_secret_capture_callback
from hermes_cli.callbacks import prompt_for_secret
from tools.browser_tool import _emergency_cleanup_all_sessions as _cleanup_all_browsers
def _cleanup_all_terminals(*args, **kwargs):
from tools.terminal_tool import cleanup_all_environments
return cleanup_all_environments(*args, **kwargs)
def set_sudo_password_callback(*args, **kwargs):
from tools.terminal_tool import set_sudo_password_callback as _set_sudo_password_callback
return _set_sudo_password_callback(*args, **kwargs)
def set_approval_callback(*args, **kwargs):
from tools.terminal_tool import set_approval_callback as _set_approval_callback
return _set_approval_callback(*args, **kwargs)
def set_secret_capture_callback(*args, **kwargs):
from tools.skills_tool import set_secret_capture_callback as _set_secret_capture_callback
return _set_secret_capture_callback(*args, **kwargs)
def _cleanup_all_browsers(*args, **kwargs):
from tools.browser_tool import _emergency_cleanup_all_sessions
return _emergency_cleanup_all_sessions(*args, **kwargs)
# Guard to prevent cleanup from running multiple times on exit
_cleanup_done = False
# Weak reference to the active AIAgent for memory provider shutdown at exit
_active_agent_ref = None
_deferred_agent_startup_done = False
def _prepare_deferred_agent_startup() -> None:
"""Run Termux-deferred agent discovery before the first real agent turn."""
global _deferred_agent_startup_done
if _deferred_agent_startup_done:
return
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
return
_deferred_agent_startup_done = True
_accept_hooks = os.environ.get("HERMES_ACCEPT_HOOKS", "").lower() in {
"1",
"true",
"yes",
"on",
}
try:
from hermes_cli.plugins import discover_plugins
discover_plugins()
except Exception:
logger.warning(
"plugin discovery failed at deferred CLI startup",
exc_info=True,
)
try:
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
except Exception:
logger.debug(
"MCP tool discovery failed at deferred CLI startup",
exc_info=True,
)
try:
from agent.shell_hooks import register_from_config
from hermes_cli.config import load_config
register_from_config(load_config(), accept_hooks=_accept_hooks)
except Exception:
logger.debug(
"shell-hook registration failed at deferred CLI startup",
exc_info=True,
)
def _run_cleanup():
"""Run resource cleanup exactly once."""
@@ -2455,7 +2617,13 @@ def _build_compact_banner() -> str:
line1 = f"{agent_name} - AI Agent Framework"
tiny_line = agent_name
version_line = format_banner_version_label()
if os.environ.get("HERMES_FAST_STARTUP_BANNER") == "1":
from hermes_cli import __release_date__ as _release_date
from hermes_cli import __version__ as _version
version_line = f"Hermes Agent v{_version} ({_release_date})"
else:
version_line = format_banner_version_label()
w = min(shutil.get_terminal_size().columns - 2, 88)
if w < 30:
@@ -2504,19 +2672,48 @@ def _looks_like_slash_command(text: str) -> bool:
# Skill Slash Commands — dynamic commands generated from installed skills
# ============================================================================
from agent.skill_commands import (
scan_skill_commands,
get_skill_commands,
build_skill_invocation_message,
build_preloaded_skills_prompt,
)
from agent.skill_bundles import (
get_skill_bundles,
build_bundle_invocation_message,
)
_skill_commands = None
_skill_bundles = None
_skill_commands = scan_skill_commands()
_skill_bundles = get_skill_bundles()
def _ensure_skill_commands() -> dict:
global _skill_commands
if _skill_commands is None:
from agent.skill_commands import scan_skill_commands
_skill_commands = scan_skill_commands()
return _skill_commands
def get_skill_commands() -> dict:
return _ensure_skill_commands()
def build_skill_invocation_message(*args, **kwargs):
from agent.skill_commands import build_skill_invocation_message as _impl
return _impl(*args, **kwargs)
def build_preloaded_skills_prompt(*args, **kwargs):
from agent.skill_commands import build_preloaded_skills_prompt as _impl
return _impl(*args, **kwargs)
def get_skill_bundles() -> dict:
global _skill_bundles
if _skill_bundles is None:
from agent.skill_bundles import get_skill_bundles as _impl
_skill_bundles = _impl()
return _skill_bundles
def build_bundle_invocation_message(*args, **kwargs):
from agent.skill_bundles import build_bundle_invocation_message as _impl
return _impl(*args, **kwargs)
def _get_plugin_cmd_handler_names() -> set:
@@ -2615,7 +2812,7 @@ class HermesCLI:
api_key: str = None,
base_url: str = None,
max_turns: int = None,
verbose: bool = False,
verbose: Optional[bool] = None,
compact: bool = False,
resume: str = None,
checkpoints: bool = False,
@@ -2666,7 +2863,12 @@ class HermesCLI:
else:
self.busy_input_mode = "interrupt"
self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose")
# self.verbose ONLY controls global DEBUG logging (root logger level).
# display.tool_progress="verbose" controls tool-call rendering (full args,
# results, think blocks) and is independent — see _apply_logging_levels.
# Coupling the two (PR #6a1aa420e) caused all module DEBUG logs to spew
# to console whenever a user set tool_progress: verbose in config.
self.verbose = bool(verbose) if verbose is not None else False
# streaming: stream tokens to the terminal as they arrive (display.streaming in config.yaml)
self.streaming_enabled = CLI_CONFIG["display"].get("streaming", False)
@@ -2852,12 +3054,9 @@ class HermesCLI:
pass
# Fallback provider chain — tried in order when primary fails after retries.
# Supports new list format (fallback_providers) and legacy single-dict (fallback_model).
fb = CLI_CONFIG.get("fallback_providers") or CLI_CONFIG.get("fallback_model") or []
# Normalize legacy single-dict to a one-element list
if isinstance(fb, dict):
fb = [fb] if fb.get("provider") and fb.get("model") else []
self._fallback_model = fb
# Merge new ``fallback_providers`` entries with any legacy
# ``fallback_model`` entries so old configs still participate.
self._fallback_model = get_fallback_chain(CLI_CONFIG)
# Signature of the currently-initialised agent's runtime. Used to
# rebuild the agent when provider / model / base_url changes across
@@ -2865,7 +3064,9 @@ class HermesCLI:
self._active_agent_route_signature = None
# Agent will be initialized on first use
self.agent: Optional[AIAgent] = None
self.agent: Optional[Any] = None
self._tool_callbacks_installed = False
self._tirith_security_checked = False
self._app = None # prompt_toolkit Application (set in run())
# Conversation state
@@ -4463,6 +4664,41 @@ class HermesCLI:
route["request_overrides"] = overrides
return route
def _install_tool_callbacks(self) -> None:
"""Install tool callbacks that need the live prompt UI."""
if getattr(self, "_tool_callbacks_installed", False):
return
set_sudo_password_callback(self._sudo_password_callback)
set_approval_callback(self._approval_callback)
set_secret_capture_callback(self._secret_capture_callback)
try:
from tools.computer_use_tool import set_approval_callback as _set_cu_cb
_set_cu_cb(self._computer_use_approval_callback)
except ImportError:
pass
self._tool_callbacks_installed = True
def _ensure_tirith_security(self) -> None:
"""Check tirith availability once before tools can run terminal commands."""
if getattr(self, "_tirith_security_checked", False):
return
self._tirith_security_checked = True
try:
from tools.tirith_security import ensure_installed, is_platform_supported
tirith_path = ensure_installed(log_failures=False)
if tirith_path is None and is_platform_supported():
security_cfg = self.config.get("security", {}) or {}
tirith_enabled = security_cfg.get("tirith_enabled", True)
if tirith_enabled:
_cprint(
f" {_DIM}⚠ tirith security scanner enabled but not available "
f"— command scanning will use pattern matching only{_RST}"
)
except Exception:
pass
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool:
"""
Initialize the agent on first use.
@@ -4474,6 +4710,10 @@ class HermesCLI:
if self.agent is not None:
return True
_prepare_deferred_agent_startup()
self._install_tool_callbacks()
self._ensure_tirith_security()
if not self._ensure_runtime_credentials():
return False
@@ -4688,8 +4928,10 @@ class HermesCLI:
context_length=ctx_len,
)
# Show tool availability warnings if any tools are disabled
self._show_tool_availability_warnings()
# Tool discovery is intentionally deferred on the Termux bare prompt
# path; availability warnings are shown once tools are initialized.
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
self._show_tool_availability_warnings()
# Warn about very low context lengths (common with local servers)
if ctx_len and ctx_len <= 8192:
@@ -4827,10 +5069,13 @@ class HermesCLI:
if self.resume_display == "minimal":
return
MAX_DISPLAY_EXCHANGES = 10 # max user+assistant pairs to show
MAX_USER_LEN = 300 # truncate user messages
MAX_ASST_LEN = 200 # truncate assistant text
MAX_ASST_LINES = 3 # max lines of assistant text
# Read limits from config (with hardcoded defaults)
_disp = CLI_CONFIG.get("display", {})
MAX_DISPLAY_EXCHANGES = int(_disp.get("resume_exchanges", 10))
MAX_USER_LEN = int(_disp.get("resume_max_user_chars", 300))
MAX_ASST_LEN = int(_disp.get("resume_max_assistant_chars", 200))
MAX_ASST_LINES = int(_disp.get("resume_max_assistant_lines", 3))
SKIP_TOOL_ONLY = _disp.get("resume_skip_tool_only", True)
# Collect displayable entries (skip system, tool-result messages)
entries = [] # list of (role, display_text)
@@ -4893,6 +5138,10 @@ class HermesCLI:
if not parts:
# Skip pure-reasoning messages that have no visible output
continue
# Skip tool-call-only entries when SKIP_TOOL_ONLY is enabled
has_text = bool(text)
if SKIP_TOOL_ONLY and not has_text and tool_calls:
continue
entries.append(("assistant", " ".join(parts)))
_last_asst_idx = len(entries) - 1
_last_asst_full = " ".join(full_parts)
@@ -5466,9 +5715,13 @@ class HermesCLI:
def _show_status(self):
"""Show compact startup status line."""
# Get tool count
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
tool_count = len(tools) if tools else 0
# Avoid pulling the full tool registry into the bare Termux prompt path.
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") == "1":
tool_status = "tools deferred"
else:
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
tool_count = len(tools) if tools else 0
tool_status = f"{tool_count} tools"
# Format model name (shorten if needed)
model_short = self.model.split("/")[-1] if "/" in self.model else self.model
@@ -5500,7 +5753,7 @@ class HermesCLI:
self._console_print(
f" {api_indicator} [{accent_color}]{model_short}[/] "
f"[dim {separator_color}]·[/] [bold {label_color}]{tool_count} tools[/]"
f"[dim {separator_color}]·[/] [bold {label_color}]{tool_status}[/]"
f"{toolsets_info}{provider_info}"
)
@@ -5595,9 +5848,10 @@ class HermesCLI:
continue
ChatConsole().print(f" [bold {_accent_hex()}]{cmd:<15}[/] [dim]-[/] {_escape(desc)}")
if _skill_commands:
_cprint(f"\n{_BOLD}Skill Commands{_RST} ({len(_skill_commands)} installed):")
for cmd, info in sorted(_skill_commands.items()):
skill_commands = _ensure_skill_commands()
if skill_commands:
_cprint(f"\n{_BOLD}Skill Commands{_RST} ({len(skill_commands)} installed):")
for cmd, info in sorted(skill_commands.items()):
ChatConsole().print(
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}"
)
@@ -5875,15 +6129,16 @@ class HermesCLI:
else:
print(" Recent sessions:")
print()
print(f" {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
print(f" {'' * 32} {'' * 40} {'' * 13} {'' * 24}")
for session in sessions:
title = (session.get("title") or "")[:30]
print(f" {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
print(f" {'' * 3} {'' * 32} {'' * 40} {'' * 13} {'' * 24}")
for idx, session in enumerate(sessions, start=1):
title = session.get("title") or ""
preview = (session.get("preview") or "")[:38]
last_active = _relative_time(session.get("last_active"))
print(f" {title:<32} {preview:<40} {last_active:<13} {session['id']}")
print(f" {idx:<3} {title:<32} {preview:<40} {last_active:<13} {session['id']}")
print()
print(" Use /resume <session id or title> to continue where you left off.")
print(" Use /resume <number>, /resume <session id>, or /resume <session title> to continue.")
print(" Example: /resume 2")
print()
return True
@@ -5994,6 +6249,7 @@ class HermesCLI:
self.conversation_history = []
self._pending_title = None
self._resumed = False
_sync_process_session_id(self.session_id)
if self.agent:
self.agent.session_id = self.session_id
@@ -6227,7 +6483,7 @@ class HermesCLI:
target = parts[1].strip() if len(parts) > 1 else ""
if not target:
_cprint(" Usage: /resume <session_id_or_title>")
_cprint(" Usage: /resume <number|session_id_or_title>")
if self._show_recent_sessions(reason="resume"):
return
_cprint(" Tip: Use /history or `hermes sessions list` to find sessions.")
@@ -6238,10 +6494,20 @@ class HermesCLI:
_cprint(f" {format_session_db_unavailable()}")
return
# Resolve title or ID
from hermes_cli.main import _resolve_session_by_name_or_id
resolved = _resolve_session_by_name_or_id(target)
target_id = resolved or target
# Resolve numbered selection, title, or ID
if target.isdigit():
sessions = self._list_recent_sessions(limit=10)
index = int(target)
if index < 1 or index > len(sessions):
_cprint(f" Resume index {index} is out of range.")
_cprint(" Use /resume with no arguments to see available sessions.")
return
selected = sessions[index - 1]
target_id = selected["id"]
else:
from hermes_cli.main import _resolve_session_by_name_or_id
resolved = _resolve_session_by_name_or_id(target)
target_id = resolved or target
session_meta = self._session_db.get_session(target_id)
if not session_meta:
@@ -6280,6 +6546,7 @@ class HermesCLI:
self.session_id = target_id
self._resumed = True
self._pending_title = None
_sync_process_session_id(target_id)
# Load conversation history (strip transcript-only metadata entries)
restored = self._session_db.get_messages_as_conversation(target_id)
@@ -6331,6 +6598,7 @@ class HermesCLI:
f" ({msg_count} user message{'s' if msg_count != 1 else ''},"
f" {len(self.conversation_history)} total)"
)
self._display_resumed_history()
else:
_cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.")
@@ -6453,6 +6721,7 @@ class HermesCLI:
self.session_start = now
self._pending_title = None
self._resumed = True # Prevents auto-title generation
_sync_process_session_id(new_session_id)
# Sync the agent
if self.agent:
@@ -7814,6 +8083,7 @@ class HermesCLI:
"clear",
"This clears the screen and starts a new session.\n"
"The current conversation history will be discarded.",
cmd_original=cmd_original,
) is None:
return
self.new_session(silent=True)
@@ -7938,12 +8208,16 @@ class HermesCLI:
if not self._handle_handoff_command(cmd_original):
return False
elif canonical == "new":
parts = cmd_original.split(maxsplit=1)
title = parts[1].strip() if len(parts) > 1 else None
# Strip inline-skip tokens (now/--yes/-y) before deriving the title
# so "/new now My Session" yields title="My Session" instead of
# title="now My Session". See _split_destructive_skip.
_new_args, _ = self._split_destructive_skip(cmd_original)
title = _new_args.strip() or None
if self._confirm_destructive_slash(
"new",
"This starts a fresh session.\n"
"The current conversation history will be discarded.",
cmd_original=cmd_original,
) is None:
return
self.new_session(title=title)
@@ -7970,6 +8244,7 @@ class HermesCLI:
if self._confirm_destructive_slash(
"undo",
"This removes the last user/assistant exchange from history.",
cmd_original=cmd_original,
) is None:
return
self.undo_last()
@@ -8118,6 +8393,8 @@ class HermesCLI:
else:
# Check for user-defined quick commands (bypass agent loop, no LLM call)
base_cmd = cmd_lower.split()[0]
skill_commands = _ensure_skill_commands()
skill_bundles = get_skill_bundles()
quick_commands = self.config.get("quick_commands", {})
if base_cmd.lstrip("/") in quick_commands:
qcmd = quick_commands[base_cmd.lstrip("/")]
@@ -8173,14 +8450,14 @@ class HermesCLI:
_cprint(f"\033[1;31mPlugin command error: {e}{_RST}")
# Skill bundles take precedence over individual skills — /<bundle>
# loads multiple skills at once. Rescans cheaply when files change.
elif base_cmd in get_skill_bundles():
elif base_cmd in skill_bundles:
user_instruction = cmd_original[len(base_cmd):].strip()
bundle_result = build_bundle_invocation_message(
base_cmd, user_instruction, task_id=self.session_id
)
if bundle_result:
msg, loaded_names, missing = bundle_result
bundle_info = get_skill_bundles()[base_cmd]
bundle_info = skill_bundles[base_cmd]
print(
f"\n⚡ Loading bundle: {bundle_info['name']} "
f"({len(loaded_names)} skills)"
@@ -8196,13 +8473,13 @@ class HermesCLI:
f"[bold red]Failed to load bundle for {base_cmd}[/]"
)
# Check for skill slash commands (/gif-search, /axolotl, etc.)
elif base_cmd in _skill_commands:
elif base_cmd in skill_commands:
user_instruction = cmd_original[len(base_cmd):].strip()
msg = build_skill_invocation_message(
base_cmd, user_instruction, task_id=self.session_id
)
if msg:
skill_name = _skill_commands[base_cmd]["name"]
skill_name = skill_commands[base_cmd]["name"]
print(f"\n⚡ Loading skill: {skill_name}")
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
@@ -8214,7 +8491,7 @@ class HermesCLI:
# that execution-time resolution agrees with tab-completion.
from hermes_cli.commands import COMMANDS
typed_base = cmd_lower.split()[0]
all_known = set(COMMANDS) | set(_skill_commands) | set(get_skill_bundles())
all_known = set(COMMANDS) | set(skill_commands) | set(skill_bundles)
matches = [c for c in all_known if c.startswith(typed_base)]
if len(matches) > 1:
# Prefer an exact match (typed the full command name)
@@ -9045,18 +9322,23 @@ class HermesCLI:
_cprint(" Failed to save runtime_footer setting to config.yaml")
def _toggle_verbose(self):
"""Cycle tool progress mode: off → new → all → verbose → off."""
"""Cycle tool progress mode: off → new → all → verbose → off.
Tool-progress display (full args / results / think blocks at the
``verbose`` step) is INDEPENDENT of global DEBUG logging. Cycling
through here does not change ``self.verbose`` or the agent's
``verbose_logging`` / ``quiet_mode`` those remain under the
explicit ``-v``/``--verbose`` flag and the ``/verbose-logging``
toggle. See PR #6a1aa420e for the history that decoupled them.
"""
cycle = ["off", "new", "all", "verbose"]
try:
idx = cycle.index(self.tool_progress_mode)
except ValueError:
idx = 2 # default to "all"
self.tool_progress_mode = cycle[(idx + 1) % len(cycle)]
self.verbose = self.tool_progress_mode == "verbose"
if self.agent:
self.agent.verbose_logging = self.verbose
self.agent.quiet_mode = not self.verbose
self.agent.reasoning_callback = self._current_reasoning_callback()
# Use raw ANSI codes via _cprint so the output is routed through
@@ -9068,7 +9350,7 @@ class HermesCLI:
"off": f"{_Colors.DIM}Tool progress: OFF{_Colors.RESET} — silent mode, just the final response.",
"new": f"{_Colors.YELLOW}Tool progress: NEW{_Colors.RESET} — show each new tool (skip repeats).",
"all": f"{_Colors.GREEN}Tool progress: ALL{_Colors.RESET} — show every tool call.",
"verbose": f"{_Colors.BOLD}{_Colors.GREEN}Tool progress: VERBOSE{_Colors.RESET} — full args, results, think blocks, and debug logs.",
"verbose": f"{_Colors.BOLD}{_Colors.GREEN}Tool progress: VERBOSE{_Colors.RESET} — full args, results, and think blocks.",
}
_cprint(labels.get(self.tool_progress_mode, ""))
@@ -9614,7 +9896,49 @@ class HermesCLI:
if _reload_thread.is_alive():
print(" ⚠️ MCP reload timed out (30s). Some servers may not have reconnected.")
def _confirm_destructive_slash(self, command: str, detail: str) -> Optional[str]:
# Inline-skip tokens that bypass the destructive-slash confirmation modal.
# Matches the escape-hatch pattern users on broken modal platforms
# (currently native Windows PowerShell — issue #30768) need to self-serve
# without having to flip approvals.destructive_slash_confirm in config.
_DESTRUCTIVE_SKIP_TOKENS = frozenset({"now", "--yes", "-y"})
@classmethod
def _split_destructive_skip(cls, cmd_text: Optional[str]) -> tuple[str, bool]:
"""Split inline-skip tokens out of a destructive slash command.
Returns ``(remainder, skip)`` where ``remainder`` is the original
text with the command word and any recognized skip tokens removed,
and ``skip`` is True iff at least one skip token was found.
Examples:
"/reset now" -> ("", True)
"/reset --yes My title" -> ("My title", True)
"/new My title" -> ("My title", False)
"/clear" -> ("", False)
"""
if not cmd_text:
return "", False
tokens = cmd_text.strip().split()
if not tokens:
return "", False
# Drop leading "/cmd" word — callers pass the full command text.
if tokens[0].startswith("/"):
tokens = tokens[1:]
skip = False
kept: list[str] = []
for tok in tokens:
if tok.lower() in cls._DESTRUCTIVE_SKIP_TOKENS:
skip = True
continue
kept.append(tok)
return " ".join(kept), skip
def _confirm_destructive_slash(
self,
command: str,
detail: str,
cmd_original: Optional[str] = None,
) -> Optional[str]:
"""Prompt the user to confirm a destructive session slash command.
Used by ``/clear``, ``/new``/``/reset``, and ``/undo`` before they
@@ -9630,9 +9954,24 @@ class HermesCLI:
gate is off the function returns ``"once"`` immediately without
prompting.
Inline-skip: if ``cmd_original`` contains ``now``, ``--yes``, or
``-y`` as an argument (e.g. ``/reset now``, ``/new --yes My title``),
the modal is bypassed and ``"once"`` is returned immediately. This is
an escape hatch for platforms where the prompt_toolkit modal hangs
(issue #30768 — native Windows PowerShell). Callers are responsible
for stripping the skip tokens from any remaining argument parsing
(see :meth:`_split_destructive_skip`).
Returns ``"once"``, ``"always"``, or ``None`` (cancelled). Callers
proceed with the destructive action when the result is non-None.
"""
# Inline-skip escape hatch — works regardless of platform/modal state.
# See class-level _DESTRUCTIVE_SKIP_TOKENS for the accepted tokens.
if cmd_original:
_, _skip = self._split_destructive_skip(cmd_original)
if _skip:
return "once"
# Gate check — respects prior "Always Approve" clicks.
try:
cfg = load_cli_config()
@@ -9967,9 +10306,7 @@ class HermesCLI:
self._last_scrollback_tool = function_name
try:
from agent.display import get_cute_tool_message
line = get_cute_tool_message(function_name, stored_args, duration)
if is_error:
line = f"{line} [error]"
line = get_cute_tool_message(function_name, stored_args, duration, result=kwargs.get("result"))
_cprint(f" {line}")
except Exception:
pass
@@ -10178,6 +10515,7 @@ class HermesCLI:
self._voice_processing = True
submitted = False
transcription_failed = False
wav_path = None
try:
if self._voice_recorder is None:
@@ -10226,18 +10564,24 @@ class HermesCLI:
else:
error = result.get("error", "Unknown error")
_cprint(f"\n{_DIM}Transcription failed: {error}{_RST}")
transcription_failed = True
except Exception as e:
_cprint(f"\n{_DIM}Voice processing error: {e}{_RST}")
transcription_failed = wav_path is not None
finally:
with self._voice_lock:
self._voice_processing = False
if hasattr(self, '_app') and self._app:
self._app.invalidate()
# Clean up temp file
# Clean up temp file unless transcription failed. On failure, keep
# the source recording so long dictation is not lost.
try:
if wav_path and os.path.isfile(wav_path):
os.unlink(wav_path)
if transcription_failed:
_cprint(f"{_DIM}Recording preserved at: {wav_path}{_RST}")
else:
os.unlink(wav_path)
except Exception:
pass
@@ -11973,37 +12317,11 @@ class HermesCLI:
self._voice_tts_done = threading.Event() # Signals TTS playback finished
self._voice_tts_done.set() # Initially "done" (no TTS pending)
# Register callbacks so terminal_tool prompts route through our UI
set_sudo_password_callback(self._sudo_password_callback)
set_approval_callback(self._approval_callback)
set_secret_capture_callback(self._secret_capture_callback)
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
self._install_tool_callbacks()
# Computer-use shares the same approval UI (prompt_toolkit dialog).
# The tool handler expects a 3-arg callback (action, args, summary)
# and returns "approve_once" | "approve_session" | "always_approve"
# | "deny". Adapt our existing generic callback.
try:
from tools.computer_use_tool import set_approval_callback as _set_cu_cb
_set_cu_cb(self._computer_use_approval_callback)
except ImportError:
pass # computer_use extras not installed
# Ensure tirith security scanner is available (downloads if needed).
# Warn the user if tirith is enabled in config but not available,
# so they know command security scanning is degraded. Suppressed
# on platforms where tirith ships no binary (Windows etc.) — the
# user can't act on it and pattern-matching guards still run.
try:
from tools.tirith_security import ensure_installed, is_platform_supported
tirith_path = ensure_installed(log_failures=False)
if tirith_path is None and is_platform_supported():
security_cfg = self.config.get("security", {}) or {}
tirith_enabled = security_cfg.get("tirith_enabled", True)
if tirith_enabled:
_cprint(f" {_DIM}⚠ tirith security scanner enabled but not available "
f"— command scanning will use pattern matching only{_RST}")
except Exception:
pass # Non-fatal — fail-open at scan time if unavailable
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
self._ensure_tirith_security()
# Key bindings for the input area
kb = KeyBindings()
@@ -14161,7 +14479,7 @@ def main(
api_key: str = None,
base_url: str = None,
max_turns: int = None,
verbose: bool = False,
verbose: Optional[bool] = None,
quiet: bool = False,
compact: bool = False,
list_tools: bool = False,
@@ -14507,4 +14825,6 @@ def main(
if __name__ == "__main__":
import fire
fire.Fire(main)
+4 -1
View File
@@ -529,7 +529,9 @@ def _send_media_via_adapter(
"""
from pathlib import Path
from gateway.platforms.base import should_send_media_as_audio
from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
for media_path, _is_voice in media_files:
try:
@@ -614,6 +616,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
# Extract MEDIA: tags so attachments are forwarded as files, not raw text
from gateway.platforms.base import BasePlatformAdapter
media_files, cleaned_delivery_content = BasePlatformAdapter.extract_media(delivery_content)
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
try:
config = load_gateway_config()
+10 -5
View File
@@ -6,17 +6,22 @@
#
# Set HERMES_UID / HERMES_GID to the host user that owns ~/.hermes so
# files created inside the container stay readable/writable on the host.
# The entrypoint remaps the internal `hermes` user to these values via
# usermod/groupmod + gosu.
# The s6-overlay stage2 hook remaps the internal `hermes` user to these
# values via usermod/groupmod; each supervised service then drops to that
# user via `s6-setuidgid`.
#
# Security notes:
# - The dashboard service binds to 127.0.0.1 by default. It stores API
# keys; exposing it on LAN without auth is unsafe. If you want remote
# access, use an SSH tunnel or put it behind a reverse proxy that
# adds authentication — do NOT pass --insecure --host 0.0.0.0.
# - If you override entrypoint, keep /opt/hermes/docker/entrypoint.sh in
# the command chain. It drops root to the hermes user before gateway
# files such as gateway.lock are created.
# - If you override entrypoint, keep `/init` as the first command in
# the chain (or let docker use the image's default ENTRYPOINT,
# which is `["/init", "/opt/hermes/docker/main-wrapper.sh"]`).
# `/init` is s6-overlay's PID 1 — it runs the cont-init.d scripts
# (chown, profile reconcile, dashboard toggle) and sets up the
# supervision tree before any service starts. Bypassing it skips
# all of that setup and the gateway will not work correctly.
# - The gateway's API server is off unless you uncomment API_SERVER_KEY
# and API_SERVER_HOST. See docs/user-guide/api-server.md before doing
# this on an internet-facing host.
+90
View File
@@ -0,0 +1,90 @@
#!/command/with-contenv sh
# shellcheck shell=sh
# Make supervise/ trees for ALL declared s6 services queryable and
# controllable by the unprivileged hermes user (UID 10000).
#
# Background (PR #30136 review item I4): the entire s6 lifecycle
# (s6-svc, s6-svstat, s6-svwait) is dispatched as the hermes user
# inside the container (every Hermes runtime path runs under
# ``s6-setuidgid hermes``). But s6-supervise creates each service's
# ``supervise/`` and top-level ``event/`` directory with mode 0700
# owned by its effective UID — which is root, because s6-supervise
# is spawned by s6-svscan running as PID 1. So unprivileged clients
# get EACCES on every probe / control call against the slot.
#
# Two fixes, one in each registration path:
#
# 1. For RUNTIME-registered profile gateways (created via the s6
# runtime register hooks in profiles.py): the Python helper
# ``_seed_supervise_skeleton`` pre-creates supervise/ + event/ +
# supervise/control owned by hermes BEFORE s6-svscanctl -a fires.
# s6-supervise's mkdir/mkfifo are EEXIST-safe, so it inherits our
# ownership and never tries to chown back to root.
#
# 2. For STATIC s6-rc services (dashboard, main-hermes) declared at
# image-build time under /etc/s6-overlay/s6-rc.d/*: these are
# compiled by s6-rc at boot, and s6-supervise spawns BEFORE
# cont-init.d gets to run — so by the time we're here, the
# supervise/ tree is already there as root:root 0700. We chown
# it here. s6-supervise will keep using the same files; it never
# re-asserts ownership on a running service.
#
# This script runs as root after 01-hermes-setup but before
# 02-reconcile-profiles, so the chowns are settled before the
# Python reconciler walks the scandir. Lexicographic ordering
# guarantees this — the suffix is unusual because we want to slot
# in between 01 and the existing 02-reconcile-profiles without
# renumbering both (which would be a churn-noise patch on its own).
set -eu
# /run/s6-rc/servicedirs holds the live, compiled service directories
# for every static (s6-rc) service. Symlinks under /run/service/*
# point here. Per-service supervise/ + event/ both need hermes
# ownership for s6-svstat etc. to work as hermes.
SVC_ROOT=/run/s6-rc/servicedirs
if [ ! -d "$SVC_ROOT" ]; then
echo "[supervise-perms] $SVC_ROOT not present; skipping"
exit 0
fi
for svc in "$SVC_ROOT"/*; do
[ -d "$svc" ] || continue
name=$(basename "$svc")
# Skip s6-overlay-internal services (they need to stay root-only;
# the s6rc-* helpers manage the supervision tree itself).
case "$name" in
s6rc-*|s6-linux-*)
continue
;;
esac
# supervise/ tree — needed by s6-svstat / s6-svc.
if [ -d "$svc/supervise" ]; then
chown -R hermes:hermes "$svc/supervise" 2>/dev/null || \
echo "[supervise-perms] could not chown $svc/supervise"
# 0710 = group searchable. ``s6-svstat`` only needs to openat
# status, not list the dir, but giving the hermes group +x is
# the minimum that lets group members access the contents.
chmod 0710 "$svc/supervise" 2>/dev/null || true
# supervise/control is a FIFO that s6-svc writes commands
# into; the hermes user needs +w. Owner is already hermes
# after the recursive chown above; widen perms to 0660 so
# ``s6-svc`` works for any member of the hermes group too.
if [ -p "$svc/supervise/control" ]; then
chmod 0660 "$svc/supervise/control" 2>/dev/null || true
fi
fi
# Top-level event/ dir — s6-svlisten1 / s6-svwait subscribe here.
if [ -d "$svc/event" ]; then
chown hermes:hermes "$svc/event" 2>/dev/null || \
echo "[supervise-perms] could not chown $svc/event"
# Preserve s6's 03730 mode (setgid + g+rwx + sticky).
chmod 03730 "$svc/event" 2>/dev/null || true
fi
done
echo "[supervise-perms] chowned supervise/ trees for static s6-rc services"
+45
View File
@@ -0,0 +1,45 @@
#!/command/with-contenv sh
# shellcheck shell=sh
# Container-boot reconciliation of per-profile gateway s6 services.
#
# Runs as root after 01-hermes-setup (the stage2 hook) has chowned
# the volume and seeded $HERMES_HOME, but before s6-rc starts user
# services. /etc/cont-init.d/* scripts run in lexicographic order,
# so the `02-` prefix guarantees ordering.
#
# Service directories under /run/service/ live on tmpfs and are
# wiped on every container restart. Profile directories under
# $HERMES_HOME/profiles/ live on the persistent VOLUME. This script
# walks the persistent profiles, recreates the s6 service slots,
# and auto-starts only those whose last recorded state was
# `running` — see hermes_cli/container_boot.py.
#
# Phase 4 also needs hermes-user writes to /run/service/ (so the
# profile create/delete hooks can register/unregister at runtime),
# so we chown the scandir before invoking the reconciler. We
# additionally chown the s6-svscan control FIFO so the hermes user
# can send rescan signals via ``s6-svscanctl -a``; without this the
# entire runtime-registration path is inert under UID 10000 (the
# Python wrapper catches the resulting EACCES, prints a warning,
# and swallows the failure).
set -e
# Make the dynamic scandir hermes-writable. The directory itself
# starts root-owned by s6-overlay.
chown hermes:hermes /run/service 2>/dev/null || true
# Make the svscan control FIFO hermes-writable so s6-svscanctl -a
# / -an work for the hermes user. The FIFO is created by s6-svscan
# at PID-1 startup, so by the time this cont-init.d script runs it
# already exists. Both ``control`` and ``lock`` need to be writable
# for the various svscanctl operations; the directory itself stays
# root-owned (we only need to touch the two FIFOs/locks inside).
if [ -d /run/service/.s6-svscan ]; then
for entry in control lock; do
if [ -e "/run/service/.s6-svscan/$entry" ]; then
chown hermes:hermes "/run/service/.s6-svscan/$entry" 2>/dev/null || true
fi
done
fi
exec s6-setuidgid hermes /opt/hermes/.venv/bin/python -m hermes_cli.container_boot

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